feat: add fluxer upstream source and self-hosting documentation
- Clone of github.com/fluxerapp/fluxer (official upstream) - SELF_HOSTING.md: full VM rebuild procedure, architecture overview, service reference, step-by-step setup, troubleshooting, seattle reference - dev/.env.example: all env vars with secrets redacted and generation instructions - dev/livekit.yaml: LiveKit config template with placeholder keys - fluxer-seattle/: existing seattle deployment setup scripts
This commit is contained in:
364
fluxer/fluxer_app/scripts/DevServer.tsx
Normal file
364
fluxer/fluxer_app/scripts/DevServer.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChildProcess, spawn} from 'node:child_process';
|
||||
import type {Dirent} from 'node:fs';
|
||||
import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
const metadataFile = path.join(projectRoot, '.devserver-cache.json');
|
||||
const binDir = path.join(projectRoot, 'node_modules', '.bin');
|
||||
const rspackBin = path.join(binDir, 'rspack');
|
||||
const tcmBin = path.join(binDir, 'tcm');
|
||||
const DEFAULT_SKIP_DIRS = new Set(['.git', 'node_modules', '.turbo', 'dist', 'target', 'pkg', 'pkgs']);
|
||||
let metadataCache: Metadata | null = null;
|
||||
|
||||
interface StepMetadata {
|
||||
lastRun: number;
|
||||
inputs: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
[key: string]: StepMetadata;
|
||||
}
|
||||
|
||||
type StepKey = 'wasm' | 'colors' | 'masks' | 'cssTypes' | 'lingui';
|
||||
|
||||
async function loadMetadata(): Promise<void> {
|
||||
if (metadataCache !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(metadataFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
metadataCache = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Metadata;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
metadataCache = {};
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to read dev server metadata cache, falling back to full rebuild:', error);
|
||||
metadataCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMetadata(): Promise<void> {
|
||||
if (!metadataCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(metadataFile), {recursive: true});
|
||||
await writeFile(metadataFile, JSON.stringify(metadataCache, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function haveInputsChanged(prev: Record<string, number>, next: Record<string, number>): boolean {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(next);
|
||||
if (prevKeys.length !== nextKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key of nextKeys) {
|
||||
if (!Object.hasOwn(prev, key) || prev[key] !== next[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldRunStep(stepName: StepKey, inputs: Record<string, number>): boolean {
|
||||
if (!metadataCache) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = metadataCache[stepName];
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return haveInputsChanged(entry.inputs, inputs);
|
||||
}
|
||||
|
||||
async function collectFileStats(paths: ReadonlyArray<string>): Promise<Record<string, number>> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const relPath of paths) {
|
||||
const absolutePath = path.join(projectRoot, relPath);
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(`Expected ${relPath} to be a file when collecting dev server cache inputs.`);
|
||||
}
|
||||
result[relPath] = fileStat.mtimeMs;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectDirectoryStats(
|
||||
rootRel: string,
|
||||
predicate: (relPath: string) => boolean,
|
||||
): Promise<Record<string, number>> {
|
||||
const accumulator: Record<string, number> = {};
|
||||
|
||||
async function walk(relPath: string): Promise<void> {
|
||||
const absoluteDir = path.join(projectRoot, relPath);
|
||||
let entries: Array<Dirent>;
|
||||
try {
|
||||
entries = await readdir(absoluteDir, {withFileTypes: true});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (DEFAULT_SKIP_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walk(path.join(relPath, entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileRel = path.join(relPath, entry.name);
|
||||
if (!predicate(fileRel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileStat = await stat(path.join(projectRoot, fileRel));
|
||||
accumulator[fileRel] = fileStat.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootRel);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
async function runCachedStep(
|
||||
stepName: StepKey,
|
||||
gatherInputs: () => Promise<Record<string, number>>,
|
||||
command: string,
|
||||
args: ReadonlyArray<string>,
|
||||
): Promise<void> {
|
||||
const inputs = await gatherInputs();
|
||||
if (!shouldRunStep(stepName, inputs)) {
|
||||
console.log(`Skipping ${command} ${args.join(' ')} (no changes detected)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand(command, args);
|
||||
|
||||
metadataCache ??= {};
|
||||
metadataCache[stepName] = {lastRun: Date.now(), inputs};
|
||||
await saveMetadata();
|
||||
}
|
||||
|
||||
async function gatherWasmInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats(path.join('crates', 'libfluxcore'), () => true);
|
||||
}
|
||||
|
||||
async function gatherColorInputs(): Promise<Record<string, number>> {
|
||||
return collectFileStats(['scripts/GenerateColorSystem.tsx']);
|
||||
}
|
||||
|
||||
async function gatherMaskInputs(): Promise<Record<string, number>> {
|
||||
return collectFileStats(['scripts/GenerateAvatarMasks.tsx', 'src/components/uikit/TypingConstants.tsx']);
|
||||
}
|
||||
|
||||
async function gatherCssModuleInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats('src', (relPath) => relPath.endsWith('.module.css'));
|
||||
}
|
||||
|
||||
async function gatherLinguiInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats(path.join('src', 'locales'), (relPath) => relPath.endsWith('.po'));
|
||||
}
|
||||
|
||||
let currentChild: ChildProcess | null = null;
|
||||
let cssTypeWatcher: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdownSignals: ReadonlyArray<NodeJS.Signals> = ['SIGINT', 'SIGTERM'];
|
||||
|
||||
function handleShutdown(signal: NodeJS.Signals): void {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
console.log(`\nReceived ${signal}, shutting down fluxer app dev server...`);
|
||||
currentChild?.kill('SIGTERM');
|
||||
cssTypeWatcher?.kill('SIGTERM');
|
||||
}
|
||||
|
||||
shutdownSignals.forEach((signal) => {
|
||||
process.on(signal, () => handleShutdown(signal));
|
||||
});
|
||||
|
||||
function runCommand(command: string, args: ReadonlyArray<string>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (shuttingDown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
currentChild = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
currentChild = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
currentChild = null;
|
||||
|
||||
if (shuttingDown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`${command} ${args.join(' ')} terminated by signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && code !== 0) {
|
||||
reject(new Error(`${command} ${args.join(' ')} exited with status ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanDist(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distPath = path.join(projectRoot, 'dist');
|
||||
await rm(distPath, {recursive: true, force: true});
|
||||
}
|
||||
|
||||
function startCssTypeWatcher(): void {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(tcmBin, ['src', '--pattern', '**/*.module.css', '--watch', '--silent'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
cssTypeWatcher = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
if (!shuttingDown) {
|
||||
console.error('CSS type watcher error:', error);
|
||||
}
|
||||
cssTypeWatcher = null;
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
cssTypeWatcher = null;
|
||||
if (!shuttingDown && code !== 0) {
|
||||
console.error(`CSS type watcher exited unexpectedly (code: ${code}, signal: ${signal})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runRspack(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (shuttingDown) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(rspackBin, ['serve', '--mode', 'development'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
currentChild = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
currentChild = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
currentChild = null;
|
||||
|
||||
if (shuttingDown) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`rspack serve terminated by signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadMetadata();
|
||||
|
||||
try {
|
||||
await runCachedStep('wasm', gatherWasmInputs, 'pnpm', ['wasm:codegen']);
|
||||
await runCachedStep('colors', gatherColorInputs, 'pnpm', ['generate:colors']);
|
||||
await runCachedStep('masks', gatherMaskInputs, 'pnpm', ['generate:masks']);
|
||||
await runCachedStep('cssTypes', gatherCssModuleInputs, 'pnpm', ['generate:css-types']);
|
||||
await runCachedStep('lingui', gatherLinguiInputs, 'pnpm', ['lingui:compile']);
|
||||
await cleanDist();
|
||||
|
||||
startCssTypeWatcher();
|
||||
|
||||
const rspackExitCode = await runRspack();
|
||||
|
||||
if (!shuttingDown && rspackExitCode !== 0) {
|
||||
process.exit(rspackExitCode);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shuttingDown) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
584
fluxer/fluxer_app/scripts/GenerateAvatarMasks.tsx
Normal file
584
fluxer/fluxer_app/scripts/GenerateAvatarMasks.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '@app/components/uikit/TypingConstants';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 44 | 48 | 56 | 80 | 120;
|
||||
|
||||
interface StatusConfig {
|
||||
statusSize: number;
|
||||
cutoutRadius: number;
|
||||
cutoutCenter: number;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
|
||||
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
|
||||
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
|
||||
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
|
||||
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
|
||||
40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34},
|
||||
44: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 38},
|
||||
48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42},
|
||||
56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49},
|
||||
80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68},
|
||||
120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100},
|
||||
};
|
||||
|
||||
const DESIGN_RULES = {
|
||||
mobileAspectRatio: 0.75,
|
||||
mobileCornerRadius: 0.12,
|
||||
mobileScreenWidth: 0.72,
|
||||
mobileScreenHeight: 0.7,
|
||||
mobileScreenY: 0.06,
|
||||
mobileWheelRadius: 0.13,
|
||||
mobileWheelY: 0.83,
|
||||
|
||||
mobilePhoneExtraHeight: 2,
|
||||
mobileDisplayExtraHeight: 2,
|
||||
mobileDisplayExtraWidthPerSide: 2,
|
||||
|
||||
idle: {
|
||||
cutoutRadiusRatio: 0.7,
|
||||
cutoutOffsetRatio: 0.35,
|
||||
},
|
||||
dnd: {
|
||||
barWidthRatio: 1.3,
|
||||
barHeightRatio: 0.4,
|
||||
minBarHeight: 2,
|
||||
},
|
||||
offline: {
|
||||
innerRingRatio: 0.6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MOBILE_SCREEN_WIDTH_TRIM_PX = 4;
|
||||
const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2;
|
||||
const MOBILE_SCREEN_X_OFFSET_PX = 0;
|
||||
const MOBILE_SCREEN_Y_OFFSET_PX = 3;
|
||||
|
||||
function getStatusConfig(avatarSize: number): StatusConfig {
|
||||
if (STATUS_CONFIG[avatarSize]) {
|
||||
return STATUS_CONFIG[avatarSize];
|
||||
}
|
||||
const sizes = Object.keys(STATUS_CONFIG)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const closest = sizes.reduce((prev, curr) =>
|
||||
Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev,
|
||||
);
|
||||
return STATUS_CONFIG[closest];
|
||||
}
|
||||
|
||||
interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
borderWidth: number;
|
||||
}
|
||||
|
||||
interface MobileStatusGeometry extends StatusGeometry {
|
||||
phoneWidth: number;
|
||||
phoneHeight: number;
|
||||
phoneX: number;
|
||||
phoneY: number;
|
||||
phoneRx: number;
|
||||
bezelHeight: number;
|
||||
}
|
||||
|
||||
function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry {
|
||||
const config = getStatusConfig(avatarSize);
|
||||
|
||||
const statusSize = config.statusSize;
|
||||
const cutoutCenter = config.cutoutCenter;
|
||||
const cutoutRadius = config.cutoutRadius;
|
||||
|
||||
const innerRadius = statusSize / 2;
|
||||
const outerRadius = cutoutRadius;
|
||||
const borderWidth = cutoutRadius - innerRadius;
|
||||
|
||||
const baseGeometry = {
|
||||
size: statusSize,
|
||||
cx: cutoutCenter,
|
||||
cy: cutoutCenter,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
borderWidth,
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
return baseGeometry;
|
||||
}
|
||||
|
||||
const phoneWidth = statusSize;
|
||||
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
|
||||
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
|
||||
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
|
||||
|
||||
const phoneX = cutoutCenter - phoneWidth / 2;
|
||||
const phoneY = cutoutCenter - phoneHeight / 2;
|
||||
|
||||
return {
|
||||
...baseGeometry,
|
||||
phoneWidth,
|
||||
phoneHeight,
|
||||
phoneX,
|
||||
phoneY,
|
||||
phoneRx,
|
||||
bezelHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAvatarMaskDefault(size: number): string {
|
||||
const r = size / 2;
|
||||
return `<circle fill="white" cx="${r}" cy="${r}" r="${r}" />`;
|
||||
}
|
||||
|
||||
function generateAvatarMaskStatusRound(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateAvatarMaskStatusTyping(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const typingRx = status.outerRadius;
|
||||
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidth =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
MOBILE_SCREEN_WIDTH_TRIM_PX;
|
||||
const screenHeight =
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
|
||||
const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
|
||||
const screenY =
|
||||
mobileStatus.phoneY +
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
const screenRx = Math.min(screenWidth, screenHeight) * 0.1;
|
||||
|
||||
const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius;
|
||||
const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2;
|
||||
const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<rect fill="white" x="${mobileStatus.phoneX}" y="${mobileStatus.phoneY}" width="${mobileStatus.phoneWidth}" height="${mobileStatus.phoneHeight}" rx="${mobileStatus.phoneRx}" ry="${mobileStatus.phoneRx}" />
|
||||
<rect fill="black" x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}" rx="${screenRx}" ry="${screenRx}" />
|
||||
<circle fill="black" cx="${wheelCx}" cy="${wheelCy}" r="${wheelRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateStatusOnline(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
return `<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusIdle(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio);
|
||||
const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio);
|
||||
const cutoutCx = status.cx - cutoutOffsetDistance;
|
||||
const cutoutCy = status.cy - cutoutOffsetDistance;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<circle fill="black" cx="${cutoutCx}" cy="${cutoutCy}" r="${cutoutRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusDnd(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio);
|
||||
const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio;
|
||||
const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight));
|
||||
const barX = status.cx - barWidth / 2;
|
||||
const barY = status.cy - barHeight / 2;
|
||||
const barRx = barHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<rect fill="black" x="${barX}" y="${barY}" width="${barWidth}" height="${barHeight}" rx="${barRx}" ry="${barRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusOffline(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${innerRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateStatusTyping(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const rx = status.outerRadius;
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
|
||||
}
|
||||
|
||||
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120];
|
||||
|
||||
let output = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
type AvatarSize = ${SIZES.join(' | ')};
|
||||
|
||||
interface MaskDefinition {
|
||||
viewBox: string;
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
interface MaskSet {
|
||||
avatarDefault: MaskDefinition;
|
||||
avatarStatusRound: MaskDefinition;
|
||||
avatarStatusTyping: MaskDefinition;
|
||||
statusOnline: MaskDefinition;
|
||||
statusOnlineMobile: MaskDefinition;
|
||||
statusIdle: MaskDefinition;
|
||||
statusIdleMobile: MaskDefinition;
|
||||
statusDnd: MaskDefinition;
|
||||
statusDndMobile: MaskDefinition;
|
||||
statusOffline: MaskDefinition;
|
||||
statusTyping: MaskDefinition;
|
||||
}
|
||||
|
||||
export const AVATAR_MASKS: Record<AvatarSize, MaskSet> = {
|
||||
`;
|
||||
|
||||
for (const size of SIZES) {
|
||||
output += ` ${size}: {
|
||||
avatarDefault: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskDefault(size)},
|
||||
},
|
||||
avatarStatusRound: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskStatusRound(size)},
|
||||
},
|
||||
avatarStatusTyping: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskStatusTyping(size)},
|
||||
},
|
||||
statusOnline: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOnline(size, false)},
|
||||
},
|
||||
statusOnlineMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOnline(size, true)},
|
||||
},
|
||||
statusIdle: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusIdle(size, false)},
|
||||
},
|
||||
statusIdleMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusIdle(size, true)},
|
||||
},
|
||||
statusDnd: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusDnd(size, false)},
|
||||
},
|
||||
statusDndMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusDnd(size, true)},
|
||||
},
|
||||
statusOffline: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOffline(size)},
|
||||
},
|
||||
statusTyping: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusTyping(size)},
|
||||
},
|
||||
},
|
||||
`;
|
||||
}
|
||||
|
||||
output += `} as const;
|
||||
|
||||
export const SVGMasks = () => (
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
aria-hidden={true}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
top: '-1px',
|
||||
left: '-1px',
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
`;
|
||||
|
||||
for (const size of SIZES) {
|
||||
const status = calculateStatusGeometry(size, false);
|
||||
const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
|
||||
const cx = status.cx / size;
|
||||
const cy = status.cy / size;
|
||||
const r = status.outerRadius / size;
|
||||
|
||||
const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size;
|
||||
const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size;
|
||||
const idleCutoutCx = cx - idleCutoutOffset;
|
||||
const idleCutoutCy = cy - idleCutoutOffset;
|
||||
|
||||
const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size;
|
||||
const dndBarHeight =
|
||||
Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size;
|
||||
const dndBarX = cx - dndBarWidth / 2;
|
||||
const dndBarY = cy - dndBarHeight / 2;
|
||||
const dndBarRx = dndBarHeight / 2;
|
||||
|
||||
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
|
||||
|
||||
const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingExtensionPx = Math.max(0, typingWidthPx - status.size);
|
||||
const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
const typingWidth = typingWidthPx / size;
|
||||
const typingHeight = status.size / size;
|
||||
const typingX = cx - typingWidth / 2 + typingBridgeShift;
|
||||
const typingY = cy - typingHeight / 2;
|
||||
const typingRx = status.outerRadius / size;
|
||||
|
||||
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size;
|
||||
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidthPx =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
MOBILE_SCREEN_WIDTH_TRIM_PX;
|
||||
const screenHeightPx =
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
|
||||
const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
|
||||
const screenYpx =
|
||||
mobileStatus.phoneY +
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
|
||||
const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1;
|
||||
|
||||
const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4);
|
||||
const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
|
||||
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${cutoutPhoneX}" y="${cutoutPhoneY}" width="${cutoutPhoneWidth}" height="${cutoutPhoneHeight}" rx="${cutoutPhoneRx}" ry="${cutoutPhoneRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${idleCutoutCx}" cy="${idleCutoutCy}" r="${idleCutoutR}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<rect fill="black" x="${dndBarX}" y="${dndBarY}" width="${dndBarWidth}" height="${dndBarHeight}" rx="${dndBarRx}" ry="${dndBarRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${offlineInnerR}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</mask>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
`;
|
||||
|
||||
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
|
||||
fs.writeFileSync(outputPath, output);
|
||||
|
||||
console.log(`Generated ${outputPath}`);
|
||||
|
||||
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
export interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
borderWidth: number;
|
||||
isMobile?: boolean;
|
||||
phoneWidth?: number;
|
||||
phoneHeight?: number;
|
||||
}
|
||||
|
||||
const STATUS_GEOMETRY: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, false);
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
const STATUS_GEOMETRY_MOBILE: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry {
|
||||
const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY;
|
||||
|
||||
if (map[avatarSize]) {
|
||||
return map[avatarSize];
|
||||
}
|
||||
|
||||
const closestSize = Object.keys(map)
|
||||
.map(Number)
|
||||
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
|
||||
|
||||
return map[closestSize];
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
|
||||
fs.writeFileSync(layoutPath, layoutOutput);
|
||||
680
fluxer/fluxer_app/scripts/GenerateColorSystem.tsx
Normal file
680
fluxer/fluxer_app/scripts/GenerateColorSystem.tsx
Normal file
@@ -0,0 +1,680 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mkdirSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative} from 'node:path';
|
||||
|
||||
interface ColorFamily {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
useSaturationFactor: boolean;
|
||||
}
|
||||
|
||||
interface ScaleStop {
|
||||
name: string;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
interface Scale {
|
||||
family: string;
|
||||
range: [number, number];
|
||||
curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
|
||||
stops: Array<ScaleStop>;
|
||||
}
|
||||
|
||||
interface TokenDef {
|
||||
name?: string;
|
||||
scale?: string;
|
||||
value?: string;
|
||||
family?: string;
|
||||
hue?: number;
|
||||
saturation?: number;
|
||||
lightness?: number;
|
||||
alpha?: number;
|
||||
useSaturationFactor?: boolean;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
families: Record<string, ColorFamily>;
|
||||
scales: Record<string, Scale>;
|
||||
tokens: {
|
||||
root: Array<TokenDef>;
|
||||
light: Array<TokenDef>;
|
||||
coal: Array<TokenDef>;
|
||||
};
|
||||
}
|
||||
|
||||
const CONFIG: Config = {
|
||||
families: {
|
||||
neutralDark: {hue: 220, saturation: 13, useSaturationFactor: true},
|
||||
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
|
||||
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
|
||||
link: {hue: 210, saturation: 100, useSaturationFactor: true},
|
||||
accentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
|
||||
statusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
|
||||
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
|
||||
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
|
||||
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
|
||||
statusDanger: {hue: 1, saturation: 77, useSaturationFactor: true},
|
||||
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
|
||||
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
|
||||
},
|
||||
|
||||
scales: {
|
||||
darkSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [5, 26],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-lighter', position: 0.22},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
coalSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [1, 12],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
darkText: {
|
||||
family: 'neutralDark',
|
||||
range: [52, 96],
|
||||
curve: 'easeInOut',
|
||||
stops: [
|
||||
{name: '--text-tertiary-secondary', position: 0},
|
||||
{name: '--text-tertiary-muted', position: 0.2},
|
||||
{name: '--text-tertiary', position: 0.38},
|
||||
{name: '--text-primary-muted', position: 0.55},
|
||||
{name: '--text-chat-muted', position: 0.55},
|
||||
{name: '--text-secondary', position: 0.72},
|
||||
{name: '--text-chat', position: 0.82},
|
||||
{name: '--text-primary', position: 1},
|
||||
],
|
||||
},
|
||||
lightSurface: {
|
||||
family: 'neutralLight',
|
||||
range: [86, 98.5],
|
||||
curve: 'easeIn',
|
||||
stops: [
|
||||
{name: '--background-header-primary-hover', position: 0},
|
||||
{name: '--background-header-primary', position: 0.12},
|
||||
{name: '--background-header-secondary', position: 0.2},
|
||||
{name: '--guild-list-foreground', position: 0.35},
|
||||
{name: '--background-tertiary', position: 0.42},
|
||||
{name: '--background-channel-header', position: 0.5},
|
||||
{name: '--background-secondary-alt', position: 0.63},
|
||||
{name: '--background-secondary', position: 0.74},
|
||||
{name: '--background-secondary-lighter', position: 0.83},
|
||||
{name: '--background-textarea', position: 0.88},
|
||||
{name: '--background-primary', position: 1},
|
||||
],
|
||||
},
|
||||
lightText: {
|
||||
family: 'neutralLight',
|
||||
range: [15, 60],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--text-primary', position: 0},
|
||||
{name: '--text-chat', position: 0.08},
|
||||
{name: '--text-secondary', position: 0.28},
|
||||
{name: '--text-chat-muted', position: 0.45},
|
||||
{name: '--text-primary-muted', position: 0.45},
|
||||
{name: '--text-tertiary', position: 0.6},
|
||||
{name: '--text-tertiary-secondary', position: 0.75},
|
||||
{name: '--text-tertiary-muted', position: 0.85},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
tokens: {
|
||||
root: [
|
||||
{scale: 'darkSurface'},
|
||||
{scale: 'darkText'},
|
||||
{
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
{name: '--panel-control-divider', family: 'neutralDark', saturation: 30, lightness: 55, alpha: 0.35},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.04)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.1},
|
||||
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.15},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 13, lightness: 80, alpha: 0.22},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
|
||||
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 22},
|
||||
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
|
||||
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 24},
|
||||
{name: '--control-button-active-text', value: 'var(--text-primary)'},
|
||||
{name: '--control-button-danger-text', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60},
|
||||
{name: '--control-button-danger-hover-bg', hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20},
|
||||
{name: '--brand-primary', family: 'brand', lightness: 55},
|
||||
{name: '--brand-secondary', family: 'brand', saturation: 60, lightness: 49},
|
||||
{name: '--brand-primary-light', family: 'brand', saturation: 100, lightness: 84},
|
||||
{name: '--brand-primary-fill', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--status-online', family: 'statusOnline', lightness: 40},
|
||||
{name: '--status-idle', family: 'statusIdle', lightness: 50},
|
||||
{name: '--status-dnd', family: 'statusDnd', lightness: 60},
|
||||
{name: '--status-offline', family: 'statusOffline', lightness: 65},
|
||||
{name: '--status-danger', family: 'statusDanger', lightness: 55},
|
||||
{name: '--status-warning', value: 'var(--status-idle)'},
|
||||
{name: '--text-warning', family: 'statusIdle', lightness: 55},
|
||||
{name: '--plutonium', value: 'var(--brand-primary)'},
|
||||
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
|
||||
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--plutonium-icon', family: 'brandIcon', lightness: 50},
|
||||
{name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--text-link', family: 'link', lightness: 70},
|
||||
{name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98},
|
||||
{name: '--text-code', family: 'textCode', lightness: 90},
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
|
||||
{name: '--markup-mention-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-mention-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
|
||||
{name: '--markup-mention-border', family: 'link', lightness: 70, alpha: 0.3},
|
||||
{name: '--markup-jump-link-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 12%, transparent)'},
|
||||
{name: '--markup-jump-link-hover-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
|
||||
{name: '--markup-everyone-text', hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75},
|
||||
{
|
||||
name: '--markup-everyone-fill',
|
||||
value: 'color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)',
|
||||
},
|
||||
{
|
||||
name: '--markup-everyone-border',
|
||||
hue: 250,
|
||||
saturation: 80,
|
||||
useSaturationFactor: true,
|
||||
lightness: 75,
|
||||
alpha: 0.3,
|
||||
},
|
||||
{name: '--markup-here-text', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70},
|
||||
{
|
||||
name: '--markup-here-fill',
|
||||
value: 'color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)',
|
||||
},
|
||||
{name: '--markup-here-border', hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3},
|
||||
{name: '--markup-interactive-hover-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-interactive-hover-fill', value: 'color-mix(in srgb, var(--text-link) 30%, transparent)'},
|
||||
{
|
||||
name: '--interactive-muted',
|
||||
value: `color-mix(
|
||||
in oklab,
|
||||
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
|
||||
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
)`,
|
||||
},
|
||||
{
|
||||
name: '--interactive-active',
|
||||
value: `color-mix(
|
||||
in oklab,
|
||||
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
|
||||
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
)`,
|
||||
},
|
||||
{name: '--button-primary-fill', hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44},
|
||||
{name: '--button-primary-active-fill', hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38},
|
||||
{name: '--button-primary-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-secondary-fill', hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false},
|
||||
{
|
||||
name: '--button-secondary-active-fill',
|
||||
hue: 0,
|
||||
saturation: 0,
|
||||
lightness: 100,
|
||||
alpha: 0.15,
|
||||
useSaturationFactor: false,
|
||||
},
|
||||
{name: '--button-secondary-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-secondary-active-text', value: 'var(--button-secondary-text)'},
|
||||
{name: '--button-danger-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54},
|
||||
{name: '--button-danger-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45},
|
||||
{name: '--button-danger-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)'},
|
||||
{name: '--button-danger-outline-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48},
|
||||
{name: '--button-danger-outline-active-border', value: 'transparent'},
|
||||
{name: '--button-ghost-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 0},
|
||||
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.3)'},
|
||||
{name: '--button-outline-text', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.15)'},
|
||||
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.4)'},
|
||||
{name: '--theme-border', value: 'transparent'},
|
||||
{name: '--theme-border-width', value: '0px'},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', family: 'neutralDark', lightness: 15, alpha: 0.8},
|
||||
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--border-color', family: 'neutralDark', lightness: 50, alpha: 0.2},
|
||||
{name: '--border-color-hover', family: 'neutralDark', lightness: 50, alpha: 0.3},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
|
||||
{name: '--accent-primary', value: 'var(--brand-primary)'},
|
||||
{name: '--accent-success', value: 'var(--status-online)'},
|
||||
{name: '--accent-warning', value: 'var(--status-idle)'},
|
||||
{name: '--accent-danger', value: 'var(--status-dnd)'},
|
||||
{name: '--accent-info', value: 'var(--text-link)'},
|
||||
{name: '--accent-purple', family: 'accentPurple', lightness: 65},
|
||||
{name: '--alert-note-color', family: 'link', lightness: 70},
|
||||
{name: '--alert-tip-color', family: 'statusOnline', lightness: 45},
|
||||
{name: '--alert-important-color', family: 'accentPurple', lightness: 65},
|
||||
{name: '--alert-warning-color', family: 'statusIdle', lightness: 55},
|
||||
{name: '--alert-caution-color', hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60},
|
||||
{name: '--shadow-sm', value: '0 1px 2px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--shadow-md', value: '0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--shadow-lg', value: '0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--shadow-xl', value: '0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--transition-fast', value: '100ms ease'},
|
||||
{name: '--transition-normal', value: '200ms ease'},
|
||||
{name: '--transition-slow', value: '300ms ease'},
|
||||
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.2)'},
|
||||
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.3)'},
|
||||
{name: '--scrollbar-thumb-bg', value: 'rgba(121, 122, 124, 0.4)'},
|
||||
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(121, 122, 124, 0.7)'},
|
||||
{name: '--scrollbar-track-bg', value: 'transparent'},
|
||||
{
|
||||
name: '--user-area-divider-color',
|
||||
value: 'color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)',
|
||||
},
|
||||
],
|
||||
|
||||
light: [
|
||||
{scale: 'lightSurface'},
|
||||
{scale: 'lightText'},
|
||||
{name: '--panel-control-bg', value: 'color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)'},
|
||||
{name: '--panel-control-border', family: 'neutralLight', saturation: 25, lightness: 45, alpha: 0.25},
|
||||
{name: '--panel-control-divider', family: 'neutralLight', saturation: 30, lightness: 35, alpha: 0.2},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.65)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{name: '--background-modifier-accent', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.22},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralLight', saturation: 10, lightness: 40, alpha: 0.32},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
{name: '--control-button-normal-text', family: 'neutralLight', lightness: 50},
|
||||
{name: '--control-button-hover-bg', family: 'neutralLight', lightness: 88},
|
||||
{name: '--control-button-hover-text', family: 'neutralLight', lightness: 20},
|
||||
{name: '--control-button-active-bg', family: 'neutralLight', lightness: 85},
|
||||
{name: '--control-button-active-text', family: 'neutralLight', lightness: 15},
|
||||
{name: '--control-button-danger-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--control-button-danger-hover-bg', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95},
|
||||
{name: '--text-link', family: 'link', lightness: 45},
|
||||
{name: '--text-code', family: 'textCode', lightness: 45},
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2},
|
||||
{name: '--markup-mention-border', family: 'link', lightness: 45, alpha: 0.4},
|
||||
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 8%, transparent)'},
|
||||
{name: '--markup-everyone-text', hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45},
|
||||
{
|
||||
name: '--markup-everyone-fill',
|
||||
value: 'color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)',
|
||||
},
|
||||
{
|
||||
name: '--markup-everyone-border',
|
||||
hue: 250,
|
||||
saturation: 70,
|
||||
useSaturationFactor: true,
|
||||
lightness: 45,
|
||||
alpha: 0.4,
|
||||
},
|
||||
{name: '--markup-here-text', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40},
|
||||
{
|
||||
name: '--markup-here-fill',
|
||||
value: 'color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)',
|
||||
},
|
||||
{name: '--markup-here-border', hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4},
|
||||
{name: '--status-online', family: 'statusOnline', saturation: 70, lightness: 40},
|
||||
{name: '--status-idle', family: 'statusIdle', saturation: 90, lightness: 45},
|
||||
{name: '--status-dnd', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--status-offline', family: 'statusOffline', hue: 210, saturation: 10, lightness: 55},
|
||||
{name: '--plutonium', value: 'var(--brand-primary)'},
|
||||
{name: '--plutonium-hover', value: 'var(--brand-secondary)'},
|
||||
{name: '--plutonium-text', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--plutonium-icon', family: 'brandIcon', lightness: 45},
|
||||
{name: '--invite-verified-icon-color', value: 'var(--brand-primary)'},
|
||||
{name: '--border-color', family: 'neutralLight', lightness: 40, alpha: 0.15},
|
||||
{name: '--border-color-hover', family: 'neutralLight', lightness: 40, alpha: 0.25},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', family: 'neutralLight', saturation: 22, lightness: 90, alpha: 0.9},
|
||||
{name: '--bg-code-block', value: 'var(--background-primary)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--alert-note-color', family: 'link', lightness: 45},
|
||||
{name: '--alert-tip-color', hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35},
|
||||
{name: '--alert-important-color', family: 'accentPurple', lightness: 50},
|
||||
{name: '--alert-warning-color', family: 'statusIdle', saturation: 90, lightness: 45},
|
||||
{name: '--alert-caution-color', hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--spoiler-overlay-color', value: 'rgba(0, 0, 0, 0.1)'},
|
||||
{name: '--spoiler-overlay-hover-color', value: 'rgba(0, 0, 0, 0.15)'},
|
||||
{name: '--button-secondary-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{name: '--button-secondary-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.15},
|
||||
{name: '--button-secondary-text', family: 'neutralLight', lightness: 15},
|
||||
{name: '--button-secondary-active-text', family: 'neutralLight', lightness: 10},
|
||||
{name: '--button-ghost-text', family: 'neutralLight', lightness: 20},
|
||||
{name: '--button-inverted-fill', hue: 0, saturation: 0, lightness: 100},
|
||||
{name: '--button-inverted-text', hue: 0, saturation: 0, lightness: 10},
|
||||
{name: '--button-outline-border', value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.3)'},
|
||||
{name: '--button-outline-text', family: 'neutralLight', lightness: 20},
|
||||
{name: '--button-outline-active-fill', family: 'neutralLight', saturation: 10, lightness: 10, alpha: 0.1},
|
||||
{
|
||||
name: '--button-outline-active-border',
|
||||
value: '1px solid hsla(220, calc(10% * var(--saturation-factor)), 40%, 0.5)',
|
||||
},
|
||||
{name: '--button-danger-outline-border', value: '1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)'},
|
||||
{name: '--button-danger-outline-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45},
|
||||
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--user-area-divider-color', family: 'neutralLight', lightness: 40, alpha: 0.2},
|
||||
],
|
||||
|
||||
coal: [
|
||||
{scale: 'coalSurface'},
|
||||
{name: '--background-secondary', value: 'var(--background-primary)'},
|
||||
{name: '--background-secondary-lighter', value: 'var(--background-primary)'},
|
||||
{
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-primary) 90%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 20, lightness: 30, alpha: 0.35},
|
||||
{name: '--panel-control-divider', family: 'neutralDark', saturation: 20, lightness: 25, alpha: 0.28},
|
||||
{name: '--panel-control-highlight', value: 'hsla(0, 0%, 100%, 0.06)'},
|
||||
{name: '--background-modifier-hover', family: 'neutralDark', lightness: 100, alpha: 0.04},
|
||||
{name: '--background-modifier-selected', family: 'neutralDark', lightness: 100, alpha: 0.08},
|
||||
{name: '--background-modifier-accent', family: 'neutralDark', saturation: 10, lightness: 65, alpha: 0.18},
|
||||
{name: '--background-modifier-accent-focus', family: 'neutralDark', saturation: 10, lightness: 70, alpha: 0.26},
|
||||
{name: '--control-button-normal-bg', value: 'transparent'},
|
||||
{name: '--control-button-normal-text', value: 'var(--text-primary-muted)'},
|
||||
{name: '--control-button-hover-bg', family: 'neutralDark', lightness: 12},
|
||||
{name: '--control-button-hover-text', value: 'var(--text-primary)'},
|
||||
{name: '--control-button-active-bg', family: 'neutralDark', lightness: 14},
|
||||
{name: '--control-button-active-text', value: 'var(--text-primary)'},
|
||||
{name: '--scrollbar-thumb-bg', value: 'rgba(160, 160, 160, 0.35)'},
|
||||
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(200, 200, 200, 0.55)'},
|
||||
{name: '--scrollbar-track-bg', value: 'rgba(0, 0, 0, 0.45)'},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', value: 'hsl(220, calc(13% * var(--saturation-factor)), 8%)'},
|
||||
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--button-secondary-fill', value: 'hsla(0, 0%, 100%, 0.04)'},
|
||||
{name: '--button-secondary-active-fill', value: 'hsla(0, 0%, 100%, 0.07)'},
|
||||
{name: '--button-secondary-text', value: 'var(--text-primary)'},
|
||||
{name: '--button-secondary-active-text', value: 'var(--text-primary)'},
|
||||
{name: '--button-outline-border', value: '1px solid hsla(0, 0%, 100%, 0.08)'},
|
||||
{name: '--button-outline-active-fill', value: 'hsla(0, 0%, 100%, 0.12)'},
|
||||
{name: '--button-outline-active-border', value: '1px solid hsla(0, 0%, 100%, 0.16)'},
|
||||
{
|
||||
name: '--user-area-divider-color',
|
||||
value: 'color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
interface OutputToken {
|
||||
type: 'tone' | 'literal';
|
||||
name: string;
|
||||
family?: string;
|
||||
hue?: number;
|
||||
saturation?: number;
|
||||
lightness?: number;
|
||||
alpha?: number;
|
||||
useSaturationFactor?: boolean;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function applyCurve(curve: Scale['curve'], t: number): number {
|
||||
switch (curve) {
|
||||
case 'easeIn':
|
||||
return t * t;
|
||||
case 'easeOut':
|
||||
return 1 - (1 - t) * (1 - t);
|
||||
case 'easeInOut':
|
||||
if (t < 0.5) {
|
||||
return 2 * t * t;
|
||||
}
|
||||
return 1 - 2 * (1 - t) * (1 - t);
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
function buildScaleTokens(scale: Scale): Array<OutputToken> {
|
||||
const lastIndex = Math.max(scale.stops.length - 1, 1);
|
||||
const tokens: Array<OutputToken> = [];
|
||||
|
||||
for (let i = 0; i < scale.stops.length; i++) {
|
||||
const stop = scale.stops[i];
|
||||
let pos: number;
|
||||
|
||||
if (stop.position !== undefined) {
|
||||
pos = clamp01(stop.position);
|
||||
} else {
|
||||
pos = i / lastIndex;
|
||||
}
|
||||
|
||||
const eased = applyCurve(scale.curve, pos);
|
||||
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
|
||||
lightness = Math.round(lightness * 1000) / 1000;
|
||||
|
||||
tokens.push({
|
||||
type: 'tone',
|
||||
name: stop.name,
|
||||
family: scale.family,
|
||||
lightness,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
|
||||
const tokens: Array<OutputToken> = [];
|
||||
|
||||
for (const def of defs) {
|
||||
if (def.scale) {
|
||||
const scale = scales[def.scale];
|
||||
if (!scale) {
|
||||
console.warn(`Warning: unknown scale "${def.scale}"`);
|
||||
continue;
|
||||
}
|
||||
tokens.push(...buildScaleTokens(scale));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.value !== undefined) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
name: def.name!,
|
||||
value: def.value.trim(),
|
||||
});
|
||||
} else {
|
||||
tokens.push({
|
||||
type: 'tone',
|
||||
name: def.name!,
|
||||
family: def.family,
|
||||
hue: def.hue,
|
||||
saturation: def.saturation,
|
||||
lightness: def.lightness,
|
||||
alpha: def.alpha,
|
||||
useSaturationFactor: def.useSaturationFactor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
if (value === Math.floor(value)) {
|
||||
return String(Math.floor(value));
|
||||
}
|
||||
let s = value.toFixed(2);
|
||||
s = s.replace(/\.?0+$/, '');
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatTone(token: OutputToken, families: Record<string, ColorFamily>): string {
|
||||
const family = token.family ? families[token.family] : undefined;
|
||||
|
||||
let hue = 0;
|
||||
let saturation = 0;
|
||||
let lightness = 0;
|
||||
let useFactor = false;
|
||||
|
||||
if (token.hue !== undefined) {
|
||||
hue = token.hue;
|
||||
} else if (family) {
|
||||
hue = family.hue;
|
||||
}
|
||||
|
||||
if (token.saturation !== undefined) {
|
||||
saturation = token.saturation;
|
||||
} else if (family) {
|
||||
saturation = family.saturation;
|
||||
}
|
||||
|
||||
if (token.lightness !== undefined) {
|
||||
lightness = token.lightness;
|
||||
}
|
||||
|
||||
if (token.useSaturationFactor !== undefined) {
|
||||
useFactor = token.useSaturationFactor;
|
||||
} else if (family) {
|
||||
useFactor = family.useSaturationFactor;
|
||||
}
|
||||
|
||||
let satStr: string;
|
||||
if (useFactor) {
|
||||
satStr = `calc(${formatNumber(saturation)}% * var(--saturation-factor))`;
|
||||
} else {
|
||||
satStr = `${formatNumber(saturation)}%`;
|
||||
}
|
||||
|
||||
if (token.alpha === undefined) {
|
||||
return `hsl(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%)`;
|
||||
}
|
||||
|
||||
return `hsla(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%, ${formatNumber(token.alpha)})`;
|
||||
}
|
||||
|
||||
function formatValue(token: OutputToken, families: Record<string, ColorFamily>): string {
|
||||
if (token.type === 'tone') {
|
||||
return formatTone(token, families);
|
||||
}
|
||||
return token.value!.trim();
|
||||
}
|
||||
|
||||
function renderBlock(selector: string, tokens: Array<OutputToken>, families: Record<string, ColorFamily>): string {
|
||||
const lines: Array<string> = [];
|
||||
for (const token of tokens) {
|
||||
lines.push(`\t${token.name}: ${formatValue(token, families)};`);
|
||||
}
|
||||
return `${selector} {\n${lines.join('\n')}\n}`;
|
||||
}
|
||||
|
||||
function generateCSS(
|
||||
cfg: Config,
|
||||
rootTokens: Array<OutputToken>,
|
||||
lightTokens: Array<OutputToken>,
|
||||
coalTokens: Array<OutputToken>,
|
||||
): string {
|
||||
const header = `/*
|
||||
* This file is auto-generated by scripts/GenerateColorSystem.ts.
|
||||
* Do not edit directly — update the config in generate-color-system.ts instead.
|
||||
*/`;
|
||||
|
||||
const blocks = [
|
||||
renderBlock(':root', rootTokens, cfg.families),
|
||||
renderBlock('.theme-light', lightTokens, cfg.families),
|
||||
renderBlock('.theme-coal', coalTokens, cfg.families),
|
||||
];
|
||||
|
||||
return `${header}\n\n${blocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
|
||||
const rootTokens = expandTokens(CONFIG.tokens.root, CONFIG.scales);
|
||||
const lightTokens = expandTokens(CONFIG.tokens.light, CONFIG.scales);
|
||||
const coalTokens = expandTokens(CONFIG.tokens.coal, CONFIG.scales);
|
||||
|
||||
const cssPath = join(appDir, 'src', 'styles', 'generated', 'color-system.css');
|
||||
|
||||
mkdirSync(dirname(cssPath), {recursive: true});
|
||||
|
||||
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens);
|
||||
writeFileSync(cssPath, css);
|
||||
|
||||
const relCSS = relative(appDir, cssPath);
|
||||
console.log(`Wrote ${relCSS}`);
|
||||
}
|
||||
|
||||
main();
|
||||
315
fluxer/fluxer_app/scripts/GenerateEmojiSprites.tsx
Normal file
315
fluxer/fluxer_app/scripts/GenerateEmojiSprites.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {convertToCodePoints} from '@app/utils/EmojiCodepointUtils';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const EMOJI_SPRITES = {
|
||||
nonDiversityPerRow: 42,
|
||||
diversityPerRow: 10,
|
||||
pickerPerRow: 11,
|
||||
pickerCount: 50,
|
||||
} as const;
|
||||
|
||||
const EMOJI_SIZE = 32;
|
||||
const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji';
|
||||
const SPRITE_SCALES = [1, 2] as const;
|
||||
|
||||
interface EmojiObject {
|
||||
surrogates: string;
|
||||
skins?: Array<{surrogates: string}>;
|
||||
}
|
||||
|
||||
interface EmojiEntry {
|
||||
surrogates: string;
|
||||
}
|
||||
|
||||
const svgCache = new Map<string, string | null>();
|
||||
|
||||
async function fetchTwemojiSVG(codepoint: string): Promise<string | null> {
|
||||
if (svgCache.has(codepoint)) {
|
||||
return svgCache.get(codepoint) ?? null;
|
||||
}
|
||||
|
||||
const url = `${TWEMOJI_CDN}/${codepoint}.svg`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Twemoji ${codepoint} returned ${response.status}`);
|
||||
svgCache.set(codepoint, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
svgCache.set(codepoint, body);
|
||||
return body;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch Twemoji ${codepoint}:`, err);
|
||||
svgCache.set(codepoint, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fixSVGSize(svg: string, size: number): string {
|
||||
return svg.replace(/<svg([^>]*)>/i, `<svg$1 width="${size}" height="${size}">`);
|
||||
}
|
||||
|
||||
async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buffer> {
|
||||
const fixed = fixSVGSize(svgContent, size);
|
||||
return sharp(Buffer.from(fixed)).resize(size, size).png().toBuffer();
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
h /= 360;
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
const hueToRgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
r = hueToRgb(p, q, h + 1 / 3);
|
||||
g = hueToRgb(p, q, h);
|
||||
b = hueToRgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round(Math.min(1, Math.max(0, r)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, g)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, b)) * 255),
|
||||
];
|
||||
}
|
||||
|
||||
async function createPlaceholder(size: number): Promise<Buffer> {
|
||||
const h = Math.random() * 360;
|
||||
const [r, g, b] = hslToRgb(h, 0.7, 0.6);
|
||||
|
||||
const radius = Math.floor(size * 0.4);
|
||||
const cx = Math.floor(size / 2);
|
||||
const cy = Math.floor(size / 2);
|
||||
|
||||
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgb(${r},${g},${b})"/>
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer> {
|
||||
const codepoint = convertToCodePoints(surrogate);
|
||||
|
||||
const svg = await fetchTwemojiSVG(codepoint);
|
||||
if (svg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(svg, size);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render SVG for ${codepoint}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (codepoint.includes('-200d-')) {
|
||||
const basePart = codepoint.split('-200d-')[0];
|
||||
const baseSvg = await fetchTwemojiSVG(basePart);
|
||||
if (baseSvg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(baseSvg, size);
|
||||
} catch (error) {
|
||||
console.error(`Failed to render base SVG for ${basePart}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
|
||||
return createPlaceholder(size);
|
||||
}
|
||||
|
||||
async function renderSpriteSheet(
|
||||
emojiEntries: Array<EmojiEntry>,
|
||||
perRow: number,
|
||||
fileNameBase: string,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
if (perRow <= 0) {
|
||||
throw new Error('perRow must be > 0');
|
||||
}
|
||||
|
||||
const rows = Math.ceil(emojiEntries.length / perRow);
|
||||
|
||||
for (const scale of SPRITE_SCALES) {
|
||||
const size = EMOJI_SIZE * scale;
|
||||
const dstW = perRow * size;
|
||||
const dstH = rows * size;
|
||||
|
||||
const compositeOps: Array<sharp.OverlayOptions> = [];
|
||||
|
||||
for (let i = 0; i < emojiEntries.length; i++) {
|
||||
const item = emojiEntries[i];
|
||||
const emojiBuffer = await loadEmojiImage(item.surrogates, size);
|
||||
|
||||
const row = Math.floor(i / perRow);
|
||||
const col = i % perRow;
|
||||
const x = col * size;
|
||||
const y = row * size;
|
||||
|
||||
compositeOps.push({
|
||||
input: emojiBuffer,
|
||||
left: x,
|
||||
top: y,
|
||||
});
|
||||
}
|
||||
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: dstW,
|
||||
height: dstH,
|
||||
channels: 4,
|
||||
background: {r: 0, g: 0, b: 0, alpha: 0},
|
||||
},
|
||||
})
|
||||
.composite(compositeOps)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const suffix = scale !== 1 ? `@${scale}x` : '';
|
||||
const outPath = join(outputDir, `${fileNameBase}${suffix}.png`);
|
||||
writeFileSync(outPath, sheet);
|
||||
console.log(`Wrote ${outPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateMainSpriteSheet(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const base: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const obj of objs) {
|
||||
base.push({surrogates: obj.surrogates});
|
||||
}
|
||||
}
|
||||
await renderSpriteSheet(base, EMOJI_SPRITES.nonDiversityPerRow, 'spritesheet-emoji', outputDir);
|
||||
}
|
||||
|
||||
async function generateDiversitySpriteSheets(
|
||||
emojiData: Record<string, Array<EmojiObject>>,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
for (let skinIndex = 0; skinIndex < skinTones.length; skinIndex++) {
|
||||
const skinTone = skinTones[skinIndex];
|
||||
const skinCodepoint = convertToCodePoints(skinTone);
|
||||
|
||||
const skinEntries: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const obj of objs) {
|
||||
if (obj.skins && obj.skins.length > skinIndex && obj.skins[skinIndex].surrogates) {
|
||||
skinEntries.push({surrogates: obj.skins[skinIndex].surrogates});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skinEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
|
||||
const basicEmojis = [
|
||||
'\u{1F600}',
|
||||
'\u{1F603}',
|
||||
'\u{1F604}',
|
||||
'\u{1F601}',
|
||||
'\u{1F606}',
|
||||
'\u{1F605}',
|
||||
'\u{1F602}',
|
||||
'\u{1F923}',
|
||||
'\u{1F60A}',
|
||||
'\u{1F607}',
|
||||
'\u{1F642}',
|
||||
'\u{1F609}',
|
||||
'\u{1F60C}',
|
||||
'\u{1F60D}',
|
||||
'\u{1F970}',
|
||||
'\u{1F618}',
|
||||
'\u{1F617}',
|
||||
'\u{1F619}',
|
||||
'\u{1F61A}',
|
||||
'\u{1F60B}',
|
||||
'\u{1F61B}',
|
||||
'\u{1F61D}',
|
||||
'\u{1F61C}',
|
||||
'\u{1F92A}',
|
||||
'\u{1F928}',
|
||||
'\u{1F9D0}',
|
||||
'\u{1F913}',
|
||||
'\u{1F60E}',
|
||||
'\u{1F973}',
|
||||
'\u{1F60F}',
|
||||
];
|
||||
|
||||
const entries: Array<EmojiEntry> = basicEmojis.map((e) => ({surrogates: e}));
|
||||
await renderSpriteSheet(entries, EMOJI_SPRITES.pickerPerRow, 'spritesheet-picker', outputDir);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
|
||||
const outputDir = join(appDir, 'src', 'assets', 'emoji-sprites');
|
||||
mkdirSync(outputDir, {recursive: true});
|
||||
|
||||
const emojiDataPath = join(appDir, 'src', 'data', 'emojis.json');
|
||||
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
|
||||
|
||||
console.log('Generating main sprite sheet...');
|
||||
await generateMainSpriteSheet(emojiData, outputDir);
|
||||
|
||||
console.log('Generating diversity sprite sheets...');
|
||||
await generateDiversitySpriteSheets(emojiData, outputDir);
|
||||
|
||||
console.log('Generating picker sprite sheet...');
|
||||
await generatePickerSpriteSheet(outputDir);
|
||||
|
||||
console.log('Emoji sprites generated successfully.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
86
fluxer/fluxer_app/scripts/auto-i18n.mjs
Normal file
86
fluxer/fluxer_app/scripts/auto-i18n.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {homedir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const envOverrides = loadEnvFromFiles(['FLUXER_AUTO_I18N', 'OPENROUTER_API_KEY']);
|
||||
const FLUXER_AUTO_I18N = process.env.FLUXER_AUTO_I18N ?? envOverrides.FLUXER_AUTO_I18N ?? '';
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? envOverrides.OPENROUTER_API_KEY ?? '';
|
||||
|
||||
const shouldRun = FLUXER_AUTO_I18N === '1' && Boolean(OPENROUTER_API_KEY);
|
||||
if (!shouldRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const childEnv = {...process.env, FLUXER_AUTO_I18N, OPENROUTER_API_KEY};
|
||||
|
||||
const scriptPath = new URL('./translate-i18n.mjs', import.meta.url).pathname;
|
||||
const result = spawnSync(process.execPath, [scriptPath], {stdio: 'inherit', env: childEnv});
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
function loadEnvFromFiles(keys) {
|
||||
const homeDir = homedir();
|
||||
const targetKeys = new Set(keys);
|
||||
const env = Object.create(null);
|
||||
const candidates = ['.bash_profile', '.bashrc', '.profile'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const filePath = join(homeDir, candidate);
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const parsed = parseExportLine(line);
|
||||
if (!parsed || !targetKeys.has(parsed.key) || env[parsed.key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
env[parsed.key] = parsed.value;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseExportLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('export ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {key: match[1], value: stripQuotes(match[2])};
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
const trimmed = value.trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
27
fluxer/fluxer_app/scripts/build-sw.mjs
Normal file
27
fluxer/fluxer_app/scripts/build-sw.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {buildServiceWorker} from './build/utils/ServiceWorker';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
buildServiceWorker(isProduction).catch((error) => {
|
||||
console.error('Service worker build failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
88
fluxer/fluxer_app/scripts/build/Config.tsx
Normal file
88
fluxer/fluxer_app/scripts/build/Config.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..');
|
||||
export const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
export const ASSETS_DIR = path.join(DIST_DIR, 'assets');
|
||||
export const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
|
||||
export const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
|
||||
|
||||
export const CDN_ENDPOINT = 'https://fluxerstatic.com';
|
||||
|
||||
export const DEV_PORT = 3000;
|
||||
|
||||
export const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.json', '.mjs', '.cjs'];
|
||||
|
||||
export const LOCALES = [
|
||||
'ar',
|
||||
'bg',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-419',
|
||||
'es-ES',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'lt',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ro',
|
||||
'ru',
|
||||
'sv-SE',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
];
|
||||
|
||||
export const FILE_LOADERS: Record<string, 'file'> = {
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.ttf': 'file',
|
||||
'.eot': 'file',
|
||||
'.png': 'file',
|
||||
'.jpg': 'file',
|
||||
'.jpeg': 'file',
|
||||
'.gif': 'file',
|
||||
'.webp': 'file',
|
||||
'.ico': 'file',
|
||||
'.mp3': 'file',
|
||||
'.wav': 'file',
|
||||
'.ogg': 'file',
|
||||
'.mp4': 'file',
|
||||
'.webm': 'file',
|
||||
};
|
||||
65
fluxer/fluxer_app/scripts/build/rspack/externals.mjs
Normal file
65
fluxer/fluxer_app/scripts/build/rspack/externals.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const EXTERNAL_MODULES = [
|
||||
'@lingui/cli',
|
||||
'@lingui/conf',
|
||||
'cosmiconfig',
|
||||
'jiti',
|
||||
'node:*',
|
||||
'crypto',
|
||||
'path',
|
||||
'fs',
|
||||
'os',
|
||||
'vm',
|
||||
'perf_hooks',
|
||||
'util',
|
||||
'events',
|
||||
'stream',
|
||||
'buffer',
|
||||
'child_process',
|
||||
'cluster',
|
||||
'dgram',
|
||||
'dns',
|
||||
'http',
|
||||
'https',
|
||||
'module',
|
||||
'net',
|
||||
'repl',
|
||||
'tls',
|
||||
'url',
|
||||
'worker_threads',
|
||||
'readline',
|
||||
'zlib',
|
||||
'resolve',
|
||||
];
|
||||
|
||||
const EXTERNAL_PATTERNS = [/^node:.*/];
|
||||
|
||||
export class ExternalsPlugin {
|
||||
apply(compiler) {
|
||||
const existingExternals = compiler.options.externals || [];
|
||||
const externalsArray = Array.isArray(existingExternals) ? existingExternals : [existingExternals];
|
||||
compiler.options.externals = [...externalsArray, ...EXTERNAL_MODULES, ...EXTERNAL_PATTERNS];
|
||||
}
|
||||
}
|
||||
|
||||
export function externalsPlugin() {
|
||||
return new ExternalsPlugin();
|
||||
}
|
||||
48
fluxer/fluxer_app/scripts/build/rspack/lingui.mjs
Normal file
48
fluxer/fluxer_app/scripts/build/rspack/lingui.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export function getLinguiSwcPluginConfig() {
|
||||
return [
|
||||
'@lingui/swc-plugin',
|
||||
{
|
||||
localeDir: 'src/locales/{locale}/messages',
|
||||
runtimeModules: {
|
||||
i18n: ['@lingui/core', 'i18n'],
|
||||
trans: ['@lingui/react', 'Trans'],
|
||||
},
|
||||
stripNonEssentialFields: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createPoFileRule() {
|
||||
return {
|
||||
test: /\.po$/,
|
||||
type: 'javascript/auto',
|
||||
use: {
|
||||
loader: path.join(__dirname, 'po-loader.mjs'),
|
||||
},
|
||||
};
|
||||
}
|
||||
164
fluxer/fluxer_app/scripts/build/rspack/po-loader.mjs
Normal file
164
fluxer/fluxer_app/scripts/build/rspack/po-loader.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export default function poLoader(source) {
|
||||
const callback = this.async();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
this.cacheable?.();
|
||||
|
||||
const poPath = this.resourcePath;
|
||||
const compiledPath = `${poPath}.mjs`;
|
||||
|
||||
this.addDependency?.(poPath);
|
||||
|
||||
try {
|
||||
await fs.access(compiledPath);
|
||||
this.addDependency?.(compiledPath);
|
||||
const compiledSource = await fs.readFile(compiledPath, 'utf8');
|
||||
callback(null, compiledSource);
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
const content = Buffer.isBuffer(source) ? source.toString('utf8') : String(source);
|
||||
const messages = parsePoFile(content);
|
||||
|
||||
const code = `export const messages = ${JSON.stringify(messages, null, 2)};\nexport default messages;\n`;
|
||||
|
||||
callback(null, code);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function parsePoFile(content) {
|
||||
const messages = {};
|
||||
const entries = splitEntries(content);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parsed = parseEntry(entry);
|
||||
if (!parsed) continue;
|
||||
messages[parsed.key] = parsed.value;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function splitEntries(content) {
|
||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
return normalized
|
||||
.split(/\n{2,}/g)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEntry(entry) {
|
||||
const lines = entry.split('\n');
|
||||
|
||||
let msgctxt = null;
|
||||
let msgid = null;
|
||||
let msgidPlural = null;
|
||||
|
||||
const msgstrMap = new Map();
|
||||
|
||||
let active = null;
|
||||
let activeMsgstrIndex = 0;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
if (line.startsWith('msgctxt ')) {
|
||||
active = 'msgctxt';
|
||||
activeMsgstrIndex = 0;
|
||||
msgctxt = extractPoString(line.slice('msgctxt '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgid_plural ')) {
|
||||
active = 'msgidPlural';
|
||||
activeMsgstrIndex = 0;
|
||||
msgidPlural = extractPoString(line.slice('msgid_plural '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgid ')) {
|
||||
active = 'msgid';
|
||||
activeMsgstrIndex = 0;
|
||||
msgid = extractPoString(line.slice('msgid '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgstrIndexed = line.match(/^msgstr\[(\d+)\]\s+/);
|
||||
if (msgstrIndexed) {
|
||||
active = 'msgstr';
|
||||
activeMsgstrIndex = Number(msgstrIndexed[1]);
|
||||
const rest = line.slice(msgstrIndexed[0].length);
|
||||
msgstrMap.set(activeMsgstrIndex, extractPoString(rest));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgstr ')) {
|
||||
active = 'msgstr';
|
||||
activeMsgstrIndex = 0;
|
||||
msgstrMap.set(0, extractPoString(line.slice('msgstr '.length)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
const part = extractPoString(line);
|
||||
|
||||
if (active === 'msgctxt') msgctxt = (msgctxt ?? '') + part;
|
||||
else if (active === 'msgid') msgid = (msgid ?? '') + part;
|
||||
else if (active === 'msgidPlural') msgidPlural = (msgidPlural ?? '') + part;
|
||||
else if (active === 'msgstr') msgstrMap.set(activeMsgstrIndex, (msgstrMap.get(activeMsgstrIndex) ?? '') + part);
|
||||
}
|
||||
}
|
||||
|
||||
if (msgid == null) return null;
|
||||
|
||||
if (msgid === '') return null;
|
||||
|
||||
const key = msgctxt ? `${msgctxt}\u0004${msgid}` : msgid;
|
||||
|
||||
if (msgidPlural != null) {
|
||||
const keys = Array.from(msgstrMap.keys());
|
||||
const maxIndex = keys.length ? Math.max(...keys.map((n) => Number(n))) : 0;
|
||||
|
||||
const arr = [];
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
arr[i] = msgstrMap.get(i) ?? '';
|
||||
}
|
||||
|
||||
return {key, value: arr};
|
||||
}
|
||||
|
||||
return {key, value: msgstrMap.get(0) ?? ''};
|
||||
}
|
||||
|
||||
function extractPoString(str) {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
|
||||
|
||||
return trimmed.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
122
fluxer/fluxer_app/scripts/build/rspack/static-files.mjs
Normal file
122
fluxer/fluxer_app/scripts/build/rspack/static-files.mjs
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {sources} from '@rspack/core';
|
||||
|
||||
function normalizeEndpoint(staticCdnEndpoint) {
|
||||
if (!staticCdnEndpoint) return '';
|
||||
return staticCdnEndpoint.endsWith('/') ? staticCdnEndpoint.slice(0, -1) : staticCdnEndpoint;
|
||||
}
|
||||
|
||||
function generateManifest(staticCdnEndpointRaw) {
|
||||
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
|
||||
|
||||
const manifest = {
|
||||
name: 'Fluxer',
|
||||
short_name: 'Fluxer',
|
||||
description:
|
||||
'Fluxer is a free and open source instant messaging and VoIP platform built for friends, groups, and communities.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
theme_color: '#4641D9',
|
||||
background_color: '#2b2d31',
|
||||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/android-chrome-192x192.png`,
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/android-chrome-512x512.png`,
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/apple-touch-icon.png`,
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/favicon-32x32.png`,
|
||||
sizes: '32x32',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/favicon-16x16.png`,
|
||||
sizes: '16x16',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
}
|
||||
|
||||
function generateBrowserConfig(staticCdnEndpointRaw) {
|
||||
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
|
||||
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="${staticCdnEndpoint}/web/mstile-150x150.png"/>
|
||||
<TileColor>#4641D9</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>`;
|
||||
}
|
||||
|
||||
function generateRobotsTxt() {
|
||||
return 'User-agent: *\nAllow: /\n';
|
||||
}
|
||||
|
||||
export class StaticFilesPlugin {
|
||||
constructor(options) {
|
||||
this.staticCdnEndpoint = options?.staticCdnEndpoint ?? '';
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('StaticFilesPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{
|
||||
name: 'StaticFilesPlugin',
|
||||
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
|
||||
},
|
||||
() => {
|
||||
compilation.emitAsset('manifest.json', new sources.RawSource(generateManifest(this.staticCdnEndpoint)));
|
||||
compilation.emitAsset(
|
||||
'browserconfig.xml',
|
||||
new sources.RawSource(generateBrowserConfig(this.staticCdnEndpoint)),
|
||||
);
|
||||
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function staticFilesPlugin(options) {
|
||||
return new StaticFilesPlugin(options);
|
||||
}
|
||||
63
fluxer/fluxer_app/scripts/build/rspack/wasm.mjs
Normal file
63
fluxer/fluxer_app/scripts/build/rspack/wasm.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default function wasmLoader(_source) {
|
||||
const callback = this.async();
|
||||
|
||||
if (!callback) {
|
||||
throw new Error('Async loader not supported');
|
||||
}
|
||||
|
||||
const wasmPath = this.resourcePath;
|
||||
|
||||
fs.promises
|
||||
.readFile(wasmPath)
|
||||
.then((wasmContent) => {
|
||||
const base64 = wasmContent.toString('base64');
|
||||
const code = `
|
||||
const wasmBase64 = "${base64}";
|
||||
const wasmBinary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
|
||||
export default wasmBinary;
|
||||
`;
|
||||
callback(null, code);
|
||||
})
|
||||
.catch((err) => {
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
|
||||
export function wasmModuleRule() {
|
||||
return {
|
||||
test: /\.wasm$/,
|
||||
exclude: [/node_modules/],
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: path.join(__dirname, 'wasm.mjs'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
24
fluxer/fluxer_app/scripts/build/tsconfig.json
Normal file
24
fluxer/fluxer_app/scripts/build/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2023"],
|
||||
"noEmit": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"jsx": "preserve",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"paths": {
|
||||
"@app_scripts/*": ["./../*"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx"]
|
||||
}
|
||||
82
fluxer/fluxer_app/scripts/build/types.d.ts
vendored
Normal file
82
fluxer/fluxer_app/scripts/build/types.d.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare module 'postcss' {
|
||||
interface ProcessOptions {
|
||||
from?: string;
|
||||
to?: string;
|
||||
map?: boolean | {inline?: boolean; prev?: boolean | string | object; annotation?: boolean | string};
|
||||
parser?: unknown;
|
||||
stringifier?: unknown;
|
||||
syntax?: unknown;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
css: string;
|
||||
map?: unknown;
|
||||
root: unknown;
|
||||
processor: unknown;
|
||||
messages: Array<unknown>;
|
||||
opts: ProcessOptions;
|
||||
}
|
||||
|
||||
interface Plugin {
|
||||
postcssPlugin: string;
|
||||
Once?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
Root?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
RootExit?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
AtRule?(atRule: unknown, helpers: unknown): void | Promise<void>;
|
||||
AtRuleExit?(atRule: unknown, helpers: unknown): void | Promise<void>;
|
||||
Rule?(rule: unknown, helpers: unknown): void | Promise<void>;
|
||||
RuleExit?(rule: unknown, helpers: unknown): void | Promise<void>;
|
||||
Declaration?(declaration: unknown, helpers: unknown): void | Promise<void>;
|
||||
DeclarationExit?(declaration: unknown, helpers: unknown): void | Promise<void>;
|
||||
Comment?(comment: unknown, helpers: unknown): void | Promise<void>;
|
||||
CommentExit?(comment: unknown, helpers: unknown): void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Processor {
|
||||
process(css: string, options?: ProcessOptions): Promise<Result>;
|
||||
}
|
||||
|
||||
function postcss(plugins?: Array<Plugin | ((options?: unknown) => Plugin)>): Processor;
|
||||
|
||||
export default postcss;
|
||||
}
|
||||
|
||||
declare module 'postcss-modules' {
|
||||
interface PostcssModulesOptions {
|
||||
localsConvention?:
|
||||
| 'camelCase'
|
||||
| 'camelCaseOnly'
|
||||
| 'dashes'
|
||||
| 'dashesOnly'
|
||||
| ((originalClassName: string, generatedClassName: string, inputFile: string) => string);
|
||||
generateScopedName?: string | ((name: string, filename: string, css: string) => string);
|
||||
getJSON?(cssFileName: string, json: Record<string, string>, outputFileName?: string): void;
|
||||
hashPrefix?: string;
|
||||
scopeBehaviour?: 'global' | 'local';
|
||||
globalModulePaths?: Array<RegExp>;
|
||||
root?: string;
|
||||
}
|
||||
|
||||
function postcssModules(options?: PostcssModulesOptions): import('postcss').Plugin;
|
||||
|
||||
export default postcssModules;
|
||||
}
|
||||
75
fluxer/fluxer_app/scripts/build/utils/Assets.tsx
Normal file
75
fluxer/fluxer_app/scripts/build/utils/Assets.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '@app_scripts/build/Config';
|
||||
|
||||
export async function copyPublicAssets(): Promise<void> {
|
||||
if (!fs.existsSync(PUBLIC_DIR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(PUBLIC_DIR, {recursive: true});
|
||||
for (const file of files) {
|
||||
const srcPath = path.join(PUBLIC_DIR, file.toString());
|
||||
const destPath = path.join(DIST_DIR, file.toString());
|
||||
|
||||
const stat = await fs.promises.stat(srcPath);
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.mkdir(path.dirname(destPath), {recursive: true});
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyWasmFiles(): Promise<void> {
|
||||
const libfluxcoreDir = path.join(PKGS_DIR, 'libfluxcore');
|
||||
const wasmFile = path.join(libfluxcoreDir, 'libfluxcore_bg.wasm');
|
||||
|
||||
if (fs.existsSync(wasmFile)) {
|
||||
await fs.promises.copyFile(wasmFile, path.join(ASSETS_DIR, 'libfluxcore_bg.wasm'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUnusedCssAssets(assetsDir: string, keepFiles: Array<string>): Promise<void> {
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keepNames = new Set<string>();
|
||||
for (const file of keepFiles) {
|
||||
const base = path.basename(file);
|
||||
keepNames.add(base);
|
||||
if (base.endsWith('.css')) {
|
||||
keepNames.add(`${base}.map`);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(assetsDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.css') && !entry.endsWith('.css.map')) {
|
||||
continue;
|
||||
}
|
||||
if (keepNames.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.rm(path.join(assetsDir, entry), {force: true});
|
||||
}
|
||||
}
|
||||
147
fluxer/fluxer_app/scripts/build/utils/CssDts.tsx
Normal file
147
fluxer/fluxer_app/scripts/build/utils/CssDts.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {PKGS_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import postcss from 'postcss';
|
||||
import postcssModules from 'postcss-modules';
|
||||
|
||||
const RESERVED_KEYWORDS = new Set([
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'continue',
|
||||
'debugger',
|
||||
'default',
|
||||
'delete',
|
||||
'do',
|
||||
'else',
|
||||
'export',
|
||||
'extends',
|
||||
'finally',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'instanceof',
|
||||
'new',
|
||||
'return',
|
||||
'super',
|
||||
'switch',
|
||||
'this',
|
||||
'throw',
|
||||
'try',
|
||||
'typeof',
|
||||
'var',
|
||||
'void',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
'enum',
|
||||
'implements',
|
||||
'interface',
|
||||
'let',
|
||||
'package',
|
||||
'private',
|
||||
'protected',
|
||||
'public',
|
||||
'static',
|
||||
'await',
|
||||
'class',
|
||||
'const',
|
||||
]);
|
||||
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
if (RESERVED_KEYWORDS.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
||||
}
|
||||
|
||||
function generateDtsContent(classNames: Record<string, string>): string {
|
||||
const validClassNames = Object.keys(classNames).filter(isValidIdentifier);
|
||||
const typeMembers = validClassNames.map((name) => `\treadonly ${name}: string;`).join('\n');
|
||||
const defaultExportType =
|
||||
validClassNames.length > 0 ? `{\n${typeMembers}\n\treadonly [key: string]: string;\n}` : 'Record<string, string>';
|
||||
|
||||
return `declare const styles: ${defaultExportType};\nexport default styles;\n`;
|
||||
}
|
||||
|
||||
async function findCssModuleFiles(dir: string): Promise<Array<string>> {
|
||||
const files: Array<string> = [];
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
const entries = await fs.promises.readdir(currentDir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') {
|
||||
await walk(fullPath);
|
||||
}
|
||||
} else if (entry.name.endsWith('.module.css')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function generateDtsForFile(cssPath: string): Promise<void> {
|
||||
const cssContent = await fs.promises.readFile(cssPath, 'utf-8');
|
||||
let exportedClassNames: Record<string, string> = {};
|
||||
|
||||
await postcss([
|
||||
postcssModules({
|
||||
localsConvention: 'camelCaseOnly',
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
getJSON(_cssFileName: string, json: Record<string, string>) {
|
||||
exportedClassNames = json;
|
||||
},
|
||||
}),
|
||||
]).process(cssContent, {from: cssPath});
|
||||
|
||||
const dtsPath = `${cssPath}.d.ts`;
|
||||
const dtsContent = generateDtsContent(exportedClassNames);
|
||||
await fs.promises.writeFile(dtsPath, dtsContent);
|
||||
}
|
||||
|
||||
export async function generateCssDtsForFile(cssPath: string): Promise<void> {
|
||||
if (!cssPath.endsWith('.module.css')) {
|
||||
return;
|
||||
}
|
||||
await generateDtsForFile(cssPath);
|
||||
}
|
||||
|
||||
export async function generateAllCssDts(): Promise<void> {
|
||||
const srcFiles = await findCssModuleFiles(SRC_DIR);
|
||||
const pkgsFiles = await findCssModuleFiles(PKGS_DIR);
|
||||
const allFiles = [...srcFiles, ...pkgsFiles];
|
||||
|
||||
console.log(`Generating .d.ts files for ${allFiles.length} CSS modules...`);
|
||||
|
||||
await Promise.all(allFiles.map(generateDtsForFile));
|
||||
|
||||
console.log(`Generated ${allFiles.length} CSS module type definitions.`);
|
||||
}
|
||||
94
fluxer/fluxer_app/scripts/build/utils/Html.tsx
Normal file
94
fluxer/fluxer_app/scripts/build/utils/Html.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '@app_scripts/build/Config';
|
||||
|
||||
interface BuildOutput {
|
||||
mainScript: string | null;
|
||||
cssFiles: Array<string>;
|
||||
jsFiles: Array<string>;
|
||||
cssBundleFile: string | null;
|
||||
vendorScripts: Array<string>;
|
||||
}
|
||||
|
||||
interface GenerateHtmlOptions {
|
||||
buildOutput: BuildOutput;
|
||||
production: boolean;
|
||||
}
|
||||
|
||||
async function findCssModulesFile(): Promise<string | null> {
|
||||
if (!fs.existsSync(ASSETS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
const files = await fs.promises.readdir(ASSETS_DIR);
|
||||
const stylesFiles = files.filter((name) => name.startsWith('styles.') && name.endsWith('.css'));
|
||||
if (stylesFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latestFile: string | null = null;
|
||||
let latestMtime = 0;
|
||||
|
||||
for (const fileName of stylesFiles) {
|
||||
const filePath = path.join(ASSETS_DIR, fileName);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (latestFile === null || stats.mtimeMs > latestMtime) {
|
||||
latestFile = fileName;
|
||||
latestMtime = stats.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
return latestFile ? `assets/${latestFile}` : null;
|
||||
}
|
||||
|
||||
export async function generateHtml(options: GenerateHtmlOptions): Promise<string> {
|
||||
const {buildOutput, production} = options;
|
||||
|
||||
const indexHtmlPath = path.join(ROOT_DIR, 'index.html');
|
||||
let html = await fs.promises.readFile(indexHtmlPath, 'utf-8');
|
||||
|
||||
const baseUrl = production ? `${CDN_ENDPOINT}/` : '/';
|
||||
|
||||
const cssModulesFile = buildOutput.cssBundleFile ?? (await findCssModulesFile());
|
||||
const cssFiles = cssModulesFile ? [cssModulesFile] : buildOutput.cssFiles;
|
||||
|
||||
const cssLinks = cssFiles.map((file) => `<link rel="stylesheet" href="${baseUrl}${file}">`).join('\n');
|
||||
|
||||
const crossOriginAttr = production && baseUrl.startsWith('http') ? ' crossorigin="anonymous"' : '';
|
||||
|
||||
const jsScripts = buildOutput.mainScript
|
||||
? `<script type="module" src="${baseUrl}${buildOutput.mainScript}"${crossOriginAttr}></script>`
|
||||
: '';
|
||||
|
||||
const buildScriptPreload = (file: string): string =>
|
||||
`<link rel="preload" as="script" href="${baseUrl}${file}"${crossOriginAttr}>`;
|
||||
|
||||
const preloadScripts = [
|
||||
...(buildOutput.vendorScripts ?? []).map(buildScriptPreload),
|
||||
...buildOutput.jsFiles.filter((file) => !file.includes('messages')).map(buildScriptPreload),
|
||||
].join('\n');
|
||||
|
||||
html = html.replace(/<script type="module" src="\/src\/index\.tsx"><\/script>/, jsScripts);
|
||||
const headInsert = [cssLinks, preloadScripts].filter(Boolean).join('\n');
|
||||
html = html.replace('</head>', `${headInsert}\n</head>`);
|
||||
|
||||
return html;
|
||||
}
|
||||
48
fluxer/fluxer_app/scripts/build/utils/Resolve.tsx
Normal file
48
fluxer/fluxer_app/scripts/build/utils/Resolve.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {RESOLVE_EXTENSIONS} from '@app_scripts/build/Config';
|
||||
|
||||
export function tryResolveWithExtensions(basePath: string): string | null {
|
||||
if (fs.existsSync(basePath)) {
|
||||
const stat = fs.statSync(basePath);
|
||||
if (stat.isFile()) {
|
||||
return basePath;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const indexPath = path.join(basePath, `index${ext}`);
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return indexPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const withExt = `${basePath}${ext}`;
|
||||
if (fs.existsSync(withExt)) {
|
||||
return withExt;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
37
fluxer/fluxer_app/scripts/build/utils/ServiceWorker.tsx
Normal file
37
fluxer/fluxer_app/scripts/build/utils/ServiceWorker.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
export async function buildServiceWorker(production: boolean): Promise<void> {
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'service_worker', 'Worker.tsx')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: path.join(DIST_DIR, 'sw.js'),
|
||||
minify: production,
|
||||
sourcemap: true,
|
||||
target: 'esnext',
|
||||
define: {
|
||||
__WB_MANIFEST: '[]',
|
||||
},
|
||||
});
|
||||
}
|
||||
86
fluxer/fluxer_app/scripts/build/utils/Sourcemaps.tsx
Normal file
86
fluxer/fluxer_app/scripts/build/utils/Sourcemaps.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * 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
fluxer/fluxer_app/scripts/translate-i18n.mjs
Normal file
363
fluxer/fluxer_app/scripts/translate-i18n.mjs
Normal file
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
console.error('Error: OPENROUTER_API_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const LOCALES_DIR = new URL('../src/locales', import.meta.url).pathname;
|
||||
const SOURCE_LOCALE = 'en-US';
|
||||
const BATCH_SIZE = 20;
|
||||
const CONCURRENT_LOCALES = 10;
|
||||
const CONCURRENT_BATCHES_PER_LOCALE = 3;
|
||||
|
||||
const LOCALE_NAMES = {
|
||||
ar: 'Arabic',
|
||||
bg: 'Bulgarian',
|
||||
cs: 'Czech',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
'en-GB': 'British English',
|
||||
'es-ES': 'Spanish (Spain)',
|
||||
'es-419': 'Spanish (Latin America)',
|
||||
fi: 'Finnish',
|
||||
fr: 'French',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
hr: 'Croatian',
|
||||
hu: 'Hungarian',
|
||||
id: 'Indonesian',
|
||||
it: 'Italian',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
lt: 'Lithuanian',
|
||||
nl: 'Dutch',
|
||||
no: 'Norwegian',
|
||||
pl: 'Polish',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
ro: 'Romanian',
|
||||
ru: 'Russian',
|
||||
'sv-SE': 'Swedish',
|
||||
th: 'Thai',
|
||||
tr: 'Turkish',
|
||||
uk: 'Ukrainian',
|
||||
vi: 'Vietnamese',
|
||||
'zh-CN': 'Chinese (Simplified)',
|
||||
'zh-TW': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
function parsePo(content) {
|
||||
const entries = [];
|
||||
const lines = content.split('\n');
|
||||
let currentEntry = null;
|
||||
let currentField = null;
|
||||
let isHeader = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('#. ')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.comments.push(line);
|
||||
} else if (line.startsWith('#: ')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.references.push(line);
|
||||
} else if (line.startsWith('msgid "')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.msgid = line.slice(7, -1);
|
||||
currentField = 'msgid';
|
||||
} else if (line.startsWith('msgstr "')) {
|
||||
if (currentEntry) {
|
||||
currentEntry.msgstr = line.slice(8, -1);
|
||||
currentField = 'msgstr';
|
||||
}
|
||||
} else if (line.startsWith('"') && line.endsWith('"')) {
|
||||
if (currentEntry && currentField) {
|
||||
currentEntry[currentField] += line.slice(1, -1);
|
||||
}
|
||||
} else if (line === '' && currentEntry) {
|
||||
if (isHeader && currentEntry.msgid === '') {
|
||||
isHeader = false;
|
||||
} else if (currentEntry.msgid !== '') {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
currentEntry = null;
|
||||
currentField = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntry && currentEntry.msgid !== '') {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rebuildPo(content, translations) {
|
||||
const translationMap = new Map(translations.map((t) => [t.msgid, t.msgstr]));
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const blocks = normalized.trimEnd().split(/\n{2,}/g);
|
||||
const nextBlocks = blocks.map((block) => rebuildPoBlock(block, translationMap));
|
||||
return `${nextBlocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function rebuildPoBlock(block, translationMap) {
|
||||
const lines = block.split('\n');
|
||||
const msgidRange = getFieldRange(lines, 'msgid');
|
||||
const msgstrRange = getFieldRange(lines, 'msgstr');
|
||||
|
||||
if (!msgidRange || !msgstrRange) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const hasReferences = lines.some((line) => line.startsWith('#: '));
|
||||
const msgid = readFieldRawValue(lines, msgidRange);
|
||||
if (!hasReferences && msgid === '') {
|
||||
return block;
|
||||
}
|
||||
|
||||
const currentMsgstr = readFieldRawValue(lines, msgstrRange);
|
||||
if (currentMsgstr !== '') {
|
||||
return block;
|
||||
}
|
||||
|
||||
if (!translationMap.has(msgid)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const newMsgstr = translationMap.get(msgid);
|
||||
const newMsgstrLine = `msgstr "${escapePo(newMsgstr)}"`;
|
||||
return [...lines.slice(0, msgstrRange.startIndex), newMsgstrLine, ...lines.slice(msgstrRange.endIndex)].join('\n');
|
||||
}
|
||||
|
||||
function getFieldRange(lines, field) {
|
||||
const startIndex = lines.findIndex((line) => line.startsWith(`${field} `));
|
||||
if (startIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
let endIndex = startIndex + 1;
|
||||
while (endIndex < lines.length && lines[endIndex].startsWith('"') && lines[endIndex].endsWith('"')) {
|
||||
endIndex++;
|
||||
}
|
||||
return {startIndex, endIndex};
|
||||
}
|
||||
|
||||
function readFieldRawValue(lines, range) {
|
||||
const firstLine = lines[range.startIndex];
|
||||
const match = firstLine.match(/^[a-z]+\s+"(.*)"$/);
|
||||
let value = match ? match[1] : '';
|
||||
for (let i = range.startIndex + 1; i < range.endIndex; i++) {
|
||||
value += lines[i].slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapePo(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function unescapePo(str) {
|
||||
return str.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
async function translateBatch(strings, targetLocale) {
|
||||
const localeName = LOCALE_NAMES[targetLocale] || targetLocale;
|
||||
|
||||
const prompt = `You are a professional translator. Translate the following UI strings from English to ${localeName}.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Preserve ALL placeholders exactly as they appear: {0}, {1}, {name}, {count}, etc.
|
||||
2. Preserve ICU plural syntax exactly: {0, plural, one {...} other {...}}
|
||||
3. Keep technical terms, brand names, and special characters intact
|
||||
4. Match the tone and formality of a modern chat/messaging application
|
||||
5. Return ONLY a JSON array of translated strings in the same order as input
|
||||
6. Do NOT add any explanations or notes
|
||||
|
||||
Input strings (JSON array):
|
||||
${JSON.stringify(strings, null, 2)}
|
||||
|
||||
Output (JSON array of translated strings only):`;
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://fluxer.dev',
|
||||
'X-Title': 'Fluxer i18n Translation',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/gpt-4o-mini',
|
||||
messages: [{role: 'user', content: prompt}],
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Empty response from API');
|
||||
}
|
||||
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error(`Failed to parse JSON from response: ${content}`);
|
||||
}
|
||||
|
||||
const translations = JSON.parse(jsonMatch[0]);
|
||||
|
||||
if (translations.length !== strings.length) {
|
||||
throw new Error(`Translation count mismatch: expected ${strings.length}, got ${translations.length}`);
|
||||
}
|
||||
|
||||
return translations;
|
||||
}
|
||||
|
||||
async function pMap(items, mapper, concurrency) {
|
||||
const results = [];
|
||||
const executing = new Set();
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
const promise = Promise.resolve().then(() => mapper(item, index));
|
||||
results.push(promise);
|
||||
executing.add(promise);
|
||||
|
||||
const clean = () => executing.delete(promise);
|
||||
promise.then(clean, clean);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(results);
|
||||
}
|
||||
|
||||
async function processLocale(locale) {
|
||||
const poPath = join(LOCALES_DIR, locale, 'messages.po');
|
||||
console.log(`[${locale}] Starting...`);
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(poPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(`[${locale}] Error reading file: ${error.message}`);
|
||||
return {locale, translated: 0, errors: 1};
|
||||
}
|
||||
|
||||
const entries = parsePo(content);
|
||||
const untranslated = entries.filter((e) => e.msgstr === '');
|
||||
|
||||
if (untranslated.length === 0) {
|
||||
console.log(`[${locale}] No untranslated strings`);
|
||||
return {locale, translated: 0, errors: 0};
|
||||
}
|
||||
|
||||
console.log(`[${locale}] Found ${untranslated.length} untranslated strings`);
|
||||
|
||||
const batches = [];
|
||||
for (let i = 0; i < untranslated.length; i += BATCH_SIZE) {
|
||||
batches.push({
|
||||
index: Math.floor(i / BATCH_SIZE),
|
||||
total: Math.ceil(untranslated.length / BATCH_SIZE),
|
||||
entries: untranslated.slice(i, i + BATCH_SIZE),
|
||||
});
|
||||
}
|
||||
|
||||
let errorCount = 0;
|
||||
const allTranslations = [];
|
||||
|
||||
const batchResults = await pMap(
|
||||
batches,
|
||||
async (batch) => {
|
||||
const batchStrings = batch.entries.map((e) => unescapePo(e.msgid));
|
||||
|
||||
try {
|
||||
const translatedStrings = await translateBatch(batchStrings, locale);
|
||||
console.log(`[${locale}] Batch ${batch.index + 1}/${batch.total} complete`);
|
||||
|
||||
return batch.entries.map((entry, j) => ({
|
||||
msgid: entry.msgid,
|
||||
msgstr: translatedStrings[j],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[${locale}] Batch ${batch.index + 1}/${batch.total} error: ${error.message}`);
|
||||
errorCount++;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
CONCURRENT_BATCHES_PER_LOCALE,
|
||||
);
|
||||
|
||||
for (const translations of batchResults) {
|
||||
allTranslations.push(...translations);
|
||||
}
|
||||
|
||||
if (allTranslations.length > 0) {
|
||||
const updatedContent = rebuildPo(content, allTranslations);
|
||||
writeFileSync(poPath, updatedContent, 'utf-8');
|
||||
console.log(`[${locale}] Updated ${allTranslations.length} translations`);
|
||||
}
|
||||
|
||||
return {locale, translated: allTranslations.length, errors: errorCount};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting i18n translation...');
|
||||
console.log(`Locales directory: ${LOCALES_DIR}`);
|
||||
console.log(`Concurrency: ${CONCURRENT_LOCALES} locales, ${CONCURRENT_BATCHES_PER_LOCALE} batches per locale`);
|
||||
|
||||
const locales = readdirSync(LOCALES_DIR).filter((d) => d !== SOURCE_LOCALE && LOCALE_NAMES[d]);
|
||||
|
||||
console.log(`Found ${locales.length} locales to process\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const results = await pMap(locales, processLocale, CONCURRENT_LOCALES);
|
||||
|
||||
const totalTranslated = results.reduce((sum, r) => sum + (r?.translated || 0), 0);
|
||||
const totalErrors = results.reduce((sum, r) => sum + (r?.errors || 0), 0);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\nTranslation complete in ${elapsed}s`);
|
||||
console.log(`Total: ${totalTranslated} strings translated, ${totalErrors} errors`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user