mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
workspace: reconcile latest Fluxer into the open source release and continue development there
This commit is contained in:
@@ -0,0 +1,425 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {mkdir, readFile, writeFile} from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import {performance} from 'node:perf_hooks';
|
||||
import {isMainThread, parentPort, Worker, workerData} from 'node:worker_threads';
|
||||
import {encode as encodePng} from 'fast-png';
|
||||
import {encode as encodeJpeg} from 'jpeg-js';
|
||||
import {
|
||||
assemble_apng_frames,
|
||||
assemble_gif_frame_chunks,
|
||||
crop_and_rotate_apng,
|
||||
crop_and_rotate_gif,
|
||||
crop_and_rotate_image,
|
||||
crop_rotate_rgba,
|
||||
decode_apng_frames,
|
||||
decode_gif_frames,
|
||||
encode_apng_frame_payload,
|
||||
encode_apng_frames,
|
||||
encode_gif_frame_chunk,
|
||||
encode_gif_frames,
|
||||
initSync,
|
||||
is_animated_image,
|
||||
} from '../pkgs/libfluxcore/libfluxcore.js';
|
||||
|
||||
const wasmUrl = new URL('../pkgs/libfluxcore/libfluxcore_bg.wasm', import.meta.url);
|
||||
const mediaCacheDir = new URL('../.cache/libfluxcore-bench-media/', import.meta.url);
|
||||
const workerCount = Math.max(1, Math.min(4, os.availableParallelism?.() ?? os.cpus().length ?? 1));
|
||||
const profile = process.argv.find((arg) => arg.startsWith('--profile='))?.slice('--profile='.length) ?? 'standard';
|
||||
const downloadRealMediaOnly = process.argv.includes('--download-realmedia');
|
||||
const offlineRealMedia = process.argv.includes('--offline') || process.env.FLUXCORE_BENCH_OFFLINE === '1';
|
||||
const userAgent = 'Fluxer libfluxcore benchmark (https://fluxer.app)';
|
||||
const realMediaAssets = [
|
||||
{
|
||||
id: 'jpeg-fronalpstock',
|
||||
format: 'jpeg',
|
||||
fileName: 'fronalpstock_big.jpg',
|
||||
expectedBytes: 14_679_474,
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/3/3f/Fronalpstock_big.jpg',
|
||||
source: 'https://commons.wikimedia.org/wiki/File:Fronalpstock_big.jpg',
|
||||
},
|
||||
{
|
||||
id: 'png-snr-demo',
|
||||
format: 'png',
|
||||
fileName: 'snr_image_demonstration.png',
|
||||
expectedBytes: 1_146_718,
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/f/f9/SNR_image_demonstration.png',
|
||||
source: 'https://commons.wikimedia.org/wiki/File:SNR_image_demonstration.png',
|
||||
},
|
||||
{
|
||||
id: 'gif-gerridae',
|
||||
format: 'gif',
|
||||
fileName: 'gerridae_1200x675.gif',
|
||||
expectedBytes: 6_318_395,
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/f/f0/%22%2Barya%2B%22_Gerridae_-_Anggang_kayak_-_laba-laba_air_-_Lengkongwetan_2020_1.gif',
|
||||
source:
|
||||
'https://commons.wikimedia.org/wiki/File:%22%2Barya%2B%22_Gerridae_-_Anggang_kayak_-_laba-laba_air_-_Lengkongwetan_2020_1.gif',
|
||||
},
|
||||
{
|
||||
id: 'apng-human-male',
|
||||
format: 'apng',
|
||||
fileName: '201803_human_male_anim.png',
|
||||
expectedBytes: 8_150_411,
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/c/c7/201803_Human_Male_anim.png',
|
||||
source: 'https://commons.wikimedia.org/wiki/File:201803_Human_Male_anim.png',
|
||||
},
|
||||
{
|
||||
id: 'webp-samsung-note',
|
||||
format: 'webp',
|
||||
fileName: 'samsung_galaxy_note.webp',
|
||||
expectedBytes: 7_326_306,
|
||||
url: 'https://upload.wikimedia.org/wikipedia/commons/5/59/Samsung_Galaxy_Note.WebP',
|
||||
source: 'https://commons.wikimedia.org/wiki/File:Samsung_Galaxy_Note.WebP',
|
||||
},
|
||||
{
|
||||
id: 'avif-hato',
|
||||
format: 'avif',
|
||||
fileName: 'hato.profile0.8bpc.yuv420.avif',
|
||||
expectedBytes: 259_104,
|
||||
url: 'https://raw.githubusercontent.com/link-u/avif-sample-images/master/hato.profile0.8bpc.yuv420.avif',
|
||||
source: 'https://github.com/link-u/avif-sample-images',
|
||||
},
|
||||
];
|
||||
|
||||
function initWasm(bytes) {
|
||||
initSync({module: bytes});
|
||||
}
|
||||
|
||||
function makeFrame(width, height, seed) {
|
||||
const rgba = new Uint8Array(width * height * 4);
|
||||
for (let index = 0, pixel = 0; index < rgba.length; index += 4, pixel += 1) {
|
||||
const x = pixel % width;
|
||||
const y = Math.floor(pixel / width);
|
||||
rgba[index] = (x * 3 + seed * 17) & 0xff;
|
||||
rgba[index + 1] = (y * 5 + seed * 29) & 0xff;
|
||||
rgba[index + 2] = ((x ^ y) + seed * 11) & 0xff;
|
||||
rgba[index + 3] = 255;
|
||||
}
|
||||
return {rgba, width, height, delayMs: 40};
|
||||
}
|
||||
|
||||
function batches(frames, count) {
|
||||
const out = Array.from({length: count}, () => []);
|
||||
for (let index = 0; index < frames.length; index += 1) out[index % count].push({frame: frames[index], index});
|
||||
return out.filter((batch) => batch.length > 0);
|
||||
}
|
||||
|
||||
async function encodeInWorkers(kind, frames, wasmBytes, options = {}) {
|
||||
const jobs = batches(frames, Math.min(workerCount, frames.length));
|
||||
const workers = jobs.map(() => new Worker(new URL(import.meta.url), {workerData: {wasmBytes}}));
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
jobs.map(
|
||||
(batch, index) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const worker = workers[index];
|
||||
const payload = batch.map(({frame, index: frameIndex}) => ({
|
||||
frame: {
|
||||
rgba: options.transferFrames ? frame.rgba : frame.rgba.slice(),
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
delayMs: frame.delayMs,
|
||||
},
|
||||
index: frameIndex,
|
||||
}));
|
||||
worker.once('message', (message) => {
|
||||
if (message.ok) resolve(message.frames);
|
||||
else reject(new Error(message.error || 'worker encode failed'));
|
||||
});
|
||||
worker.once('error', reject);
|
||||
worker.postMessage(
|
||||
{kind, frames: payload},
|
||||
payload.map(({frame}) => frame.rgba.buffer),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const ordered = new Array(frames.length);
|
||||
for (const result of results) {
|
||||
for (const item of result) ordered[item.index] = item.frame;
|
||||
}
|
||||
if (kind === 'apng') return assemble_apng_frames(ordered);
|
||||
return assemble_gif_frame_chunks(ordered);
|
||||
} finally {
|
||||
await Promise.allSettled(workers.map((worker) => worker.terminate()));
|
||||
}
|
||||
}
|
||||
|
||||
async function transformInWorkers(frames, cropParams, wasmBytes) {
|
||||
const jobs = batches(frames, Math.min(workerCount, frames.length));
|
||||
const workers = jobs.map(() => new Worker(new URL(import.meta.url), {workerData: {wasmBytes}}));
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
jobs.map(
|
||||
(batch, index) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const worker = workers[index];
|
||||
const payload = batch.map(({frame, index: frameIndex}) => ({
|
||||
frame: {
|
||||
rgba: frame.rgba,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
delayMs: frame.delayMs,
|
||||
},
|
||||
index: frameIndex,
|
||||
}));
|
||||
worker.once('message', (message) => {
|
||||
if (message.ok) resolve(message.frames);
|
||||
else reject(new Error(message.error || 'worker transform failed'));
|
||||
});
|
||||
worker.once('error', reject);
|
||||
worker.postMessage(
|
||||
{kind: 'transform', frames: payload, cropParams},
|
||||
payload.map(({frame}) => frame.rgba.buffer),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const ordered = new Array(frames.length);
|
||||
for (const result of results) {
|
||||
for (const item of result) ordered[item.index] = item.frame;
|
||||
}
|
||||
return ordered;
|
||||
} finally {
|
||||
await Promise.allSettled(workers.map((worker) => worker.terminate()));
|
||||
}
|
||||
}
|
||||
|
||||
function benchSync(label, iterations, fn) {
|
||||
fn();
|
||||
const start = performance.now();
|
||||
let result;
|
||||
for (let index = 0; index < iterations; index += 1) result = fn();
|
||||
const elapsed = performance.now() - start;
|
||||
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
|
||||
console.log(`${label}: ${(elapsed / iterations).toFixed(2)} ms/op (${iterations} iters, ${bytes} bytes last result)`);
|
||||
}
|
||||
|
||||
async function benchAsync(label, iterations, fn) {
|
||||
await fn();
|
||||
const start = performance.now();
|
||||
let result;
|
||||
for (let index = 0; index < iterations; index += 1) result = await fn();
|
||||
const elapsed = performance.now() - start;
|
||||
const bytes = result?.byteLength ?? 0;
|
||||
console.log(`${label}: ${(elapsed / iterations).toFixed(2)} ms/op (${iterations} iters, ${bytes} bytes last result)`);
|
||||
}
|
||||
|
||||
function benchOnce(label, fn) {
|
||||
const start = performance.now();
|
||||
const result = fn();
|
||||
const elapsed = performance.now() - start;
|
||||
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
|
||||
console.log(`${label}: ${elapsed.toFixed(2)} ms (${bytes} bytes result)`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function benchOnceAsync(label, fn) {
|
||||
const start = performance.now();
|
||||
const result = await fn();
|
||||
const elapsed = performance.now() - start;
|
||||
const bytes = result?.byteLength ?? result?.rgba?.byteLength ?? 0;
|
||||
console.log(`${label}: ${elapsed.toFixed(2)} ms (${bytes} bytes result)`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertExpectedAssetSize(asset, bytes) {
|
||||
if (asset.expectedBytes != null && bytes.byteLength !== asset.expectedBytes) {
|
||||
throw new Error(`${asset.id} expected ${asset.expectedBytes} bytes, got ${bytes.byteLength}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function readOrDownloadAsset(asset, options = {}) {
|
||||
await mkdir(mediaCacheDir, {recursive: true});
|
||||
const fileUrl = new URL(asset.fileName, mediaCacheDir);
|
||||
try {
|
||||
const data = await readFile(fileUrl);
|
||||
assertExpectedAssetSize(asset, data);
|
||||
console.log(`asset ${asset.id}: cache hit (${data.byteLength} bytes)`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (options.offline) {
|
||||
throw new Error(
|
||||
`missing or invalid local media asset ${asset.fileName}; run pnpm wasm:bench:download-realmedia`,
|
||||
{cause: error},
|
||||
);
|
||||
}
|
||||
}
|
||||
const response = await fetch(asset.url, {headers: {'User-Agent': userAgent}});
|
||||
if (!response.ok) throw new Error(`failed to download ${asset.id}: HTTP ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertExpectedAssetSize(asset, bytes);
|
||||
await writeFile(fileUrl, bytes);
|
||||
console.log(`asset ${asset.id}: downloaded ${bytes.byteLength} bytes from ${asset.source}`);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function loadRealMediaAssets(options = {}) {
|
||||
const entries = await Promise.all(
|
||||
realMediaAssets.map(async (asset) => [asset.id, {...asset, bytes: await readOrDownloadAsset(asset, options)}]),
|
||||
);
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
async function runStandardProfile(wasmBytes) {
|
||||
const cropSource = makeFrame(1024, 1024, 1);
|
||||
benchSync('crop_rotate_rgba 1024x1024 -> 512x512 rotate90', 60, () =>
|
||||
crop_rotate_rgba(cropSource.rgba, cropSource.width, cropSource.height, 64, 64, 768, 768, 90, 512, 512),
|
||||
);
|
||||
const apngFrames = Array.from({length: 24}, (_, index) => makeFrame(320, 320, index));
|
||||
benchSync('encode_apng_frames serial 24x320', 4, () => encode_apng_frames(apngFrames));
|
||||
await benchAsync('encode_apng_frames worker payloads 24x320', 4, () =>
|
||||
encodeInWorkers('apng', apngFrames, wasmBytes),
|
||||
);
|
||||
const gifFrames = Array.from({length: 16}, (_, index) => makeFrame(192, 192, index));
|
||||
benchSync('encode_gif_frames serial 16x192', 3, () => encode_gif_frames(gifFrames));
|
||||
await benchAsync('encode_gif_frames worker chunks 16x192', 3, () => encodeInWorkers('gif', gifFrames, wasmBytes));
|
||||
}
|
||||
|
||||
async function runHighResProfile(wasmBytes) {
|
||||
const crop4k = makeFrame(3840, 2160, 11);
|
||||
benchSync('crop_rotate_rgba 4K -> 1080p rotate90', 10, () =>
|
||||
crop_rotate_rgba(crop4k.rgba, crop4k.width, crop4k.height, 420, 120, 3000, 1800, 90, 1080, 1920),
|
||||
);
|
||||
const staticFrame = makeFrame(1920, 1080, 17);
|
||||
const pngSource = encodePng({
|
||||
width: staticFrame.width,
|
||||
height: staticFrame.height,
|
||||
data: staticFrame.rgba,
|
||||
depth: 8,
|
||||
channels: 4,
|
||||
});
|
||||
const jpegSource = encodeJpeg(
|
||||
{width: staticFrame.width, height: staticFrame.height, data: staticFrame.rgba},
|
||||
88,
|
||||
).data;
|
||||
benchSync('crop_and_rotate_image PNG 1080p -> PNG 720p', 3, () =>
|
||||
crop_and_rotate_image(pngSource, 'png', 160, 90, 1600, 900, 0, 1280, 720),
|
||||
);
|
||||
benchSync('crop_and_rotate_image JPEG 1080p -> JPEG 720p', 3, () =>
|
||||
crop_and_rotate_image(jpegSource, 'jpeg', 160, 90, 1600, 900, 0, 1280, 720),
|
||||
);
|
||||
const apngFrames = Array.from({length: 6}, (_, index) => makeFrame(1920, 1080, index + 31));
|
||||
benchSync('encode_apng_frames serial 6x1080p', 1, () => encode_apng_frames(apngFrames));
|
||||
await benchAsync('encode_apng_frames worker payloads 6x1080p', 1, () =>
|
||||
encodeInWorkers('apng', apngFrames, wasmBytes),
|
||||
);
|
||||
const gifFrames = Array.from({length: 6}, (_, index) => makeFrame(1280, 720, index + 51));
|
||||
benchSync('encode_gif_frames serial 6x720p', 1, () => encode_gif_frames(gifFrames));
|
||||
await benchAsync('encode_gif_frames worker chunks 6x720p', 1, () => encodeInWorkers('gif', gifFrames, wasmBytes));
|
||||
}
|
||||
|
||||
async function runRealMediaProfile(wasmBytes) {
|
||||
const assets = await loadRealMediaAssets({offline: offlineRealMedia});
|
||||
const jpeg = assets.get('jpeg-fronalpstock').bytes;
|
||||
const png = assets.get('png-snr-demo').bytes;
|
||||
const gif = assets.get('gif-gerridae').bytes;
|
||||
const apng = assets.get('apng-human-male').bytes;
|
||||
const webp = assets.get('webp-samsung-note').bytes;
|
||||
const avif = assets.get('avif-hato').bytes;
|
||||
benchOnce('real JPEG crop Fronalpstock 10109x4542 -> JPEG 1920x1080', () =>
|
||||
crop_and_rotate_image(jpeg, 'jpeg', 1000, 400, 8000, 3600, 0, 1920, 1080),
|
||||
);
|
||||
benchOnce('real PNG crop SNR demo 3840x2880 -> PNG 1280x720', () =>
|
||||
crop_and_rotate_image(png, 'png', 320, 240, 3200, 1800, 0, 1280, 720),
|
||||
);
|
||||
benchOnce('real APNG serial crop 1920x1920x22 -> 720x720', () =>
|
||||
crop_and_rotate_apng(apng, 240, 240, 1440, 1440, 0, 720, 720),
|
||||
);
|
||||
await benchOnceAsync('real APNG worker-style crop 1920x1920x22 -> 720x720', async () => {
|
||||
const frames = decode_apng_frames(apng);
|
||||
const transformed = await transformInWorkers(
|
||||
frames,
|
||||
{x: 240, y: 240, width: 1440, height: 1440, rotation: 0, resizeWidth: 720, resizeHeight: 720},
|
||||
wasmBytes,
|
||||
);
|
||||
return encodeInWorkers('apng', transformed, wasmBytes, {transferFrames: true});
|
||||
});
|
||||
benchOnce('real GIF serial crop 1200x675x13 -> 854x480', () =>
|
||||
crop_and_rotate_gif(gif, 80, 45, 1040, 585, 0, 854, 480),
|
||||
);
|
||||
await benchOnceAsync('real GIF worker-style crop 1200x675x13 -> 854x480', async () => {
|
||||
const frames = decode_gif_frames(gif);
|
||||
const transformed = await transformInWorkers(
|
||||
frames,
|
||||
{x: 80, y: 45, width: 1040, height: 585, rotation: 0, resizeWidth: 854, resizeHeight: 480},
|
||||
wasmBytes,
|
||||
);
|
||||
return encodeInWorkers('gif', transformed, wasmBytes, {transferFrames: true});
|
||||
});
|
||||
benchSync('real WebP animated detection 4032x3024', 1000, () => (is_animated_image(webp) ? webp : webp));
|
||||
benchSync('real AVIF animated detection 3082x2048', 1000, () => (is_animated_image(avif) ? avif : avif));
|
||||
console.log(
|
||||
'WebP and AVIF decode/crop are routed through browser ImageDecoder or the native media bridge, not this Node wasm loader.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
initWasm(workerData.wasmBytes);
|
||||
parentPort.on('message', (message) => {
|
||||
try {
|
||||
if (message.kind === 'apng') {
|
||||
const frames = message.frames.map(({frame, index}) => ({
|
||||
frame: encode_apng_frame_payload(frame),
|
||||
index,
|
||||
}));
|
||||
parentPort.postMessage(
|
||||
{ok: true, frames},
|
||||
frames.map(({frame}) => frame.compressed.buffer),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.kind === 'transform') {
|
||||
const frames = message.frames.map(({frame, index}) => {
|
||||
const transformed = crop_rotate_rgba(
|
||||
frame.rgba,
|
||||
frame.width,
|
||||
frame.height,
|
||||
message.cropParams.x,
|
||||
message.cropParams.y,
|
||||
message.cropParams.width,
|
||||
message.cropParams.height,
|
||||
message.cropParams.rotation,
|
||||
message.cropParams.resizeWidth,
|
||||
message.cropParams.resizeHeight,
|
||||
);
|
||||
return {frame: {...transformed, delayMs: frame.delayMs}, index};
|
||||
});
|
||||
parentPort.postMessage(
|
||||
{ok: true, frames},
|
||||
frames.map(({frame}) => frame.rgba.buffer),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const frames = message.frames.map(({frame, index}) => ({
|
||||
frame: encode_gif_frame_chunk(frame, index === 0),
|
||||
index,
|
||||
}));
|
||||
parentPort.postMessage(
|
||||
{ok: true, frames},
|
||||
frames.map(({frame}) => frame.data.buffer),
|
||||
);
|
||||
} catch (error) {
|
||||
parentPort.postMessage({ok: false, error: error instanceof Error ? error.message : String(error)});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (downloadRealMediaOnly) {
|
||||
await loadRealMediaAssets({offline: false});
|
||||
console.log(`real media assets are local in ${mediaCacheDir.pathname}`);
|
||||
process.exit(0);
|
||||
}
|
||||
const wasmBytes = await readFile(wasmUrl);
|
||||
initWasm(wasmBytes);
|
||||
console.log(
|
||||
`libfluxcore benchmark (${workerCount} encode workers, profile=${profile}, SIMD=${process.env.FLUXCORE_WASM_SIMD === '1' ? 'on' : 'off'})`,
|
||||
);
|
||||
if (profile === 'highres') {
|
||||
await runHighResProfile(wasmBytes);
|
||||
} else if (profile === 'realmedia') {
|
||||
await runRealMediaProfile(wasmBytes);
|
||||
} else {
|
||||
await runStandardProfile(wasmBytes);
|
||||
}
|
||||
}
|
||||
@@ -1,364 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type ChildProcess, spawn} from 'node:child_process';
|
||||
import type {Dirent} from 'node:fs';
|
||||
import {mkdir, readdir, readFile, rm, stat, writeFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
const metadataFile = path.join(projectRoot, '.devserver-cache.json');
|
||||
const binDir = path.join(projectRoot, 'node_modules', '.bin');
|
||||
const rspackBin = path.join(binDir, 'rspack');
|
||||
const tcmBin = path.join(binDir, 'tcm');
|
||||
const DEFAULT_SKIP_DIRS = new Set(['.git', 'node_modules', '.turbo', 'dist', 'target', 'pkg', 'pkgs']);
|
||||
let metadataCache: Metadata | null = null;
|
||||
|
||||
interface StepMetadata {
|
||||
lastRun: number;
|
||||
inputs: Record<string, number>;
|
||||
}
|
||||
|
||||
interface Metadata {
|
||||
[key: string]: StepMetadata;
|
||||
}
|
||||
|
||||
type StepKey = 'wasm' | 'colors' | 'masks' | 'cssTypes' | 'lingui';
|
||||
|
||||
async function loadMetadata(): Promise<void> {
|
||||
if (metadataCache !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await readFile(metadataFile, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
metadataCache = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Metadata;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
metadataCache = {};
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to read dev server metadata cache, falling back to full rebuild:', error);
|
||||
metadataCache = {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMetadata(): Promise<void> {
|
||||
if (!metadataCache) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(path.dirname(metadataFile), {recursive: true});
|
||||
await writeFile(metadataFile, JSON.stringify(metadataCache, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function haveInputsChanged(prev: Record<string, number>, next: Record<string, number>): boolean {
|
||||
const prevKeys = Object.keys(prev);
|
||||
const nextKeys = Object.keys(next);
|
||||
if (prevKeys.length !== nextKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const key of nextKeys) {
|
||||
if (!Object.hasOwn(prev, key) || prev[key] !== next[key]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldRunStep(stepName: StepKey, inputs: Record<string, number>): boolean {
|
||||
if (!metadataCache) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = metadataCache[stepName];
|
||||
if (!entry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return haveInputsChanged(entry.inputs, inputs);
|
||||
}
|
||||
|
||||
async function collectFileStats(paths: ReadonlyArray<string>): Promise<Record<string, number>> {
|
||||
const result: Record<string, number> = {};
|
||||
for (const relPath of paths) {
|
||||
const absolutePath = path.join(projectRoot, relPath);
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error(`Expected ${relPath} to be a file when collecting dev server cache inputs.`);
|
||||
}
|
||||
result[relPath] = fileStat.mtimeMs;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function collectDirectoryStats(
|
||||
rootRel: string,
|
||||
predicate: (relPath: string) => boolean,
|
||||
): Promise<Record<string, number>> {
|
||||
const accumulator: Record<string, number> = {};
|
||||
|
||||
async function walk(relPath: string): Promise<void> {
|
||||
const absoluteDir = path.join(projectRoot, relPath);
|
||||
let entries: Array<Dirent>;
|
||||
try {
|
||||
entries = await readdir(absoluteDir, {withFileTypes: true});
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (DEFAULT_SKIP_DIRS.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
await walk(path.join(relPath, entry.name));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileRel = path.join(relPath, entry.name);
|
||||
if (!predicate(fileRel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileStat = await stat(path.join(projectRoot, fileRel));
|
||||
accumulator[fileRel] = fileStat.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
await walk(rootRel);
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
async function runCachedStep(
|
||||
stepName: StepKey,
|
||||
gatherInputs: () => Promise<Record<string, number>>,
|
||||
command: string,
|
||||
args: ReadonlyArray<string>,
|
||||
): Promise<void> {
|
||||
const inputs = await gatherInputs();
|
||||
if (!shouldRunStep(stepName, inputs)) {
|
||||
console.log(`Skipping ${command} ${args.join(' ')} (no changes detected)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await runCommand(command, args);
|
||||
|
||||
metadataCache ??= {};
|
||||
metadataCache[stepName] = {lastRun: Date.now(), inputs};
|
||||
await saveMetadata();
|
||||
}
|
||||
|
||||
async function gatherWasmInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats(path.join('crates', 'libfluxcore'), () => true);
|
||||
}
|
||||
|
||||
async function gatherColorInputs(): Promise<Record<string, number>> {
|
||||
return collectFileStats(['scripts/GenerateColorSystem.tsx']);
|
||||
}
|
||||
|
||||
async function gatherMaskInputs(): Promise<Record<string, number>> {
|
||||
return collectFileStats(['scripts/GenerateAvatarMasks.tsx', 'src/components/uikit/TypingConstants.tsx']);
|
||||
}
|
||||
|
||||
async function gatherCssModuleInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats('src', (relPath) => relPath.endsWith('.module.css'));
|
||||
}
|
||||
|
||||
async function gatherLinguiInputs(): Promise<Record<string, number>> {
|
||||
return collectDirectoryStats(path.join('src', 'locales'), (relPath) => relPath.endsWith('.po'));
|
||||
}
|
||||
|
||||
let currentChild: ChildProcess | null = null;
|
||||
let cssTypeWatcher: ChildProcess | null = null;
|
||||
let shuttingDown = false;
|
||||
|
||||
const shutdownSignals: ReadonlyArray<NodeJS.Signals> = ['SIGINT', 'SIGTERM'];
|
||||
|
||||
function handleShutdown(signal: NodeJS.Signals): void {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
console.log(`\nReceived ${signal}, shutting down fluxer app dev server...`);
|
||||
currentChild?.kill('SIGTERM');
|
||||
cssTypeWatcher?.kill('SIGTERM');
|
||||
}
|
||||
|
||||
shutdownSignals.forEach((signal) => {
|
||||
process.on(signal, () => handleShutdown(signal));
|
||||
});
|
||||
|
||||
function runCommand(command: string, args: ReadonlyArray<string>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (shuttingDown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
currentChild = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
currentChild = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
currentChild = null;
|
||||
|
||||
if (shuttingDown) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`${command} ${args.join(' ')} terminated by signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code && code !== 0) {
|
||||
reject(new Error(`${command} ${args.join(' ')} exited with status ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanDist(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const distPath = path.join(projectRoot, 'dist');
|
||||
await rm(distPath, {recursive: true, force: true});
|
||||
}
|
||||
|
||||
function startCssTypeWatcher(): void {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(tcmBin, ['src', '--pattern', '**/*.module.css', '--watch', '--silent'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
cssTypeWatcher = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
if (!shuttingDown) {
|
||||
console.error('CSS type watcher error:', error);
|
||||
}
|
||||
cssTypeWatcher = null;
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
cssTypeWatcher = null;
|
||||
if (!shuttingDown && code !== 0) {
|
||||
console.error(`CSS type watcher exited unexpectedly (code: ${code}, signal: ${signal})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function runRspack(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (shuttingDown) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(rspackBin, ['serve', '--mode', 'development'], {
|
||||
cwd: projectRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
currentChild = child;
|
||||
|
||||
child.once('error', (error) => {
|
||||
currentChild = null;
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
currentChild = null;
|
||||
|
||||
if (shuttingDown) {
|
||||
resolve(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal) {
|
||||
reject(new Error(`rspack serve terminated by signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadMetadata();
|
||||
|
||||
try {
|
||||
await runCachedStep('wasm', gatherWasmInputs, 'pnpm', ['wasm:codegen']);
|
||||
await runCachedStep('colors', gatherColorInputs, 'pnpm', ['generate:colors']);
|
||||
await runCachedStep('masks', gatherMaskInputs, 'pnpm', ['generate:masks']);
|
||||
await runCachedStep('cssTypes', gatherCssModuleInputs, 'pnpm', ['generate:css-types']);
|
||||
await runCachedStep('lingui', gatherLinguiInputs, 'pnpm', ['lingui:compile']);
|
||||
await cleanDist();
|
||||
|
||||
startCssTypeWatcher();
|
||||
|
||||
const rspackExitCode = await runRspack();
|
||||
|
||||
if (!shuttingDown && rspackExitCode !== 0) {
|
||||
process.exit(rspackExitCode);
|
||||
}
|
||||
} catch (error) {
|
||||
if (shuttingDown) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
+112
-91
@@ -1,26 +1,9 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '@app/components/uikit/TypingConstants';
|
||||
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO, TYPING_WIDTH_MULTIPLIER} from '../src/features/ui/constants/TypingConstants';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -33,6 +16,8 @@ interface StatusConfig {
|
||||
cutoutCenter: number;
|
||||
}
|
||||
|
||||
const LARGE_STATUS_CUTOUT_GUTTER_RATIO = 0.2;
|
||||
|
||||
const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
|
||||
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
|
||||
@@ -43,10 +28,9 @@ const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
44: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 38},
|
||||
48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42},
|
||||
56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49},
|
||||
80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68},
|
||||
120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100},
|
||||
80: {statusSize: 16, cutoutRadius: 16 / 2 + 16 * LARGE_STATUS_CUTOUT_GUTTER_RATIO, cutoutCenter: 68},
|
||||
120: {statusSize: 24, cutoutRadius: 24 / 2 + 24 * LARGE_STATUS_CUTOUT_GUTTER_RATIO, cutoutCenter: 100},
|
||||
};
|
||||
|
||||
const DESIGN_RULES = {
|
||||
mobileAspectRatio: 0.75,
|
||||
mobileCornerRadius: 0.12,
|
||||
@@ -55,11 +39,9 @@ const DESIGN_RULES = {
|
||||
mobileScreenY: 0.06,
|
||||
mobileWheelRadius: 0.13,
|
||||
mobileWheelY: 0.83,
|
||||
|
||||
mobilePhoneExtraHeight: 2,
|
||||
mobileDisplayExtraHeight: 2,
|
||||
mobileDisplayExtraWidthPerSide: 2,
|
||||
|
||||
idle: {
|
||||
cutoutRadiusRatio: 0.7,
|
||||
cutoutOffsetRatio: 0.35,
|
||||
@@ -73,7 +55,6 @@ const DESIGN_RULES = {
|
||||
innerRingRatio: 0.6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MOBILE_SCREEN_WIDTH_TRIM_PX = 4;
|
||||
const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2;
|
||||
const MOBILE_SCREEN_X_OFFSET_PX = 0;
|
||||
@@ -86,10 +67,44 @@ function getStatusConfig(avatarSize: number): StatusConfig {
|
||||
const sizes = Object.keys(STATUS_CONFIG)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const closest = sizes.reduce((prev, curr) =>
|
||||
Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev,
|
||||
);
|
||||
return STATUS_CONFIG[closest];
|
||||
const firstSize = sizes[0];
|
||||
const lastSize = sizes[sizes.length - 1];
|
||||
|
||||
if (avatarSize <= firstSize) {
|
||||
return scaleStatusConfig(STATUS_CONFIG[firstSize], avatarSize / firstSize);
|
||||
}
|
||||
if (avatarSize >= lastSize) {
|
||||
return scaleStatusConfig(STATUS_CONFIG[lastSize], avatarSize / lastSize);
|
||||
}
|
||||
|
||||
const upperSize = sizes.find((size) => size > avatarSize) ?? lastSize;
|
||||
const lowerSize = sizes[sizes.indexOf(upperSize) - 1] ?? firstSize;
|
||||
const progress = (avatarSize - lowerSize) / (upperSize - lowerSize);
|
||||
return interpolateStatusConfig(STATUS_CONFIG[lowerSize], STATUS_CONFIG[upperSize], progress);
|
||||
}
|
||||
|
||||
function interpolateStatusConfig(from: StatusConfig, to: StatusConfig, progress: number): StatusConfig {
|
||||
return {
|
||||
statusSize: interpolate(from.statusSize, to.statusSize, progress),
|
||||
cutoutRadius: interpolate(from.cutoutRadius, to.cutoutRadius, progress),
|
||||
cutoutCenter: interpolate(from.cutoutCenter, to.cutoutCenter, progress),
|
||||
};
|
||||
}
|
||||
|
||||
function scaleStatusConfig(config: StatusConfig, scale: number): StatusConfig {
|
||||
return {
|
||||
statusSize: config.statusSize * scale,
|
||||
cutoutRadius: config.cutoutRadius * scale,
|
||||
cutoutCenter: config.cutoutCenter * scale,
|
||||
};
|
||||
}
|
||||
|
||||
function interpolate(from: number, to: number, progress: number): number {
|
||||
return from + (to - from) * progress;
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return Number(value.toFixed(6)).toString();
|
||||
}
|
||||
|
||||
interface StatusGeometry {
|
||||
@@ -112,15 +127,12 @@ interface MobileStatusGeometry extends StatusGeometry {
|
||||
|
||||
function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry {
|
||||
const config = getStatusConfig(avatarSize);
|
||||
|
||||
const statusSize = config.statusSize;
|
||||
const cutoutCenter = config.cutoutCenter;
|
||||
const cutoutRadius = config.cutoutRadius;
|
||||
|
||||
const innerRadius = statusSize / 2;
|
||||
const outerRadius = cutoutRadius;
|
||||
const borderWidth = cutoutRadius - innerRadius;
|
||||
|
||||
const baseGeometry = {
|
||||
size: statusSize,
|
||||
cx: cutoutCenter,
|
||||
@@ -129,19 +141,15 @@ function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false):
|
||||
outerRadius,
|
||||
borderWidth,
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
return baseGeometry;
|
||||
}
|
||||
|
||||
const phoneWidth = statusSize;
|
||||
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
|
||||
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
|
||||
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
|
||||
|
||||
const phoneX = cutoutCenter - phoneWidth / 2;
|
||||
const phoneY = cutoutCenter - phoneHeight / 2;
|
||||
|
||||
return {
|
||||
...baseGeometry,
|
||||
phoneWidth,
|
||||
@@ -161,7 +169,6 @@ function generateAvatarMaskDefault(size: number): string {
|
||||
function generateAvatarMaskStatusRound(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
@@ -173,17 +180,13 @@ function generateAvatarMaskStatusRound(size: number): string {
|
||||
function generateAvatarMaskStatusTyping(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
const typingWidth = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingHeight = status.size;
|
||||
const typingRx = status.outerRadius;
|
||||
|
||||
const typingExtension = Math.max(0, typingWidth - status.size);
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
@@ -195,7 +198,6 @@ function generateAvatarMaskStatusTyping(size: number): string {
|
||||
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidth =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
@@ -209,11 +211,9 @@ function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
const screenRx = Math.min(screenWidth, screenHeight) * 0.1;
|
||||
|
||||
const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius;
|
||||
const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2;
|
||||
const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<rect fill="white" x="${mobileStatus.phoneX}" y="${mobileStatus.phoneY}" width="${mobileStatus.phoneWidth}" height="${mobileStatus.phoneHeight}" rx="${mobileStatus.phoneRx}" ry="${mobileStatus.phoneRx}" />
|
||||
@@ -225,23 +225,19 @@ function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
|
||||
|
||||
function generateStatusOnline(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
return `<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusIdle(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio);
|
||||
const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio);
|
||||
const cutoutCx = status.cx - cutoutOffsetDistance;
|
||||
const cutoutCy = status.cy - cutoutOffsetDistance;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
@@ -249,13 +245,11 @@ function generateStatusIdle(size: number, isMobile: boolean = false): string {
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusDnd(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio);
|
||||
const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio;
|
||||
@@ -263,7 +257,6 @@ function generateStatusDnd(size: number, isMobile: boolean = false): string {
|
||||
const barX = status.cx - barWidth / 2;
|
||||
const barY = status.cy - barHeight / 2;
|
||||
const barRx = barHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
@@ -271,14 +264,12 @@ function generateStatusDnd(size: number, isMobile: boolean = false): string {
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusOffline(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
@@ -296,16 +287,13 @@ function generateStatusTyping(size: number): string {
|
||||
const typingBridgeShift = typingExtension * TYPING_BRIDGE_RIGHT_SHIFT_RATIO;
|
||||
const x = status.cx - typingWidth / 2 + typingBridgeShift;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
|
||||
}
|
||||
|
||||
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 44, 48, 56, 80, 120];
|
||||
const GENERATED_HEADER = '// SPDX-License-Identifier: AGPL-3.0-or-later\n\n';
|
||||
|
||||
let output = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
type AvatarSize = ${SIZES.join(' | ')};
|
||||
let output = `${GENERATED_HEADER}type AvatarSize = ${SIZES.join(' | ')};
|
||||
|
||||
interface MaskDefinition {
|
||||
viewBox: string;
|
||||
@@ -400,25 +388,20 @@ export const SVGMasks = () => (
|
||||
for (const size of SIZES) {
|
||||
const status = calculateStatusGeometry(size, false);
|
||||
const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
|
||||
const cx = status.cx / size;
|
||||
const cy = status.cy / size;
|
||||
const r = status.outerRadius / size;
|
||||
|
||||
const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size;
|
||||
const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size;
|
||||
const idleCutoutCx = cx - idleCutoutOffset;
|
||||
const idleCutoutCy = cy - idleCutoutOffset;
|
||||
|
||||
const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size;
|
||||
const dndBarHeight =
|
||||
Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size;
|
||||
const dndBarX = cx - dndBarWidth / 2;
|
||||
const dndBarY = cy - dndBarHeight / 2;
|
||||
const dndBarRx = dndBarHeight / 2;
|
||||
|
||||
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
|
||||
|
||||
const typingWidthPx = Math.round(status.size * TYPING_WIDTH_MULTIPLIER);
|
||||
const typingExtensionPx = Math.max(0, typingWidthPx - status.size);
|
||||
const typingBridgeShift = (typingExtensionPx * TYPING_BRIDGE_RIGHT_SHIFT_RATIO) / size;
|
||||
@@ -427,16 +410,13 @@ for (const size of SIZES) {
|
||||
const typingX = cx - typingWidth / 2 + typingBridgeShift;
|
||||
const typingY = cy - typingHeight / 2;
|
||||
const typingRx = status.outerRadius / size;
|
||||
|
||||
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size;
|
||||
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidthPx =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
@@ -449,33 +429,30 @@ for (const size of SIZES) {
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
|
||||
const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1;
|
||||
|
||||
const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4);
|
||||
const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
|
||||
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${cutoutPhoneX}" y="${cutoutPhoneY}" width="${cutoutPhoneWidth}" height="${cutoutPhoneHeight}" rx="${cutoutPhoneRx}" ry="${cutoutPhoneRx}" />
|
||||
<rect fill="black" x="${formatNumber(cutoutPhoneX)}" y="${formatNumber(cutoutPhoneY)}" width="${formatNumber(cutoutPhoneWidth)}" height="${formatNumber(cutoutPhoneHeight)}" rx="${formatNumber(cutoutPhoneRx)}" ry="${formatNumber(cutoutPhoneRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
<rect fill="black" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
@@ -483,19 +460,19 @@ for (const size of SIZES) {
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${idleCutoutCx}" cy="${idleCutoutCy}" r="${idleCutoutR}" />
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
<circle fill="black" cx="${formatNumber(idleCutoutCx)}" cy="${formatNumber(idleCutoutCy)}" r="${formatNumber(idleCutoutR)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<rect fill="black" x="${dndBarX}" y="${dndBarY}" width="${dndBarWidth}" height="${dndBarHeight}" rx="${dndBarRx}" ry="${dndBarRx}" />
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
<rect fill="black" x="${formatNumber(dndBarX)}" y="${formatNumber(dndBarY)}" width="${formatNumber(dndBarWidth)}" height="${formatNumber(dndBarHeight)}" rx="${formatNumber(dndBarRx)}" ry="${formatNumber(dndBarRx)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${offlineInnerR}" />
|
||||
<circle fill="white" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(r)}" />
|
||||
<circle fill="black" cx="${formatNumber(cx)}" cy="${formatNumber(cy)}" r="${formatNumber(offlineInnerR)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
<rect fill="white" x="${formatNumber(typingX)}" y="${formatNumber(typingY)}" width="${formatNumber(typingWidth)}" height="${formatNumber(typingHeight)}" rx="${formatNumber(typingRx)}" ry="${formatNumber(typingRx)}" />
|
||||
</mask>
|
||||
|
||||
`;
|
||||
@@ -532,15 +509,13 @@ output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBounding
|
||||
);
|
||||
`;
|
||||
|
||||
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
|
||||
const outputPath = path.join(__dirname, '../src/features/ui/components/SVGMasks.tsx');
|
||||
|
||||
fs.writeFileSync(outputPath, output);
|
||||
|
||||
console.log(`Generated ${outputPath}`);
|
||||
|
||||
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
export interface StatusGeometry {
|
||||
const layoutOutput = `${GENERATED_HEADER}export interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
@@ -554,14 +529,14 @@ export interface StatusGeometry {
|
||||
const STATUS_GEOMETRY: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, false);
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`;
|
||||
return ` ${size}: {size: ${formatNumber(geom.size)}, cx: ${formatNumber(geom.cx)}, cy: ${formatNumber(geom.cy)}, radius: ${formatNumber(geom.outerRadius)}, borderWidth: ${formatNumber(geom.borderWidth)}, isMobile: false}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
const STATUS_GEOMETRY_MOBILE: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`;
|
||||
return ` ${size}: {size: ${formatNumber(geom.size)}, cx: ${formatNumber(geom.cx)}, cy: ${formatNumber(geom.cy)}, radius: ${formatNumber(geom.outerRadius)}, borderWidth: ${formatNumber(geom.borderWidth)}, isMobile: true, phoneWidth: ${formatNumber(geom.phoneWidth)}, phoneHeight: ${formatNumber(geom.phoneHeight)}}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
@@ -572,13 +547,59 @@ export function getStatusGeometry(avatarSize: number, isMobile: boolean = false)
|
||||
return map[avatarSize];
|
||||
}
|
||||
|
||||
const closestSize = Object.keys(map)
|
||||
const sizes = Object.keys(map)
|
||||
.map(Number)
|
||||
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
|
||||
.sort((a, b) => a - b);
|
||||
const firstSize = sizes[0];
|
||||
const lastSize = sizes[sizes.length - 1];
|
||||
|
||||
return map[closestSize];
|
||||
if (avatarSize <= firstSize) {
|
||||
return scaleGeometry(map[firstSize], avatarSize / firstSize, isMobile);
|
||||
}
|
||||
if (avatarSize >= lastSize) {
|
||||
return scaleGeometry(map[lastSize], avatarSize / lastSize, isMobile);
|
||||
}
|
||||
|
||||
const upperSize = sizes.find((size) => size > avatarSize) ?? lastSize;
|
||||
const lowerSize = sizes[sizes.indexOf(upperSize) - 1] ?? firstSize;
|
||||
const progress = (avatarSize - lowerSize) / (upperSize - lowerSize);
|
||||
return interpolateGeometry(map[lowerSize], map[upperSize], progress, isMobile);
|
||||
}
|
||||
|
||||
function interpolateGeometry(from: StatusGeometry, to: StatusGeometry, progress: number, isMobile: boolean): StatusGeometry {
|
||||
return {
|
||||
size: interpolate(from.size, to.size, progress),
|
||||
cx: interpolate(from.cx, to.cx, progress),
|
||||
cy: interpolate(from.cy, to.cy, progress),
|
||||
radius: interpolate(from.radius, to.radius, progress),
|
||||
borderWidth: interpolate(from.borderWidth, to.borderWidth, progress),
|
||||
isMobile,
|
||||
phoneWidth:
|
||||
from.phoneWidth != null && to.phoneWidth != null ? interpolate(from.phoneWidth, to.phoneWidth, progress) : undefined,
|
||||
phoneHeight:
|
||||
from.phoneHeight != null && to.phoneHeight != null
|
||||
? interpolate(from.phoneHeight, to.phoneHeight, progress)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function scaleGeometry(geometry: StatusGeometry, scale: number, isMobile: boolean): StatusGeometry {
|
||||
return {
|
||||
size: geometry.size * scale,
|
||||
cx: geometry.cx * scale,
|
||||
cy: geometry.cy * scale,
|
||||
radius: geometry.radius * scale,
|
||||
borderWidth: geometry.borderWidth * scale,
|
||||
isMobile,
|
||||
phoneWidth: geometry.phoneWidth == null ? undefined : geometry.phoneWidth * scale,
|
||||
phoneHeight: geometry.phoneHeight == null ? undefined : geometry.phoneHeight * scale,
|
||||
};
|
||||
}
|
||||
|
||||
function interpolate(from: number, to: number, progress: number): number {
|
||||
return from + (to - from) * progress;
|
||||
}
|
||||
`;
|
||||
const layoutPath = path.join(__dirname, '../src/features/ui/constants/AvatarStatusGeometry.ts');
|
||||
|
||||
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
|
||||
fs.writeFileSync(layoutPath, layoutOutput);
|
||||
+357
-71
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {mkdirSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative} from 'node:path';
|
||||
@@ -57,17 +40,271 @@ interface Config {
|
||||
root: Array<TokenDef>;
|
||||
light: Array<TokenDef>;
|
||||
coal: Array<TokenDef>;
|
||||
darkLegacy: Array<TokenDef>;
|
||||
};
|
||||
}
|
||||
|
||||
const DARK_CODE_TOKENS: Array<TokenDef> = [
|
||||
{
|
||||
name: '--code-text',
|
||||
value: 'color-mix(in srgb, var(--text-secondary) 82%, hsl(340, calc(50% * var(--saturation-factor)), 90%) 18%)',
|
||||
},
|
||||
{name: '--text-code', value: 'var(--code-text)'},
|
||||
{name: '--code-muted', value: 'color-mix(in srgb, var(--text-code) 42%, var(--text-tertiary) 58%)'},
|
||||
{
|
||||
name: '--code-inline-bg',
|
||||
value: 'color-mix(in srgb, var(--background-secondary-alt) 88%, var(--background-tertiary) 12%)',
|
||||
},
|
||||
{name: '--bg-code', value: 'var(--code-inline-bg)'},
|
||||
{
|
||||
name: '--code-block-bg',
|
||||
value: 'color-mix(in srgb, var(--background-secondary-alt) 92%, var(--background-primary) 8%)',
|
||||
},
|
||||
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
|
||||
{name: '--code-block-border', value: 'color-mix(in srgb, var(--border-color) 90%, var(--text-code) 10%)'},
|
||||
{name: '--code-block-highlight', value: 'color-mix(in srgb, var(--text-primary) 10%, transparent)'},
|
||||
{name: '--ansi-inverse-text', value: 'var(--code-block-bg)'},
|
||||
{name: '--ansi-inverse-bg', value: 'var(--text-code)'},
|
||||
{name: '--ansi-fg-black', value: 'color-mix(in srgb, hsl(0, 0%, 12%) 72%, var(--text-tertiary) 28%)'},
|
||||
{
|
||||
name: '--ansi-fg-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-green',
|
||||
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-yellow',
|
||||
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-blue',
|
||||
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-magenta',
|
||||
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-cyan',
|
||||
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{name: '--ansi-fg-white', value: 'color-mix(in srgb, hsl(0, 0%, 83%) 88%, var(--text-secondary) 12%)'},
|
||||
{name: '--ansi-fg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 50%) 86%, var(--text-tertiary) 14%)'},
|
||||
{
|
||||
name: '--ansi-fg-bright-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 94%, var(--text-primary) 6%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-green',
|
||||
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 88%, var(--text-primary) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-yellow',
|
||||
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 94%, var(--text-primary) 6%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-blue',
|
||||
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 94%, var(--text-primary) 6%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-magenta',
|
||||
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 94%, var(--text-primary) 6%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-cyan',
|
||||
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 94%, var(--text-primary) 6%)',
|
||||
},
|
||||
{name: '--ansi-fg-bright-white', value: 'var(--text-primary)'},
|
||||
{name: '--ansi-bg-black', value: 'color-mix(in srgb, hsl(0, 0%, 12%) 88%, var(--code-block-bg) 12%)'},
|
||||
{
|
||||
name: '--ansi-bg-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-green',
|
||||
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-yellow',
|
||||
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-blue',
|
||||
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-magenta',
|
||||
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-cyan',
|
||||
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{name: '--ansi-bg-white', value: 'color-mix(in srgb, hsl(0, 0%, 83%) 88%, var(--code-block-bg) 12%)'},
|
||||
{name: '--ansi-bg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 50%) 88%, var(--code-block-bg) 12%)'},
|
||||
{
|
||||
name: '--ansi-bg-bright-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(88% * var(--saturation-factor)), 62%) 92%, var(--code-block-bg) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-green',
|
||||
value: 'color-mix(in srgb, hsl(99, calc(29% * var(--saturation-factor)), 47%) 92%, var(--code-block-bg) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-yellow',
|
||||
value: 'color-mix(in srgb, hsl(41, calc(53% * var(--saturation-factor)), 67%) 92%, var(--code-block-bg) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-blue',
|
||||
value: 'color-mix(in srgb, hsl(207, calc(61% * var(--saturation-factor)), 59%) 92%, var(--code-block-bg) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-magenta',
|
||||
value: 'color-mix(in srgb, hsl(305, calc(35% * var(--saturation-factor)), 65%) 92%, var(--code-block-bg) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-cyan',
|
||||
value: 'color-mix(in srgb, hsl(168, calc(53% * var(--saturation-factor)), 55%) 92%, var(--code-block-bg) 8%)',
|
||||
},
|
||||
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 100%) 92%, var(--code-block-bg) 8%)'},
|
||||
];
|
||||
const LIGHT_CODE_TOKENS: Array<TokenDef> = [
|
||||
{
|
||||
name: '--code-text',
|
||||
value: 'color-mix(in srgb, var(--text-secondary) 72%, hsl(340, calc(50% * var(--saturation-factor)), 38%) 28%)',
|
||||
},
|
||||
{name: '--text-code', value: 'var(--code-text)'},
|
||||
{name: '--code-muted', value: 'color-mix(in srgb, var(--text-code) 34%, var(--text-tertiary) 66%)'},
|
||||
{name: '--code-inline-bg', value: 'color-mix(in srgb, var(--background-secondary-alt) 92%, var(--text-code) 8%)'},
|
||||
{name: '--bg-code', value: 'var(--code-inline-bg)'},
|
||||
{
|
||||
name: '--code-block-bg',
|
||||
value: 'color-mix(in srgb, var(--background-primary) 88%, var(--background-secondary) 12%)',
|
||||
},
|
||||
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
|
||||
{name: '--code-block-border', value: 'color-mix(in srgb, var(--border-color) 92%, var(--text-code) 8%)'},
|
||||
{name: '--code-block-highlight', value: 'color-mix(in srgb, hsl(0, 0%, 100%) 82%, transparent)'},
|
||||
{name: '--ansi-inverse-text', value: 'var(--code-block-bg)'},
|
||||
{name: '--ansi-inverse-bg', value: 'var(--text-code)'},
|
||||
{name: '--ansi-fg-black', value: 'var(--text-primary)'},
|
||||
{
|
||||
name: '--ansi-fg-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-green',
|
||||
value: 'color-mix(in srgb, hsl(120, calc(100% * var(--saturation-factor)), 25%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-yellow',
|
||||
value: 'color-mix(in srgb, hsl(37, calc(52% * var(--saturation-factor)), 31%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-blue',
|
||||
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-magenta',
|
||||
value: 'color-mix(in srgb, hsl(288, calc(100% * var(--saturation-factor)), 43%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-cyan',
|
||||
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 92%, var(--text-code) 8%)',
|
||||
},
|
||||
{name: '--ansi-fg-white', value: 'color-mix(in srgb, hsl(0, 0%, 33%) 88%, var(--text-secondary) 12%)'},
|
||||
{name: '--ansi-fg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 40%) 88%, var(--text-tertiary) 12%)'},
|
||||
{
|
||||
name: '--ansi-fg-bright-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 92%, var(--text-primary) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-green',
|
||||
value: 'color-mix(in srgb, hsl(120, calc(82% * var(--saturation-factor)), 44%) 90%, var(--text-primary) 10%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-yellow',
|
||||
value: 'color-mix(in srgb, hsl(62, calc(100% * var(--saturation-factor)), 36%) 90%, var(--text-primary) 10%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-blue',
|
||||
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 92%, var(--text-primary) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-magenta',
|
||||
value: 'color-mix(in srgb, hsl(300, calc(95% * var(--saturation-factor)), 38%) 92%, var(--text-primary) 8%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-fg-bright-cyan',
|
||||
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 92%, var(--text-primary) 8%)',
|
||||
},
|
||||
{name: '--ansi-fg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 65%) 86%, var(--text-primary) 14%)'},
|
||||
{name: '--ansi-bg-black', value: 'color-mix(in srgb, hsl(0, 0%, 0%) 86%, var(--code-block-bg) 14%)'},
|
||||
{
|
||||
name: '--ansi-bg-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 86%, var(--code-block-bg) 14%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-green',
|
||||
value: 'color-mix(in srgb, hsl(120, calc(100% * var(--saturation-factor)), 25%) 86%, var(--code-block-bg) 14%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-yellow',
|
||||
value: 'color-mix(in srgb, hsl(37, calc(52% * var(--saturation-factor)), 31%) 86%, var(--code-block-bg) 14%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-blue',
|
||||
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 86%, var(--code-block-bg) 14%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-magenta',
|
||||
value: 'color-mix(in srgb, hsl(288, calc(100% * var(--saturation-factor)), 43%) 86%, var(--code-block-bg) 14%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-cyan',
|
||||
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 86%, var(--code-block-bg) 14%)',
|
||||
},
|
||||
{name: '--ansi-bg-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 86%, var(--code-block-bg) 14%)'},
|
||||
{name: '--ansi-bg-bright-black', value: 'color-mix(in srgb, hsl(0, 0%, 40%) 88%, var(--code-block-bg) 12%)'},
|
||||
{
|
||||
name: '--ansi-bg-bright-red',
|
||||
value: 'color-mix(in srgb, hsl(0, calc(62% * var(--saturation-factor)), 50%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-green',
|
||||
value: 'color-mix(in srgb, hsl(120, calc(82% * var(--saturation-factor)), 44%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-yellow',
|
||||
value: 'color-mix(in srgb, hsl(62, calc(100% * var(--saturation-factor)), 36%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-blue',
|
||||
value: 'color-mix(in srgb, hsl(211, calc(95% * var(--saturation-factor)), 33%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-magenta',
|
||||
value: 'color-mix(in srgb, hsl(300, calc(95% * var(--saturation-factor)), 38%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{
|
||||
name: '--ansi-bg-bright-cyan',
|
||||
value: 'color-mix(in srgb, hsl(192, calc(95% * var(--saturation-factor)), 38%) 88%, var(--code-block-bg) 12%)',
|
||||
},
|
||||
{name: '--ansi-bg-bright-white', value: 'color-mix(in srgb, hsl(0, 0%, 90%) 90%, var(--code-block-bg) 10%)'},
|
||||
];
|
||||
const CONFIG: Config = {
|
||||
families: {
|
||||
neutralDark: {hue: 220, saturation: 13, useSaturationFactor: true},
|
||||
neutralDark: {hue: 258, saturation: 10, useSaturationFactor: true},
|
||||
neutralLight: {hue: 220, saturation: 10, useSaturationFactor: true},
|
||||
brand: {hue: 242, saturation: 70, useSaturationFactor: true},
|
||||
link: {hue: 210, saturation: 100, useSaturationFactor: true},
|
||||
accentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
|
||||
statusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
|
||||
link: {hue: 198, saturation: 92, useSaturationFactor: true},
|
||||
accentPurple: {hue: 278, saturation: 85, useSaturationFactor: true},
|
||||
statusOnline: {hue: 152, saturation: 72, useSaturationFactor: true},
|
||||
legacyDark: {hue: 220, saturation: 13, useSaturationFactor: true},
|
||||
legacyLink: {hue: 210, saturation: 100, useSaturationFactor: true},
|
||||
legacyAccentPurple: {hue: 270, saturation: 80, useSaturationFactor: true},
|
||||
legacyStatusOnline: {hue: 142, saturation: 76, useSaturationFactor: true},
|
||||
statusIdle: {hue: 45, saturation: 93, useSaturationFactor: true},
|
||||
statusDnd: {hue: 0, saturation: 84, useSaturationFactor: true},
|
||||
statusOffline: {hue: 218, saturation: 11, useSaturationFactor: true},
|
||||
@@ -75,11 +312,10 @@ const CONFIG: Config = {
|
||||
textCode: {hue: 340, saturation: 50, useSaturationFactor: true},
|
||||
brandIcon: {hue: 38, saturation: 92, useSaturationFactor: true},
|
||||
},
|
||||
|
||||
scales: {
|
||||
darkSurface: {
|
||||
family: 'neutralDark',
|
||||
range: [5, 26],
|
||||
range: [5, 24],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
@@ -114,7 +350,7 @@ const CONFIG: Config = {
|
||||
},
|
||||
darkText: {
|
||||
family: 'neutralDark',
|
||||
range: [52, 96],
|
||||
range: [60, 96],
|
||||
curve: 'easeInOut',
|
||||
stops: [
|
||||
{name: '--text-tertiary-secondary', position: 0},
|
||||
@@ -147,7 +383,7 @@ const CONFIG: Config = {
|
||||
},
|
||||
lightText: {
|
||||
family: 'neutralLight',
|
||||
range: [15, 60],
|
||||
range: [15, 54],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--text-primary', position: 0},
|
||||
@@ -160,8 +396,40 @@ const CONFIG: Config = {
|
||||
{name: '--text-tertiary-muted', position: 0.85},
|
||||
],
|
||||
},
|
||||
legacyDarkSurface: {
|
||||
family: 'legacyDark',
|
||||
range: [5, 26],
|
||||
curve: 'easeOut',
|
||||
stops: [
|
||||
{name: '--background-primary', position: 0},
|
||||
{name: '--background-secondary', position: 0.16},
|
||||
{name: '--background-secondary-lighter', position: 0.22},
|
||||
{name: '--background-secondary-alt', position: 0.28},
|
||||
{name: '--background-tertiary', position: 0.4},
|
||||
{name: '--background-channel-header', position: 0.34},
|
||||
{name: '--guild-list-foreground', position: 0.38},
|
||||
{name: '--background-header-secondary', position: 0.5},
|
||||
{name: '--background-header-primary', position: 0.5},
|
||||
{name: '--background-textarea', position: 0.68},
|
||||
{name: '--background-header-primary-hover', position: 0.85},
|
||||
],
|
||||
},
|
||||
legacyDarkText: {
|
||||
family: 'legacyDark',
|
||||
range: [52, 96],
|
||||
curve: 'easeInOut',
|
||||
stops: [
|
||||
{name: '--text-tertiary-secondary', position: 0},
|
||||
{name: '--text-tertiary-muted', position: 0.2},
|
||||
{name: '--text-tertiary', position: 0.38},
|
||||
{name: '--text-primary-muted', position: 0.55},
|
||||
{name: '--text-chat-muted', position: 0.55},
|
||||
{name: '--text-secondary', position: 0.72},
|
||||
{name: '--text-chat', position: 0.82},
|
||||
{name: '--text-primary', position: 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
tokens: {
|
||||
root: [
|
||||
{scale: 'darkSurface'},
|
||||
@@ -171,7 +439,7 @@ const CONFIG: Config = {
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
hsl(258, calc(10% * var(--saturation-factor)), 2%) 20%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
@@ -207,8 +475,8 @@ hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
{name: '--invite-verified-icon-color', value: 'var(--text-on-brand-primary)'},
|
||||
{name: '--text-link', family: 'link', lightness: 70},
|
||||
{name: '--text-on-brand-primary', hue: 0, saturation: 0, lightness: 98},
|
||||
{name: '--text-code', family: 'textCode', lightness: 90},
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
|
||||
...DARK_CODE_TOKENS,
|
||||
{name: '--text-selection', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.35},
|
||||
{name: '--markup-mention-text', value: 'var(--text-link)'},
|
||||
{name: '--markup-mention-fill', value: 'color-mix(in srgb, var(--text-link) 20%, transparent)'},
|
||||
{name: '--markup-mention-border', family: 'link', lightness: 70, alpha: 0.3},
|
||||
@@ -287,15 +555,13 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', family: 'neutralDark', lightness: 15, alpha: 0.8},
|
||||
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
{name: '--bg-table-row-even', value: 'var(--background-secondary)'},
|
||||
{name: '--border-color', family: 'neutralDark', lightness: 50, alpha: 0.2},
|
||||
{name: '--border-color-hover', family: 'neutralDark', lightness: 50, alpha: 0.3},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
|
||||
{name: '--border-color-focus', hue: 198, saturation: 92, useSaturationFactor: true, lightness: 70, alpha: 0.45},
|
||||
{name: '--accent-primary', value: 'var(--brand-primary)'},
|
||||
{name: '--accent-success', value: 'var(--status-online)'},
|
||||
{name: '--accent-warning', value: 'var(--status-idle)'},
|
||||
@@ -324,7 +590,6 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
value: 'color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)',
|
||||
},
|
||||
],
|
||||
|
||||
light: [
|
||||
{scale: 'lightSurface'},
|
||||
{scale: 'lightText'},
|
||||
@@ -345,7 +610,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
{name: '--control-button-danger-text', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--control-button-danger-hover-bg', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95},
|
||||
{name: '--text-link', family: 'link', lightness: 45},
|
||||
{name: '--text-code', family: 'textCode', lightness: 45},
|
||||
...LIGHT_CODE_TOKENS,
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2},
|
||||
{name: '--markup-mention-border', family: 'link', lightness: 45, alpha: 0.4},
|
||||
{name: '--markup-jump-link-fill', value: 'color-mix(in srgb, var(--text-link) 8%, transparent)'},
|
||||
@@ -385,8 +650,6 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', family: 'neutralLight', saturation: 22, lightness: 90, alpha: 0.9},
|
||||
{name: '--bg-code-block', value: 'var(--background-primary)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary-alt)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
@@ -417,7 +680,6 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
{name: '--button-danger-outline-active-fill', hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50},
|
||||
{name: '--user-area-divider-color', family: 'neutralLight', lightness: 40, alpha: 0.2},
|
||||
],
|
||||
|
||||
coal: [
|
||||
{scale: 'coalSurface'},
|
||||
{name: '--background-secondary', value: 'var(--background-primary)'},
|
||||
@@ -427,7 +689,7 @@ hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-primary) 90%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
|
||||
hsl(258, calc(10% * var(--saturation-factor)), 0%) 10%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'neutralDark', saturation: 20, lightness: 30, alpha: 0.35},
|
||||
@@ -446,13 +708,19 @@ hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
|
||||
{name: '--scrollbar-thumb-bg', value: 'rgba(160, 160, 160, 0.35)'},
|
||||
{name: '--scrollbar-thumb-bg-hover', value: 'rgba(200, 200, 200, 0.55)'},
|
||||
{name: '--scrollbar-track-bg', value: 'rgba(0, 0, 0, 0.45)'},
|
||||
{name: '--spoiler-overlay-color', value: 'hsla(0, 0%, 100%, 0.08)'},
|
||||
{name: '--spoiler-overlay-hover-color', value: 'hsla(0, 0%, 100%, 0.14)'},
|
||||
{name: '--bg-primary', value: 'var(--background-primary)'},
|
||||
{name: '--bg-secondary', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-tertiary', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-hover', value: 'var(--background-modifier-hover)'},
|
||||
{name: '--bg-active', value: 'var(--background-modifier-selected)'},
|
||||
{name: '--bg-code', value: 'hsl(220, calc(13% * var(--saturation-factor)), 8%)'},
|
||||
{name: '--bg-code-block', value: 'var(--background-secondary-alt)'},
|
||||
...DARK_CODE_TOKENS,
|
||||
{name: '--code-inline-bg', value: 'color-mix(in srgb, var(--background-tertiary) 86%, var(--text-code) 14%)'},
|
||||
{name: '--bg-code', value: 'var(--code-inline-bg)'},
|
||||
{name: '--code-block-bg', value: 'color-mix(in srgb, var(--background-secondary-alt) 84%, hsl(0, 0%, 0%) 16%)'},
|
||||
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
|
||||
{name: '--code-block-border', value: 'color-mix(in srgb, var(--border-color) 84%, var(--text-code) 16%)'},
|
||||
{name: '--bg-blockquote', value: 'var(--background-secondary)'},
|
||||
{name: '--bg-table-header', value: 'var(--background-tertiary)'},
|
||||
{name: '--bg-table-row-odd', value: 'var(--background-primary)'},
|
||||
@@ -469,6 +737,48 @@ hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
|
||||
value: 'color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)',
|
||||
},
|
||||
],
|
||||
darkLegacy: [
|
||||
{scale: 'legacyDarkSurface'},
|
||||
{scale: 'legacyDarkText'},
|
||||
{
|
||||
name: '--panel-control-bg',
|
||||
value: `color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
)`,
|
||||
},
|
||||
{name: '--panel-control-border', family: 'legacyDark', saturation: 30, lightness: 65, alpha: 0.45},
|
||||
{name: '--panel-control-divider', family: 'legacyDark', saturation: 30, lightness: 55, alpha: 0.35},
|
||||
{name: '--background-modifier-hover', family: 'legacyDark', lightness: 100, alpha: 0.05},
|
||||
{name: '--background-modifier-selected', family: 'legacyDark', lightness: 100, alpha: 0.1},
|
||||
{name: '--background-modifier-accent', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.15},
|
||||
{name: '--background-modifier-accent-focus', family: 'legacyDark', saturation: 13, lightness: 80, alpha: 0.22},
|
||||
{name: '--control-button-hover-bg', family: 'legacyDark', lightness: 22},
|
||||
{name: '--control-button-active-bg', family: 'legacyDark', lightness: 24},
|
||||
{name: '--status-online', family: 'legacyStatusOnline', lightness: 40},
|
||||
{name: '--text-link', family: 'legacyLink', lightness: 70},
|
||||
{name: '--text-selection', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35},
|
||||
{name: '--markup-mention-border', family: 'legacyLink', lightness: 70, alpha: 0.3},
|
||||
...DARK_CODE_TOKENS,
|
||||
{
|
||||
name: '--code-inline-bg',
|
||||
value: 'color-mix(in srgb, var(--background-secondary-alt) 82%, var(--text-code) 18%)',
|
||||
},
|
||||
{name: '--bg-code', value: 'var(--code-inline-bg)'},
|
||||
{
|
||||
name: '--code-block-bg',
|
||||
value: 'color-mix(in srgb, var(--background-secondary-alt) 88%, var(--background-primary) 12%)',
|
||||
},
|
||||
{name: '--bg-code-block', value: 'var(--code-block-bg)'},
|
||||
{name: '--border-color', family: 'legacyDark', lightness: 50, alpha: 0.2},
|
||||
{name: '--border-color-hover', family: 'legacyDark', lightness: 50, alpha: 0.3},
|
||||
{name: '--border-color-focus', hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45},
|
||||
{name: '--accent-purple', family: 'legacyAccentPurple', lightness: 65},
|
||||
{name: '--alert-note-color', family: 'legacyLink', lightness: 70},
|
||||
{name: '--alert-tip-color', family: 'legacyStatusOnline', lightness: 45},
|
||||
{name: '--alert-important-color', family: 'legacyAccentPurple', lightness: 65},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -507,21 +817,17 @@ function applyCurve(curve: Scale['curve'], t: number): number {
|
||||
function buildScaleTokens(scale: Scale): Array<OutputToken> {
|
||||
const lastIndex = Math.max(scale.stops.length - 1, 1);
|
||||
const tokens: Array<OutputToken> = [];
|
||||
|
||||
for (let i = 0; i < scale.stops.length; i++) {
|
||||
const stop = scale.stops[i];
|
||||
let pos: number;
|
||||
|
||||
if (stop.position !== undefined) {
|
||||
pos = clamp01(stop.position);
|
||||
} else {
|
||||
pos = i / lastIndex;
|
||||
}
|
||||
|
||||
const eased = applyCurve(scale.curve, pos);
|
||||
let lightness = scale.range[0] + (scale.range[1] - scale.range[0]) * eased;
|
||||
lightness = Math.round(lightness * 1000) / 1000;
|
||||
|
||||
tokens.push({
|
||||
type: 'tone',
|
||||
name: stop.name,
|
||||
@@ -529,13 +835,11 @@ function buildScaleTokens(scale: Scale): Array<OutputToken> {
|
||||
lightness,
|
||||
});
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Array<OutputToken> {
|
||||
const tokens: Array<OutputToken> = [];
|
||||
|
||||
for (const def of defs) {
|
||||
if (def.scale) {
|
||||
const scale = scales[def.scale];
|
||||
@@ -546,7 +850,6 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
|
||||
tokens.push(...buildScaleTokens(scale));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (def.value !== undefined) {
|
||||
tokens.push({
|
||||
type: 'literal',
|
||||
@@ -566,7 +869,6 @@ function expandTokens(defs: Array<TokenDef>, scales: Record<string, Scale>): Arr
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
@@ -581,45 +883,37 @@ function formatNumber(value: number): string {
|
||||
|
||||
function formatTone(token: OutputToken, families: Record<string, ColorFamily>): string {
|
||||
const family = token.family ? families[token.family] : undefined;
|
||||
|
||||
let hue = 0;
|
||||
let saturation = 0;
|
||||
let lightness = 0;
|
||||
let useFactor = false;
|
||||
|
||||
if (token.hue !== undefined) {
|
||||
hue = token.hue;
|
||||
} else if (family) {
|
||||
hue = family.hue;
|
||||
}
|
||||
|
||||
if (token.saturation !== undefined) {
|
||||
saturation = token.saturation;
|
||||
} else if (family) {
|
||||
saturation = family.saturation;
|
||||
}
|
||||
|
||||
if (token.lightness !== undefined) {
|
||||
lightness = token.lightness;
|
||||
}
|
||||
|
||||
if (token.useSaturationFactor !== undefined) {
|
||||
useFactor = token.useSaturationFactor;
|
||||
} else if (family) {
|
||||
useFactor = family.useSaturationFactor;
|
||||
}
|
||||
|
||||
let satStr: string;
|
||||
if (useFactor) {
|
||||
satStr = `calc(${formatNumber(saturation)}% * var(--saturation-factor))`;
|
||||
} else {
|
||||
satStr = `${formatNumber(saturation)}%`;
|
||||
}
|
||||
|
||||
if (token.alpha === undefined) {
|
||||
return `hsl(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%)`;
|
||||
}
|
||||
|
||||
return `hsla(${formatNumber(hue)}, ${satStr}, ${formatNumber(lightness)}%, ${formatNumber(token.alpha)})`;
|
||||
}
|
||||
|
||||
@@ -643,36 +937,28 @@ function generateCSS(
|
||||
rootTokens: Array<OutputToken>,
|
||||
lightTokens: Array<OutputToken>,
|
||||
coalTokens: Array<OutputToken>,
|
||||
darkLegacyTokens: Array<OutputToken>,
|
||||
): string {
|
||||
const header = `/*
|
||||
* This file is auto-generated by scripts/GenerateColorSystem.ts.
|
||||
* Do not edit directly — update the config in generate-color-system.ts instead.
|
||||
*/`;
|
||||
|
||||
const blocks = [
|
||||
renderBlock(':root', rootTokens, cfg.families),
|
||||
renderBlock('.theme-light', lightTokens, cfg.families),
|
||||
renderBlock('.theme-coal', coalTokens, cfg.families),
|
||||
renderBlock('.theme-dark_legacy', darkLegacyTokens, cfg.families),
|
||||
];
|
||||
|
||||
return `${header}\n\n${blocks.join('\n\n')}\n`;
|
||||
return `/* SPDX-License-Identifier: AGPL-3.0-or-later */\n\n${blocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
|
||||
const rootTokens = expandTokens(CONFIG.tokens.root, CONFIG.scales);
|
||||
const lightTokens = expandTokens(CONFIG.tokens.light, CONFIG.scales);
|
||||
const coalTokens = expandTokens(CONFIG.tokens.coal, CONFIG.scales);
|
||||
|
||||
const cssPath = join(appDir, 'src', 'styles', 'generated', 'color-system.css');
|
||||
|
||||
const darkLegacyTokens = expandTokens(CONFIG.tokens.darkLegacy, CONFIG.scales);
|
||||
const cssPath = join(appDir, 'src', 'features', 'theme', 'styles', 'generated', 'color-system.css');
|
||||
mkdirSync(dirname(cssPath), {recursive: true});
|
||||
|
||||
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens);
|
||||
const css = generateCSS(CONFIG, rootTokens, lightTokens, coalTokens, darkLegacyTokens);
|
||||
writeFileSync(cssPath, css);
|
||||
|
||||
const relCSS = relative(appDir, cssPath);
|
||||
console.log(`Wrote ${relCSS}`);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {run} from 'typed-css-modules';
|
||||
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
run('src', {pattern: '**/*.module.css', watch}).catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
+14
-71
@@ -1,26 +1,9 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {convertToCodePoints} from '@app/utils/EmojiCodepointUtils';
|
||||
import sharp from 'sharp';
|
||||
import {convertToCodePoints} from '../src/features/expressions/utils/EmojiCodepointUtils';
|
||||
|
||||
const EMOJI_SPRITES = {
|
||||
nonDiversityPerRow: 42,
|
||||
@@ -28,14 +11,15 @@ const EMOJI_SPRITES = {
|
||||
pickerPerRow: 11,
|
||||
pickerCount: 50,
|
||||
} as const;
|
||||
|
||||
const EMOJI_SIZE = 32;
|
||||
const TWEMOJI_CDN = 'https://fluxerstatic.com/emoji';
|
||||
const SPRITE_SCALES = [1, 2] as const;
|
||||
const TWEMOJI_LOCAL_DIR = join(import.meta.dirname, '..', '..', 'fluxer_static', 'emoji');
|
||||
|
||||
interface EmojiObject {
|
||||
surrogates: string;
|
||||
skins?: Array<{surrogates: string}>;
|
||||
skins?: Array<{
|
||||
surrogates: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface EmojiEntry {
|
||||
@@ -44,27 +28,16 @@ interface EmojiEntry {
|
||||
|
||||
const svgCache = new Map<string, string | null>();
|
||||
|
||||
async function fetchTwemojiSVG(codepoint: string): Promise<string | null> {
|
||||
function loadLocalTwemojiSVG(codepoint: string): string | null {
|
||||
if (svgCache.has(codepoint)) {
|
||||
return svgCache.get(codepoint) ?? null;
|
||||
}
|
||||
|
||||
const url = `${TWEMOJI_CDN}/${codepoint}.svg`;
|
||||
|
||||
const path = join(TWEMOJI_LOCAL_DIR, `${codepoint}.svg`);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Twemoji ${codepoint} returned ${response.status}`);
|
||||
svgCache.set(codepoint, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.text();
|
||||
const body = readFileSync(path, 'utf-8');
|
||||
svgCache.set(codepoint, body);
|
||||
return body;
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch Twemoji ${codepoint}:`, err);
|
||||
} catch {
|
||||
svgCache.set(codepoint, null);
|
||||
return null;
|
||||
}
|
||||
@@ -82,15 +55,12 @@ async function renderSVGToBuffer(svgContent: string, size: number): Promise<Buff
|
||||
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
h /= 360;
|
||||
|
||||
let r: number, g: number, b: number;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
const hueToRgb = (p: number, q: number, t: number): number => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
@@ -99,12 +69,10 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
r = hueToRgb(p, q, h + 1 / 3);
|
||||
g = hueToRgb(p, q, h);
|
||||
b = hueToRgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return [
|
||||
Math.round(Math.min(1, Math.max(0, r)) * 255),
|
||||
Math.round(Math.min(1, Math.max(0, g)) * 255),
|
||||
@@ -115,22 +83,18 @@ function hslToRgb(h: number, s: number, l: number): [number, number, number] {
|
||||
async function createPlaceholder(size: number): Promise<Buffer> {
|
||||
const h = Math.random() * 360;
|
||||
const [r, g, b] = hslToRgb(h, 0.7, 0.6);
|
||||
|
||||
const radius = Math.floor(size * 0.4);
|
||||
const cx = Math.floor(size / 2);
|
||||
const cy = Math.floor(size / 2);
|
||||
|
||||
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgb(${r},${g},${b})"/>
|
||||
</svg>`;
|
||||
|
||||
return sharp(Buffer.from(svg)).png().toBuffer();
|
||||
}
|
||||
|
||||
async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer> {
|
||||
const codepoint = convertToCodePoints(surrogate);
|
||||
|
||||
const svg = await fetchTwemojiSVG(codepoint);
|
||||
const svg = loadLocalTwemojiSVG(codepoint);
|
||||
if (svg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(svg, size);
|
||||
@@ -138,10 +102,9 @@ async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer>
|
||||
console.error(`Failed to render SVG for ${codepoint}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (codepoint.includes('-200d-')) {
|
||||
const basePart = codepoint.split('-200d-')[0];
|
||||
const baseSvg = await fetchTwemojiSVG(basePart);
|
||||
const baseSvg = loadLocalTwemojiSVG(basePart);
|
||||
if (baseSvg) {
|
||||
try {
|
||||
return await renderSVGToBuffer(baseSvg, size);
|
||||
@@ -150,7 +113,6 @@ async function loadEmojiImage(surrogate: string, size: number): Promise<Buffer>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`Missing SVG for ${codepoint} (${surrogate}), using placeholder`);
|
||||
return createPlaceholder(size);
|
||||
}
|
||||
@@ -164,32 +126,25 @@ async function renderSpriteSheet(
|
||||
if (perRow <= 0) {
|
||||
throw new Error('perRow must be > 0');
|
||||
}
|
||||
|
||||
const rows = Math.ceil(emojiEntries.length / perRow);
|
||||
|
||||
for (const scale of SPRITE_SCALES) {
|
||||
const size = EMOJI_SIZE * scale;
|
||||
const dstW = perRow * size;
|
||||
const dstH = rows * size;
|
||||
|
||||
const compositeOps: Array<sharp.OverlayOptions> = [];
|
||||
|
||||
for (let i = 0; i < emojiEntries.length; i++) {
|
||||
const item = emojiEntries[i];
|
||||
const emojiBuffer = await loadEmojiImage(item.surrogates, size);
|
||||
|
||||
const row = Math.floor(i / perRow);
|
||||
const col = i % perRow;
|
||||
const x = col * size;
|
||||
const y = row * size;
|
||||
|
||||
compositeOps.push({
|
||||
input: emojiBuffer,
|
||||
left: x,
|
||||
top: y,
|
||||
});
|
||||
}
|
||||
|
||||
const sheet = await sharp({
|
||||
create: {
|
||||
width: dstW,
|
||||
@@ -201,7 +156,6 @@ async function renderSpriteSheet(
|
||||
.composite(compositeOps)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const suffix = scale !== 1 ? `@${scale}x` : '';
|
||||
const outPath = join(outputDir, `${fileNameBase}${suffix}.png`);
|
||||
writeFileSync(outPath, sheet);
|
||||
@@ -227,11 +181,9 @@ async function generateDiversitySpriteSheets(
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
const skinTones = ['\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
for (let skinIndex = 0; skinIndex < skinTones.length; skinIndex++) {
|
||||
const skinTone = skinTones[skinIndex];
|
||||
const skinCodepoint = convertToCodePoints(skinTone);
|
||||
|
||||
const skinEntries: Array<EmojiEntry> = [];
|
||||
for (const objs of Object.values(emojiData)) {
|
||||
for (const obj of objs) {
|
||||
@@ -240,11 +192,9 @@ async function generateDiversitySpriteSheets(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skinEntries.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await renderSpriteSheet(skinEntries, EMOJI_SPRITES.diversityPerRow, `spritesheet-${skinCodepoint}`, outputDir);
|
||||
}
|
||||
}
|
||||
@@ -282,7 +232,6 @@ async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
|
||||
'\u{1F973}',
|
||||
'\u{1F60F}',
|
||||
];
|
||||
|
||||
const entries: Array<EmojiEntry> = basicEmojis.map((e) => ({surrogates: e}));
|
||||
await renderSpriteSheet(entries, EMOJI_SPRITES.pickerPerRow, 'spritesheet-picker', outputDir);
|
||||
}
|
||||
@@ -290,22 +239,16 @@ async function generatePickerSpriteSheet(outputDir: string): Promise<void> {
|
||||
async function main(): Promise<void> {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
|
||||
const outputDir = join(appDir, 'src', 'assets', 'emoji-sprites');
|
||||
const outputDir = join(appDir, 'src', 'media', 'images', 'emoji-sprites');
|
||||
mkdirSync(outputDir, {recursive: true});
|
||||
|
||||
const emojiDataPath = join(appDir, 'src', 'data', 'emojis.json');
|
||||
const emojiDataPath = join(appDir, 'src', 'media', 'data', 'emojis.json');
|
||||
const emojiData: Record<string, Array<EmojiObject>> = JSON.parse(readFileSync(emojiDataPath, 'utf-8'));
|
||||
|
||||
console.log('Generating main sprite sheet...');
|
||||
await generateMainSpriteSheet(emojiData, outputDir);
|
||||
|
||||
console.log('Generating diversity sprite sheets...');
|
||||
await generateDiversitySpriteSheets(emojiData, outputDir);
|
||||
|
||||
console.log('Generating picker sprite sheet...');
|
||||
await generatePickerSpriteSheet(outputDir);
|
||||
|
||||
console.log('Emoji sprites generated successfully.');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative} from 'node:path';
|
||||
import {renderMessageLayoutCss} from '../src/features/theme/layout/MessageLayoutCss';
|
||||
|
||||
function main(): void {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = join(scriptDir, '..');
|
||||
const cssPath = join(appDir, 'src', 'features', 'theme', 'styles', 'generated', 'message-layout.css');
|
||||
const css = renderMessageLayoutCss();
|
||||
if (process.argv.includes('--check')) {
|
||||
if (!existsSync(cssPath) || readFileSync(cssPath, 'utf8') !== css) {
|
||||
throw new Error(`${relative(appDir, cssPath)} is stale. Run pnpm generate:message-layout.`);
|
||||
}
|
||||
console.log(`Checked ${relative(appDir, cssPath)}`);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(cssPath), {recursive: true});
|
||||
writeFileSync(cssPath, css);
|
||||
console.log(`Wrote ${relative(appDir, cssPath)}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,395 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {dirname, join, relative, resolve} from 'node:path';
|
||||
|
||||
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
|
||||
|
||||
interface CssSource {
|
||||
file: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface VariableDefinition {
|
||||
name: string;
|
||||
kind: ThemeVariableKind;
|
||||
groupId: string;
|
||||
groupLabel: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
const PRIORITY_CSS_SOURCES: ReadonlyArray<CssSource> = [
|
||||
{file: 'src/app/globals.css', label: 'globals'},
|
||||
{file: 'src/features/theme/styles/generated/color-system.css', label: 'color-system'},
|
||||
{file: 'src/features/theme/styles/generated/message-layout.css', label: 'message-layout'},
|
||||
];
|
||||
const PRIORITY_SOURCE_INDEX = new Map(PRIORITY_CSS_SOURCES.map((source, index) => [source.file, index]));
|
||||
const IGNORED_SOURCE_PREFIXES = ['src/features/theme_studio/', 'src/theme/'];
|
||||
|
||||
const EXTRA_GLOBAL_DEFAULTS: ReadonlyArray<{name: string; value: string; source: string}> = [
|
||||
{
|
||||
name: '--font-sans',
|
||||
value: "'Fluxer Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
source: 'runtime-fonts',
|
||||
},
|
||||
{
|
||||
name: '--font-mono',
|
||||
value: "'Fluxer Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
|
||||
source: 'runtime-fonts',
|
||||
},
|
||||
{name: '--font-size', value: '1rem', source: 'runtime-accessibility'},
|
||||
{name: '--chat-horizontal-padding', value: '1rem', source: 'runtime-accessibility'},
|
||||
{name: '--message-group-spacing', value: '1rem', source: 'runtime-accessibility'},
|
||||
{name: '--link-decoration', value: 'none', source: 'runtime-accessibility'},
|
||||
{name: '--markup-strikethrough-color', value: 'currentColor', source: 'runtime-accessibility'},
|
||||
];
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
typography: 'Typography',
|
||||
surfaces: 'Surfaces',
|
||||
headers: 'Headers',
|
||||
text: 'Text',
|
||||
brand: 'Brand & accents',
|
||||
status: 'Status indicators',
|
||||
borders: 'Borders & focus',
|
||||
alerts: 'Alerts & callouts',
|
||||
markup: 'Markup & mentions',
|
||||
buttons: 'Buttons',
|
||||
code: 'Code & terminal',
|
||||
tables: 'Tables',
|
||||
scrolling: 'Scrolling',
|
||||
layout: 'Layout',
|
||||
messages: 'Messages',
|
||||
emoji: 'Emoji',
|
||||
motion: 'Motion',
|
||||
layering: 'Layering',
|
||||
media: 'Media',
|
||||
forms: 'Forms',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
function stripAtRuleBlocks(css: string): string {
|
||||
let output = '';
|
||||
let index = 0;
|
||||
while (index < css.length) {
|
||||
if (css[index] !== '@') {
|
||||
output += css[index];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const nextSemicolon = css.indexOf(';', index);
|
||||
const nextBrace = css.indexOf('{', index);
|
||||
if (nextBrace === -1 || (nextSemicolon !== -1 && nextSemicolon < nextBrace)) {
|
||||
index = nextSemicolon === -1 ? css.length : nextSemicolon + 1;
|
||||
continue;
|
||||
}
|
||||
let depth = 0;
|
||||
let cursor = nextBrace;
|
||||
for (; cursor < css.length; cursor += 1) {
|
||||
if (css[cursor] === '{') depth += 1;
|
||||
if (css[cursor] === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
index = cursor;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function toPosixPath(path: string): string {
|
||||
return path.replaceAll('\\', '/');
|
||||
}
|
||||
|
||||
function discoverCssSources(appDir: string): ReadonlyArray<CssSource> {
|
||||
const srcDir = join(appDir, 'src');
|
||||
const files: Array<string> = [];
|
||||
const visit = (directory: string) => {
|
||||
for (const entry of readdirSync(directory, {withFileTypes: true})) {
|
||||
const absolutePath = join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
visit(absolutePath);
|
||||
continue;
|
||||
}
|
||||
if (!entry.isFile() || !entry.name.endsWith('.css')) continue;
|
||||
const sourceFile = toPosixPath(relative(appDir, absolutePath));
|
||||
if (IGNORED_SOURCE_PREFIXES.some((prefix) => sourceFile.startsWith(prefix))) continue;
|
||||
files.push(sourceFile);
|
||||
}
|
||||
};
|
||||
visit(srcDir);
|
||||
return files
|
||||
.sort((left, right) => {
|
||||
const leftPriority = PRIORITY_SOURCE_INDEX.get(left);
|
||||
const rightPriority = PRIORITY_SOURCE_INDEX.get(right);
|
||||
if (leftPriority !== undefined || rightPriority !== undefined) {
|
||||
return (leftPriority ?? Number.MAX_SAFE_INTEGER) - (rightPriority ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
})
|
||||
.map((file) => ({
|
||||
file,
|
||||
label: PRIORITY_CSS_SOURCES.find((source) => source.file === file)?.label ?? file.replace(/^src\//, ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function selectorHas(selector: string, target: string): boolean {
|
||||
return selector
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.includes(target);
|
||||
}
|
||||
|
||||
function extractDeclarations(block: string): Array<[string, string]> {
|
||||
const declarations: Array<[string, string]> = [];
|
||||
const pattern = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(block)) !== null) {
|
||||
const name = match[1] as string;
|
||||
const value = (match[2] as string).replace(/\s+/g, ' ').trim();
|
||||
declarations.push([name, value]);
|
||||
}
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function readSourceVariables(appDir: string): {
|
||||
darkDefaults: Map<string, string>;
|
||||
lightDefaults: Map<string, string>;
|
||||
sources: Map<string, string>;
|
||||
} {
|
||||
const darkDefaults = new Map<string, string>();
|
||||
const lightOverrides = new Map<string, string>();
|
||||
const sources = new Map<string, string>();
|
||||
for (const source of discoverCssSources(appDir)) {
|
||||
const absolutePath = join(appDir, source.file);
|
||||
const css = stripAtRuleBlocks(readFileSync(absolutePath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''));
|
||||
const blockPattern = /([^{}]+)\{([^{}]*)\}/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = blockPattern.exec(css)) !== null) {
|
||||
const selector = (match[1] as string).trim();
|
||||
const block = match[2] as string;
|
||||
const isRoot = selectorHas(selector, ':root');
|
||||
const isLight = selectorHas(selector, '.theme-light');
|
||||
if (!isRoot && !isLight) continue;
|
||||
for (const [name, value] of extractDeclarations(block)) {
|
||||
if (isRoot) {
|
||||
darkDefaults.set(name, value);
|
||||
sources.set(name, source.label);
|
||||
}
|
||||
if (isLight) {
|
||||
lightOverrides.set(name, value);
|
||||
sources.set(name, source.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const extra of EXTRA_GLOBAL_DEFAULTS) {
|
||||
if (!darkDefaults.has(extra.name)) {
|
||||
darkDefaults.set(extra.name, extra.value);
|
||||
sources.set(extra.name, extra.source);
|
||||
}
|
||||
}
|
||||
const lightDefaults = new Map(darkDefaults);
|
||||
for (const [name, value] of lightOverrides) {
|
||||
lightDefaults.set(name, value);
|
||||
}
|
||||
return {darkDefaults, lightDefaults, sources};
|
||||
}
|
||||
|
||||
function resolveVariableValue(name: string, values: ReadonlyMap<string, string>, stack = new Set<string>()): string {
|
||||
const value = values.get(name);
|
||||
if (!value) return '';
|
||||
return value.replace(
|
||||
/var\(\s*(--[a-zA-Z0-9_-]+)(?:\s*,\s*([^)]+))?\)/g,
|
||||
(full, dependency: string, fallback?: string) => {
|
||||
if (dependency === '--saturation-factor') return full;
|
||||
if (stack.has(dependency)) return fallback?.trim() ?? full;
|
||||
const dependencyValue = values.get(dependency);
|
||||
if (!dependencyValue) return fallback?.trim() ?? full;
|
||||
const nextStack = new Set(stack);
|
||||
nextStack.add(name);
|
||||
return resolveVariableValue(dependency, values, nextStack);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function getGroupId(name: string): string {
|
||||
if (name.startsWith('--font')) return 'typography';
|
||||
if (name.startsWith('--z-index')) return 'layering';
|
||||
if (name.startsWith('--transition')) return 'motion';
|
||||
if (name.startsWith('--shadow')) return 'borders';
|
||||
if (name.includes('scrollbar')) return 'scrolling';
|
||||
if (name.startsWith('--message')) return 'messages';
|
||||
if (name.includes('typing')) return 'messages';
|
||||
if (name.includes('emoji')) return 'emoji';
|
||||
if (name.includes('textarea') || name.includes('input') || name.includes('form')) return 'forms';
|
||||
if (name.includes('button') || name.includes('control-button')) return 'buttons';
|
||||
if (name.startsWith('--code') || name.startsWith('--ansi') || name === '--text-code') return 'code';
|
||||
if (name.includes('table')) return 'tables';
|
||||
if (name.startsWith('--markup') || name.includes('spoiler')) return 'markup';
|
||||
if (name.startsWith('--alert')) return 'alerts';
|
||||
if (name.startsWith('--status')) return 'status';
|
||||
if (name.startsWith('--brand') || name.startsWith('--accent') || name.startsWith('--plutonium')) return 'brand';
|
||||
if (name.startsWith('--text')) return 'text';
|
||||
if (name.includes('border') || name.includes('focus') || name.includes('radius')) return 'borders';
|
||||
if (name.includes('layout') || name.includes('spacing') || name.includes('padding') || name.includes('gap'))
|
||||
return 'layout';
|
||||
if (name.includes('width') || name.includes('height') || name.includes('size') || name.includes('gutter'))
|
||||
return 'layout';
|
||||
if (name.includes('media') || name.includes('avatar') || name.includes('guild-icon')) return 'media';
|
||||
if (name.includes('bg') || name.includes('background') || name.includes('surface') || name.includes('guild-list')) {
|
||||
return 'surfaces';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function getKind(name: string, value: string): ThemeVariableKind {
|
||||
const lowerName = name.toLowerCase();
|
||||
const lowerValue = value.toLowerCase();
|
||||
if (name === '--font-sans' || name === '--font-mono') return 'font';
|
||||
if (name.startsWith('--shadow')) return 'shadow';
|
||||
if (name.startsWith('--transition') || /\b\d+(?:\.\d+)?m?s\b/.test(lowerValue)) return 'transition';
|
||||
if (/^-?\d+(?:\.\d+)?$/.test(value)) return 'number';
|
||||
if (lowerName.includes('opacity')) return 'number';
|
||||
if (lowerValue.includes(' solid ')) return 'other';
|
||||
if (
|
||||
lowerValue === 'transparent' ||
|
||||
lowerValue === 'currentcolor' ||
|
||||
lowerValue.startsWith('#') ||
|
||||
lowerValue.startsWith('hsl') ||
|
||||
lowerValue.startsWith('rgb') ||
|
||||
lowerValue.startsWith('color-mix')
|
||||
) {
|
||||
return 'color';
|
||||
}
|
||||
if (
|
||||
/(?:^|\s)-?\d*\.?\d+(?:px|rem|em|%|vh|vw|dvh|svh|cqi)\b/.test(lowerValue) ||
|
||||
lowerValue.includes('calc(') ||
|
||||
lowerValue.includes('clamp(') ||
|
||||
lowerValue.includes('min(') ||
|
||||
lowerValue.includes('max(')
|
||||
) {
|
||||
return 'dimension';
|
||||
}
|
||||
if (
|
||||
lowerName.startsWith('--ansi') ||
|
||||
lowerName.includes('color') ||
|
||||
lowerName.startsWith('--text-') ||
|
||||
lowerName.endsWith('-text') ||
|
||||
lowerName.includes('-text-') ||
|
||||
lowerName.includes('bg') ||
|
||||
lowerName.includes('background') ||
|
||||
lowerName.includes('fill') ||
|
||||
lowerName.includes('accent') ||
|
||||
lowerName.includes('brand') ||
|
||||
lowerName.includes('status') ||
|
||||
lowerName.includes('alert') ||
|
||||
lowerName.includes('selection')
|
||||
) {
|
||||
return 'color';
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function buildDefinitions(
|
||||
darkDefaults: ReadonlyMap<string, string>,
|
||||
sources: ReadonlyMap<string, string>,
|
||||
): Array<VariableDefinition> {
|
||||
return [...darkDefaults.keys()]
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((name) => {
|
||||
const value = resolveVariableValue(name, darkDefaults);
|
||||
const groupId = getGroupId(name);
|
||||
return {
|
||||
name,
|
||||
kind: getKind(name, value),
|
||||
groupId,
|
||||
groupLabel: GROUP_LABELS[groupId] ?? GROUP_LABELS.other,
|
||||
source: sources.get(name) ?? 'unknown',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderStringArray(name: string, values: ReadonlyArray<string>): string {
|
||||
const body = values.map((value) => `\t${JSON.stringify(value)},`).join('\n');
|
||||
return `export const ${name}: ReadonlyArray<string> = [\n${body}\n];`;
|
||||
}
|
||||
|
||||
function renderValueMap(name: string, values: ReadonlyMap<string, string>): string {
|
||||
const body = [...values.keys()]
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((key) => `\t${JSON.stringify(key)}: ${JSON.stringify(resolveVariableValue(key, values))},`)
|
||||
.join('\n');
|
||||
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
|
||||
}
|
||||
|
||||
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
|
||||
const body = definitions
|
||||
.map(
|
||||
(definition) =>
|
||||
`\t{name: ${JSON.stringify(definition.name)}, kind: ${JSON.stringify(definition.kind)}, groupId: ${JSON.stringify(definition.groupId)}, groupLabel: ${JSON.stringify(definition.groupLabel)}, source: ${JSON.stringify(definition.source)}},`,
|
||||
)
|
||||
.join('\n');
|
||||
return `export const THEME_VARIABLES: ReadonlyArray<ThemeVariableDefinition> = [\n${body}\n];`;
|
||||
}
|
||||
|
||||
function render(appDir: string): string {
|
||||
const {darkDefaults, lightDefaults, sources} = readSourceVariables(appDir);
|
||||
const definitions = buildDefinitions(darkDefaults, sources);
|
||||
const colorVariables = definitions
|
||||
.filter((definition) => definition.kind === 'color')
|
||||
.map((definition) => definition.name);
|
||||
const fontVariables = definitions
|
||||
.filter((definition) => definition.kind === 'font')
|
||||
.map((definition) => definition.name);
|
||||
return `// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
// Generated by scripts/GenerateThemeVariables.ts. Do not edit by hand.
|
||||
|
||||
export type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
|
||||
|
||||
export interface ThemeVariableDefinition {
|
||||
\tname: string;
|
||||
\tkind: ThemeVariableKind;
|
||||
\tgroupId: string;
|
||||
\tgroupLabel: string;
|
||||
\tsource: string;
|
||||
}
|
||||
|
||||
${renderDefinitions(definitions)}
|
||||
|
||||
${renderStringArray(
|
||||
'THEME_VARIABLE_NAMES',
|
||||
definitions.map((definition) => definition.name),
|
||||
)}
|
||||
|
||||
${renderStringArray('THEME_COLOR_VARIABLES', colorVariables)}
|
||||
|
||||
${renderStringArray('THEME_FONT_VARIABLES', fontVariables)}
|
||||
|
||||
${renderValueMap('THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES', darkDefaults)}
|
||||
|
||||
${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
|
||||
`;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const scriptDir = import.meta.dirname;
|
||||
const appDir = resolve(scriptDir, '..');
|
||||
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
|
||||
const contents = render(appDir);
|
||||
if (process.argv.includes('--check')) {
|
||||
if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== contents) {
|
||||
throw new Error(`${relative(appDir, outputPath)} is stale. Run pnpm generate:theme-variables.`);
|
||||
}
|
||||
console.log(`Checked ${relative(appDir, outputPath)}`);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(outputPath), {recursive: true});
|
||||
writeFileSync(outputPath, contents);
|
||||
console.log(`Wrote ${relative(appDir, outputPath)}`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,664 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {existsSync, readdirSync, readFileSync, statSync, writeFileSync} from 'node:fs';
|
||||
import {join, relative, resolve, sep} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {parse} from '@babel/parser';
|
||||
|
||||
const APP_DIR = fileURLToPath(new URL('..', import.meta.url));
|
||||
const SOURCE_DIR = join(APP_DIR, 'src');
|
||||
const JSX_EXTENSIONS = new Set(['.tsx', '.jsx']);
|
||||
const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.cache', '.swc']);
|
||||
const NON_THEMEABLE_IDENTIFIERS = new Set([
|
||||
'Fragment',
|
||||
'I18nProvider',
|
||||
'Outlet',
|
||||
'Profiler',
|
||||
'Provider',
|
||||
'React',
|
||||
'RouterProvider',
|
||||
'StrictMode',
|
||||
'Suspense',
|
||||
'Trans',
|
||||
]);
|
||||
const NON_THEMEABLE_MEMBER_PROPERTIES = new Set(['Consumer', 'Fragment', 'Provider']);
|
||||
const PASS_THROUGH_DOM_MEMBERS = new Set(['motion', 'm']);
|
||||
const GENERIC_PATH_SEGMENTS = new Set([
|
||||
'alerts',
|
||||
'bottomsheets',
|
||||
'commands',
|
||||
'components',
|
||||
'config',
|
||||
'constants',
|
||||
'dialogs',
|
||||
'hooks',
|
||||
'layout',
|
||||
'layouts',
|
||||
'modals',
|
||||
'models',
|
||||
'pages',
|
||||
'panels',
|
||||
'popouts',
|
||||
'routes',
|
||||
'sections',
|
||||
'shared',
|
||||
'state',
|
||||
'tabs',
|
||||
'types',
|
||||
'utils',
|
||||
]);
|
||||
const GENERIC_SCOPE_NAMES = new Set(['children', 'component', 'props', 'render', 'root-component']);
|
||||
const EVENT_ATTRIBUTE_NAMES = [
|
||||
'onClick',
|
||||
'onPress',
|
||||
'onSelect',
|
||||
'onSubmit',
|
||||
'onChange',
|
||||
'onInput',
|
||||
'onKeyDown',
|
||||
'onPointerDown',
|
||||
'onMouseDown',
|
||||
'onContextMenu',
|
||||
];
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node scripts/add-data-flx-attributes.mjs [options] [paths...]
|
||||
|
||||
Adds stable data-flx attributes to JSX elements for theme selectors.
|
||||
|
||||
Options:
|
||||
--dry-run Report changes without writing files (default)
|
||||
--write Write changes
|
||||
--check Exit non-zero when any data-flx attributes are missing
|
||||
--target <all|dom> all = JSX components and DOM nodes, dom = intrinsic DOM/SVG only (default: all)
|
||||
--summary-limit <n> Number of changed files to list in the summary (default: 30)
|
||||
--help Show this message
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
check: false,
|
||||
write: false,
|
||||
target: 'all',
|
||||
summaryLimit: 30,
|
||||
paths: [],
|
||||
};
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--dry-run') {
|
||||
options.write = false;
|
||||
} else if (arg === '--write') {
|
||||
options.write = true;
|
||||
} else if (arg === '--check') {
|
||||
options.check = true;
|
||||
options.write = false;
|
||||
} else if (arg === '--target') {
|
||||
options.target = argv[++index];
|
||||
} else if (arg.startsWith('--target=')) {
|
||||
options.target = arg.slice('--target='.length);
|
||||
} else if (arg === '--summary-limit') {
|
||||
options.summaryLimit = Number(argv[++index]);
|
||||
} else if (arg.startsWith('--summary-limit=')) {
|
||||
options.summaryLimit = Number(arg.slice('--summary-limit='.length));
|
||||
} else if (arg === '--help') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
} else if (arg === '--') {
|
||||
} else if (arg.startsWith('-')) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
} else {
|
||||
options.paths.push(arg);
|
||||
}
|
||||
}
|
||||
if (!['all', 'dom'].includes(options.target)) {
|
||||
throw new Error('--target must be "all" or "dom"');
|
||||
}
|
||||
if (!Number.isInteger(options.summaryLimit) || options.summaryLimit < 0) {
|
||||
throw new Error('--summary-limit must be a non-negative integer');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function getExtension(path) {
|
||||
const index = path.lastIndexOf('.');
|
||||
return index === -1 ? '' : path.slice(index);
|
||||
}
|
||||
|
||||
function walk(dir, out) {
|
||||
for (const entry of readdirSync(dir, {withFileTypes: true})) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
walk(join(dir, entry.name), out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && JSX_EXTENSIONS.has(getExtension(entry.name))) {
|
||||
out.push(join(dir, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectFiles(paths) {
|
||||
const files = [];
|
||||
const roots = paths.length > 0 ? paths : [SOURCE_DIR];
|
||||
for (const input of roots) {
|
||||
const absolute = resolve(APP_DIR, input);
|
||||
if (!existsSync(absolute)) {
|
||||
throw new Error(`Path does not exist: ${input}`);
|
||||
}
|
||||
const stat = statSync(absolute);
|
||||
if (stat.isDirectory()) {
|
||||
walk(absolute, files);
|
||||
} else if (stat.isFile() && JSX_EXTENSIONS.has(getExtension(absolute))) {
|
||||
files.push(absolute);
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(files)).sort();
|
||||
}
|
||||
|
||||
function parseSource(source, filePath) {
|
||||
return parse(source, {
|
||||
sourceFilename: filePath,
|
||||
sourceType: 'module',
|
||||
errorRecovery: false,
|
||||
plugins: [
|
||||
'jsx',
|
||||
'typescript',
|
||||
['decorators', {decoratorsBeforeExport: true}],
|
||||
'importAttributes',
|
||||
'explicitResourceManagement',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function kebabCase(value) {
|
||||
return String(value)
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
||||
.replace(/[\s_.:/\\]+/g, '-')
|
||||
.replace(/[^A-Za-z0-9-]+/g, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function uniqueItems(values) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const value of values) {
|
||||
if (!value || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
out.push(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function relativeFileScope(filePath) {
|
||||
const relativePath = relative(SOURCE_DIR, filePath).split(sep).join('/');
|
||||
const withoutExtension = relativePath.replace(/\.[tj]sx$/, '');
|
||||
const parts = withoutExtension.split('/').filter(Boolean);
|
||||
const normalized = [];
|
||||
for (let index = 0; index < parts.length; index++) {
|
||||
const part = parts[index];
|
||||
if (index === 0 && part === 'features') continue;
|
||||
const token = kebabCase(part);
|
||||
if (!token) continue;
|
||||
if (GENERIC_PATH_SEGMENTS.has(token) && index !== parts.length - 1) continue;
|
||||
normalized.push(token);
|
||||
}
|
||||
if (normalized.at(-1) === 'index' && normalized.length > 1) normalized.pop();
|
||||
return normalized.join('.');
|
||||
}
|
||||
|
||||
function getNodeName(node) {
|
||||
if (!node) return '';
|
||||
switch (node.type) {
|
||||
case 'JSXIdentifier':
|
||||
return node.name;
|
||||
case 'JSXMemberExpression':
|
||||
return `${getNodeName(node.object)}.${getNodeName(node.property)}`;
|
||||
case 'JSXNamespacedName':
|
||||
return `${getNodeName(node.namespace)}:${getNodeName(node.name)}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getNameTail(name) {
|
||||
const parts = name.split(/[.:]/).filter(Boolean);
|
||||
if (parts.length === 0) return '';
|
||||
if (parts.length >= 2 && PASS_THROUGH_DOM_MEMBERS.has(parts[0]) && /^[a-z]/.test(parts[1])) {
|
||||
return parts[1];
|
||||
}
|
||||
return parts.join('-');
|
||||
}
|
||||
|
||||
function isIntrinsicElementName(name) {
|
||||
if (!name) return false;
|
||||
if (/^[a-z]/.test(name)) return true;
|
||||
const parts = name.split('.');
|
||||
return parts.length === 2 && PASS_THROUGH_DOM_MEMBERS.has(parts[0]) && /^[a-z]/.test(parts[1]);
|
||||
}
|
||||
|
||||
function isNonThemeableOpeningElement(opening) {
|
||||
const name = getNodeName(opening.name);
|
||||
if (!name) return true;
|
||||
if (NON_THEMEABLE_IDENTIFIERS.has(name)) return true;
|
||||
const parts = name.split('.');
|
||||
const property = parts.at(-1);
|
||||
return property ? NON_THEMEABLE_MEMBER_PROPERTIES.has(property) : false;
|
||||
}
|
||||
|
||||
function shouldTagOpeningElement(opening, target) {
|
||||
if (opening.type !== 'JSXOpeningElement') return false;
|
||||
if (isNonThemeableOpeningElement(opening)) return false;
|
||||
const name = getNodeName(opening.name);
|
||||
if (target === 'dom') return isIntrinsicElementName(name);
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAttributeName(attribute) {
|
||||
if (!attribute || attribute.type !== 'JSXAttribute') return '';
|
||||
return getNodeName(attribute.name);
|
||||
}
|
||||
|
||||
function hasAttribute(opening, name) {
|
||||
return opening.attributes.some((attribute) => getAttributeName(attribute) === name);
|
||||
}
|
||||
|
||||
function getAttribute(opening, names) {
|
||||
const wanted = Array.isArray(names) ? new Set(names) : new Set([names]);
|
||||
return opening.attributes.find((attribute) => wanted.has(getAttributeName(attribute))) ?? null;
|
||||
}
|
||||
|
||||
function stringLiteralAttribute(opening, names) {
|
||||
const attribute = getAttribute(opening, names);
|
||||
if (!attribute?.value) return '';
|
||||
if (attribute.value.type === 'StringLiteral') return attribute.value.value;
|
||||
return '';
|
||||
}
|
||||
|
||||
function expressionFromAttribute(attribute) {
|
||||
if (!attribute?.value) return null;
|
||||
if (attribute.value.type === 'JSXExpressionContainer') return attribute.value.expression;
|
||||
return attribute.value;
|
||||
}
|
||||
|
||||
function memberPropertyToken(node) {
|
||||
if (!node) return '';
|
||||
if (node.type === 'MemberExpression' || node.type === 'OptionalMemberExpression') {
|
||||
const objectName = node.object?.type === 'Identifier' ? node.object.name : '';
|
||||
if (!/(^|[A-Z])styles?$/.test(objectName)) return '';
|
||||
if (!node.computed && node.property?.type === 'Identifier') return kebabCase(node.property.name);
|
||||
if (node.computed && node.property?.type === 'StringLiteral') return kebabCase(node.property.value);
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function collectClassTokens(node, out = []) {
|
||||
if (!node) return out;
|
||||
const memberToken = memberPropertyToken(node);
|
||||
if (memberToken) out.push(memberToken);
|
||||
switch (node.type) {
|
||||
case 'StringLiteral':
|
||||
out.push(...node.value.split(/\s+/).map(kebabCase));
|
||||
break;
|
||||
case 'TemplateLiteral':
|
||||
for (const quasi of node.quasis) out.push(...quasi.value.cooked.split(/\s+/).map(kebabCase));
|
||||
for (const expression of node.expressions) collectClassTokens(expression, out);
|
||||
break;
|
||||
case 'ArrayExpression':
|
||||
for (const element of node.elements) collectClassTokens(element, out);
|
||||
break;
|
||||
case 'ObjectExpression':
|
||||
for (const property of node.properties) {
|
||||
if (property.type === 'ObjectProperty') collectClassTokens(property.key, out);
|
||||
}
|
||||
break;
|
||||
case 'CallExpression':
|
||||
case 'OptionalCallExpression':
|
||||
for (const arg of node.arguments) collectClassTokens(arg, out);
|
||||
break;
|
||||
case 'ConditionalExpression':
|
||||
collectClassTokens(node.consequent, out);
|
||||
collectClassTokens(node.alternate, out);
|
||||
break;
|
||||
case 'LogicalExpression':
|
||||
collectClassTokens(node.left, out);
|
||||
collectClassTokens(node.right, out);
|
||||
break;
|
||||
case 'SequenceExpression':
|
||||
for (const expression of node.expressions) collectClassTokens(expression, out);
|
||||
break;
|
||||
case 'TSAsExpression':
|
||||
case 'TSSatisfiesExpression':
|
||||
case 'TSTypeAssertion':
|
||||
case 'TSNonNullExpression':
|
||||
collectClassTokens(node.expression, out);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function commonSegmentPrefix(tokens) {
|
||||
if (tokens.length < 2) return '';
|
||||
const segments = tokens.map((token) => token.split('-').filter(Boolean));
|
||||
const prefix = [];
|
||||
for (let index = 0; index < Math.min(...segments.map((segment) => segment.length)); index++) {
|
||||
const candidate = segments[0][index];
|
||||
if (segments.every((segment) => segment[index] === candidate)) {
|
||||
prefix.push(candidate);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return prefix.length >= 2 ? prefix.join('-') : '';
|
||||
}
|
||||
|
||||
function classTokenForOpening(opening) {
|
||||
const classAttribute = getAttribute(opening, ['className', 'class']);
|
||||
if (!classAttribute) return '';
|
||||
const tokens = uniqueItems(collectClassTokens(expressionFromAttribute(classAttribute)).filter(Boolean));
|
||||
if (tokens.length === 0) return '';
|
||||
return commonSegmentPrefix(tokens) || tokens[0];
|
||||
}
|
||||
|
||||
function identifierToken(node) {
|
||||
if (!node) return '';
|
||||
switch (node.type) {
|
||||
case 'Identifier':
|
||||
return node.name;
|
||||
case 'MemberExpression':
|
||||
case 'OptionalMemberExpression':
|
||||
return identifierToken(node.property);
|
||||
case 'CallExpression':
|
||||
case 'OptionalCallExpression':
|
||||
return identifierToken(node.callee);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function firstCallTokenFromExpression(node) {
|
||||
if (!node) return '';
|
||||
switch (node.type) {
|
||||
case 'CallExpression':
|
||||
case 'OptionalCallExpression':
|
||||
return identifierToken(node.callee);
|
||||
case 'ArrowFunctionExpression':
|
||||
case 'FunctionExpression':
|
||||
return firstCallTokenFromExpression(node.body);
|
||||
case 'BlockStatement':
|
||||
for (const statement of node.body) {
|
||||
const token = firstCallTokenFromExpression(statement);
|
||||
if (token) return token;
|
||||
}
|
||||
return '';
|
||||
case 'ExpressionStatement':
|
||||
return firstCallTokenFromExpression(node.expression);
|
||||
case 'ReturnStatement':
|
||||
return firstCallTokenFromExpression(node.argument);
|
||||
case 'ConditionalExpression':
|
||||
return firstCallTokenFromExpression(node.consequent) || firstCallTokenFromExpression(node.alternate);
|
||||
default:
|
||||
return identifierToken(node);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHandlerToken(token) {
|
||||
const kebab = kebabCase(token)
|
||||
.replace(/^handle-/, '')
|
||||
.replace(/^on-/, '')
|
||||
.replace(/-(handler|callback)$/, '');
|
||||
return GENERIC_SCOPE_NAMES.has(kebab) ? '' : kebab;
|
||||
}
|
||||
|
||||
function handlerTokenForOpening(opening) {
|
||||
for (const name of EVENT_ATTRIBUTE_NAMES) {
|
||||
const attribute = getAttribute(opening, name);
|
||||
const token = normalizeHandlerToken(firstCallTokenFromExpression(expressionFromAttribute(attribute)));
|
||||
if (token) return token;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isInteractiveOpening(opening) {
|
||||
const name = getNodeName(opening.name);
|
||||
const tail = kebabCase(getNameTail(name));
|
||||
if (['a', 'button', 'input', 'select', 'textarea', 'summary'].includes(tail)) return true;
|
||||
if (stringLiteralAttribute(opening, 'role') === 'button') return true;
|
||||
return EVENT_ATTRIBUTE_NAMES.some((eventName) => getAttribute(opening, eventName));
|
||||
}
|
||||
|
||||
function semanticTokenForOpening(opening) {
|
||||
const name = getNodeName(opening.name);
|
||||
const nameToken = kebabCase(getNameTail(name)) || 'element';
|
||||
const dataRole = kebabCase(stringLiteralAttribute(opening, ['data-role', 'data-testid', 'data-test-id']));
|
||||
const id = kebabCase(stringLiteralAttribute(opening, 'id'));
|
||||
const classToken = classTokenForOpening(opening);
|
||||
const role = kebabCase(stringLiteralAttribute(opening, 'role'));
|
||||
const ariaLabel = kebabCase(stringLiteralAttribute(opening, 'aria-label'));
|
||||
const type = kebabCase(stringLiteralAttribute(opening, 'type'));
|
||||
const handler = isInteractiveOpening(opening) ? handlerTokenForOpening(opening) : '';
|
||||
const parts = [];
|
||||
if (dataRole) parts.push(dataRole);
|
||||
else if (id) parts.push(id);
|
||||
else if (classToken) parts.push(classToken);
|
||||
else if (role) parts.push(role);
|
||||
else if (ariaLabel) parts.push(ariaLabel);
|
||||
else parts.push(nameToken);
|
||||
if (handler && !parts.some((part) => part.includes(handler) || handler.includes(part))) parts.push(handler);
|
||||
if (type && !parts.some((part) => part.includes(type))) parts.push(type);
|
||||
return uniqueItems(parts).join('.');
|
||||
}
|
||||
|
||||
function inferFunctionScopeName(node, ancestors) {
|
||||
if (node.type === 'FunctionDeclaration' && node.id?.name) return node.id.name;
|
||||
if (node.type === 'FunctionExpression' && node.id?.name) return node.id.name;
|
||||
const parent = ancestors.at(-1);
|
||||
const grandparent = ancestors.at(-2);
|
||||
if (parent?.type === 'VariableDeclarator' && parent.id?.type === 'Identifier') return parent.id.name;
|
||||
if (parent?.type === 'ObjectProperty' && parent.key?.type === 'Identifier') return parent.key.name;
|
||||
if (parent?.type === 'ObjectMethod' && parent.key?.type === 'Identifier') return parent.key.name;
|
||||
if (parent?.type === 'AssignmentExpression' && parent.left?.type === 'Identifier') return parent.left.name;
|
||||
if (
|
||||
parent?.type === 'CallExpression' &&
|
||||
grandparent?.type === 'VariableDeclarator' &&
|
||||
grandparent.id?.type === 'Identifier'
|
||||
) {
|
||||
return grandparent.id.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isFunctionLike(node) {
|
||||
return (
|
||||
node.type === 'FunctionDeclaration' ||
|
||||
node.type === 'FunctionExpression' ||
|
||||
node.type === 'ArrowFunctionExpression' ||
|
||||
node.type === 'ObjectMethod' ||
|
||||
node.type === 'ClassMethod' ||
|
||||
node.type === 'ClassPrivateMethod'
|
||||
);
|
||||
}
|
||||
|
||||
function isTraversableNode(value) {
|
||||
return value && typeof value === 'object' && typeof value.type === 'string';
|
||||
}
|
||||
|
||||
function visit(node, ancestors, state) {
|
||||
if (!isTraversableNode(node)) return;
|
||||
let nextState = state;
|
||||
if (isFunctionLike(node)) {
|
||||
const rawScope = inferFunctionScopeName(node, ancestors);
|
||||
const scope = kebabCase(rawScope);
|
||||
if (scope && !GENERIC_SCOPE_NAMES.has(scope)) {
|
||||
nextState = {...state, scopes: [...state.scopes, scope]};
|
||||
}
|
||||
}
|
||||
if (node.type === 'JSXOpeningElement') {
|
||||
state.onOpening(node, nextState.scopes);
|
||||
}
|
||||
const nextAncestors = [...ancestors, node];
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (
|
||||
key === 'comments' ||
|
||||
key === 'end' ||
|
||||
key === 'extra' ||
|
||||
key === 'innerComments' ||
|
||||
key === 'leadingComments' ||
|
||||
key === 'loc' ||
|
||||
key === 'start' ||
|
||||
key === 'trailingComments'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item, nextAncestors, nextState);
|
||||
} else {
|
||||
visit(value, nextAncestors, nextState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function dataFlxValue(filePath, opening, scopes, usedKeys) {
|
||||
const scopeParts = [relativeFileScope(filePath)];
|
||||
const localScope = scopes.at(-1);
|
||||
const localScopeToken = localScope && !scopeParts[0].split('.').includes(localScope) ? localScope : '';
|
||||
if (localScopeToken) scopeParts.push(localScopeToken);
|
||||
scopeParts.push(semanticTokenForOpening(opening));
|
||||
const base = scopeParts.filter(Boolean).join('.');
|
||||
const count = usedKeys.get(base) ?? 0;
|
||||
usedKeys.set(base, count + 1);
|
||||
return count === 0 ? base : `${base}--${count + 1}`;
|
||||
}
|
||||
|
||||
function lineStartAt(source, position) {
|
||||
const previousNewline = source.lastIndexOf('\n', position - 1);
|
||||
return previousNewline === -1 ? 0 : previousNewline + 1;
|
||||
}
|
||||
|
||||
function lineEndAt(source, position) {
|
||||
const nextNewline = source.indexOf('\n', position);
|
||||
return nextNewline === -1 ? source.length : nextNewline;
|
||||
}
|
||||
|
||||
function indentationAt(source, position) {
|
||||
const lineStart = lineStartAt(source, position);
|
||||
const lineEnd = lineEndAt(source, position);
|
||||
const linePrefix = source.slice(lineStart, Math.min(position, lineEnd));
|
||||
const match = linePrefix.match(/^\s*/);
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
function attributeIndent(source, opening) {
|
||||
const multiline = source.slice(opening.start, opening.end).includes('\n');
|
||||
if (!multiline) return '';
|
||||
for (const attribute of opening.attributes) {
|
||||
const start = attribute.start;
|
||||
const lineStart = lineStartAt(source, start);
|
||||
if (/^\s*$/.test(source.slice(lineStart, start))) return indentationAt(source, start);
|
||||
}
|
||||
return `${indentationAt(source, opening.start)}\t`;
|
||||
}
|
||||
|
||||
function openingCloseTokenStart(source, opening) {
|
||||
const close = source.lastIndexOf('>', opening.end - 1);
|
||||
return opening.selfClosing ? close - 1 : close;
|
||||
}
|
||||
|
||||
function buildInsertion(source, opening, value) {
|
||||
const attrText = `data-flx=${JSON.stringify(value)}`;
|
||||
const multiline = source.slice(opening.start, opening.end).includes('\n');
|
||||
const firstSpread = opening.attributes.find((attribute) => attribute.type === 'JSXSpreadAttribute');
|
||||
if (firstSpread) {
|
||||
if (!multiline) return {position: firstSpread.start, text: `${attrText} `};
|
||||
const start = lineStartAt(source, firstSpread.start);
|
||||
return {position: start, text: `${attributeIndent(source, opening)}${attrText}\n`};
|
||||
}
|
||||
const closeStart = openingCloseTokenStart(source, opening);
|
||||
if (!multiline) {
|
||||
const prefix = /\s/.test(source[closeStart - 1] ?? '') ? '' : ' ';
|
||||
const suffix = opening.selfClosing ? ' ' : '';
|
||||
return {position: closeStart, text: `${prefix}${attrText}${suffix}`};
|
||||
}
|
||||
return {position: lineStartAt(source, closeStart), text: `${attributeIndent(source, opening)}${attrText}\n`};
|
||||
}
|
||||
|
||||
function processFile(filePath, options) {
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const ast = parseSource(source, filePath);
|
||||
const usedKeys = new Map();
|
||||
const insertions = [];
|
||||
const stats = {
|
||||
added: 0,
|
||||
existing: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
visit(ast, [], {
|
||||
scopes: [],
|
||||
onOpening(opening, scopes) {
|
||||
if (!shouldTagOpeningElement(opening, options.target)) {
|
||||
stats.skipped++;
|
||||
return;
|
||||
}
|
||||
if (hasAttribute(opening, 'data-flx')) {
|
||||
stats.existing++;
|
||||
return;
|
||||
}
|
||||
const value = dataFlxValue(filePath, opening, scopes, usedKeys);
|
||||
insertions.push(buildInsertion(source, opening, value));
|
||||
stats.added++;
|
||||
},
|
||||
});
|
||||
if (insertions.length > 0 && options.write) {
|
||||
let output = source;
|
||||
for (const insertion of insertions.sort((a, b) => b.position - a.position)) {
|
||||
output = `${output.slice(0, insertion.position)}${insertion.text}${output.slice(insertion.position)}`;
|
||||
}
|
||||
writeFileSync(filePath, output);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const files = collectFiles(options.paths);
|
||||
const totals = {
|
||||
filesScanned: 0,
|
||||
filesChanged: 0,
|
||||
added: 0,
|
||||
existing: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
const changedFiles = [];
|
||||
for (const file of files) {
|
||||
const result = processFile(file, options);
|
||||
totals.filesScanned++;
|
||||
totals.added += result.added;
|
||||
totals.existing += result.existing;
|
||||
totals.skipped += result.skipped;
|
||||
if (result.added > 0) {
|
||||
totals.filesChanged++;
|
||||
changedFiles.push({file, added: result.added});
|
||||
}
|
||||
}
|
||||
const mode = options.write ? 'write' : options.check ? 'check' : 'dry-run';
|
||||
console.log(
|
||||
`data-flx ${mode}: filesScanned=${totals.filesScanned} filesChanged=${totals.filesChanged} added=${totals.added} existing=${totals.existing} skipped=${totals.skipped} target=${options.target}`,
|
||||
);
|
||||
for (const item of changedFiles.slice(0, options.summaryLimit)) {
|
||||
console.log(` ${relative(APP_DIR, item.file)} +${item.added}`);
|
||||
}
|
||||
if (changedFiles.length > options.summaryLimit) {
|
||||
console.log(` ...and ${changedFiles.length - options.summaryLimit} more files`);
|
||||
}
|
||||
if (options.check && totals.added > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {homedir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const envOverrides = loadEnvFromFiles(['FLUXER_AUTO_I18N', 'OPENROUTER_API_KEY']);
|
||||
const FLUXER_AUTO_I18N = process.env.FLUXER_AUTO_I18N ?? envOverrides.FLUXER_AUTO_I18N ?? '';
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? envOverrides.OPENROUTER_API_KEY ?? '';
|
||||
|
||||
const shouldRun = FLUXER_AUTO_I18N === '1' && Boolean(OPENROUTER_API_KEY);
|
||||
if (!shouldRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const childEnv = {...process.env, FLUXER_AUTO_I18N, OPENROUTER_API_KEY};
|
||||
|
||||
const scriptPath = new URL('./translate-i18n.mjs', import.meta.url).pathname;
|
||||
const result = spawnSync(process.execPath, [scriptPath], {stdio: 'inherit', env: childEnv});
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
function loadEnvFromFiles(keys) {
|
||||
const homeDir = homedir();
|
||||
const targetKeys = new Set(keys);
|
||||
const env = Object.create(null);
|
||||
const candidates = ['.bash_profile', '.bashrc', '.profile'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const filePath = join(homeDir, candidate);
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const parsed = parseExportLine(line);
|
||||
if (!parsed || !targetKeys.has(parsed.key) || env[parsed.key]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
env[parsed.key] = parsed.value;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseExportLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('export ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {key: match[1], value: stripQuotes(match[2])};
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
const trimmed = value.trim();
|
||||
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {buildServiceWorker} from './build/utils/ServiceWorker';
|
||||
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const EXTERNAL_MODULES = [
|
||||
'@lingui/cli',
|
||||
'@lingui/conf',
|
||||
'cosmiconfig',
|
||||
'jiti',
|
||||
'node:*',
|
||||
'crypto',
|
||||
'path',
|
||||
'fs',
|
||||
'os',
|
||||
'vm',
|
||||
'perf_hooks',
|
||||
'util',
|
||||
'events',
|
||||
'stream',
|
||||
'buffer',
|
||||
'child_process',
|
||||
'cluster',
|
||||
'dgram',
|
||||
'dns',
|
||||
'http',
|
||||
'https',
|
||||
'module',
|
||||
'net',
|
||||
'repl',
|
||||
'tls',
|
||||
'url',
|
||||
'worker_threads',
|
||||
'readline',
|
||||
'zlib',
|
||||
'resolve',
|
||||
];
|
||||
|
||||
const EXTERNAL_PATTERNS = [/^node:.*/];
|
||||
|
||||
export class ExternalsPlugin {
|
||||
apply(compiler) {
|
||||
const existingExternals = compiler.options.externals || [];
|
||||
const externalsArray = Array.isArray(existingExternals) ? existingExternals : [existingExternals];
|
||||
compiler.options.externals = [...externalsArray, ...EXTERNAL_MODULES, ...EXTERNAL_PATTERNS];
|
||||
}
|
||||
}
|
||||
|
||||
export function externalsPlugin() {
|
||||
return new ExternalsPlugin();
|
||||
}
|
||||
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
module.exports = function localArboriumLoader(source) {
|
||||
return source
|
||||
.replaceAll('"jsdelivr"', '"bundled"')
|
||||
.replaceAll("'jsdelivr'", "'bundled'")
|
||||
.replaceAll('"unpkg"', '"bundled"')
|
||||
.replaceAll("'unpkg'", "'bundled'")
|
||||
.replaceAll('"https://cdn.jsdelivr.net/npm"', '""')
|
||||
.replaceAll("'https://cdn.jsdelivr.net/npm'", "''")
|
||||
.replaceAll('"https://unpkg.com"', '""')
|
||||
.replaceAll("'https://unpkg.com'", "''");
|
||||
};
|
||||
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {sources} from '@rspack/core';
|
||||
|
||||
function normalizeEndpoint(staticCdnEndpoint) {
|
||||
if (!staticCdnEndpoint) return '';
|
||||
return staticCdnEndpoint.endsWith('/') ? staticCdnEndpoint.slice(0, -1) : staticCdnEndpoint;
|
||||
const STATIC_CDN_ENDPOINT_PLACEHOLDER = '{{STATIC_CDN_ENDPOINT}}';
|
||||
|
||||
function resolveStaticCdnEndpoint(staticCdnEndpoint) {
|
||||
const value = staticCdnEndpoint?.trim().replace(/\/+$/, '');
|
||||
return value || STATIC_CDN_ENDPOINT_PLACEHOLDER;
|
||||
}
|
||||
|
||||
function generateManifest(staticCdnEndpointRaw) {
|
||||
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
|
||||
|
||||
function generateManifest(staticCdnEndpoint) {
|
||||
const cdn = resolveStaticCdnEndpoint(staticCdnEndpoint);
|
||||
const manifest = {
|
||||
name: 'Fluxer',
|
||||
short_name: 'Fluxer',
|
||||
@@ -42,29 +26,29 @@ function generateManifest(staticCdnEndpointRaw) {
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/android-chrome-192x192.png`,
|
||||
src: `${cdn}/web/android-chrome-192x192.png`,
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/android-chrome-512x512.png`,
|
||||
src: `${cdn}/web/android-chrome-512x512.png`,
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/apple-touch-icon.png`,
|
||||
src: `${cdn}/web/apple-touch-icon.png`,
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/favicon-32x32.png`,
|
||||
src: `${cdn}/web/favicon-32x32.png`,
|
||||
sizes: '32x32',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${staticCdnEndpoint}/web/favicon-16x16.png`,
|
||||
src: `${cdn}/web/favicon-16x16.png`,
|
||||
sizes: '16x16',
|
||||
type: 'image/png',
|
||||
},
|
||||
@@ -74,14 +58,13 @@ function generateManifest(staticCdnEndpointRaw) {
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
}
|
||||
|
||||
function generateBrowserConfig(staticCdnEndpointRaw) {
|
||||
const staticCdnEndpoint = normalizeEndpoint(staticCdnEndpointRaw);
|
||||
|
||||
function generateBrowserConfig(staticCdnEndpoint) {
|
||||
const cdn = resolveStaticCdnEndpoint(staticCdnEndpoint);
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="${staticCdnEndpoint}/web/mstile-150x150.png"/>
|
||||
<square150x150logo src="${cdn}/web/mstile-150x150.png"/>
|
||||
<TileColor>#4641D9</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
@@ -93,8 +76,8 @@ function generateRobotsTxt() {
|
||||
}
|
||||
|
||||
export class StaticFilesPlugin {
|
||||
constructor(options) {
|
||||
this.staticCdnEndpoint = options?.staticCdnEndpoint ?? '';
|
||||
constructor(options = {}) {
|
||||
this.staticCdnEndpoint = options.staticCdnEndpoint;
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default function wasmLoader(_source) {
|
||||
const callback = this.async();
|
||||
|
||||
if (!callback) {
|
||||
throw new Error('Async loader not supported');
|
||||
}
|
||||
|
||||
const wasmPath = this.resourcePath;
|
||||
|
||||
fs.promises
|
||||
.readFile(wasmPath)
|
||||
.then((wasmContent) => {
|
||||
const base64 = wasmContent.toString('base64');
|
||||
const code = `
|
||||
const wasmBase64 = "${base64}";
|
||||
const wasmBinary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
|
||||
export default wasmBinary;
|
||||
`;
|
||||
callback(null, code);
|
||||
})
|
||||
.catch((err) => {
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
|
||||
export function wasmModuleRule() {
|
||||
return {
|
||||
test: /\.wasm$/,
|
||||
exclude: [/node_modules/],
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: path.join(__dirname, 'wasm.mjs'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
Vendored
+1
-18
@@ -1,21 +1,4 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
declare module 'postcss' {
|
||||
interface ProcessOptions {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '@app_scripts/build/Config';
|
||||
|
||||
export async function copyPublicAssets(): Promise<void> {
|
||||
if (!fs.existsSync(PUBLIC_DIR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(PUBLIC_DIR, {recursive: true});
|
||||
for (const file of files) {
|
||||
const srcPath = path.join(PUBLIC_DIR, file.toString());
|
||||
const destPath = path.join(DIST_DIR, file.toString());
|
||||
|
||||
const stat = await fs.promises.stat(srcPath);
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.mkdir(path.dirname(destPath), {recursive: true});
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyWasmFiles(): Promise<void> {
|
||||
const libfluxcoreDir = path.join(PKGS_DIR, 'libfluxcore');
|
||||
const wasmFile = path.join(libfluxcoreDir, 'libfluxcore_bg.wasm');
|
||||
|
||||
if (fs.existsSync(wasmFile)) {
|
||||
await fs.promises.copyFile(wasmFile, path.join(ASSETS_DIR, 'libfluxcore_bg.wasm'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUnusedCssAssets(assetsDir: string, keepFiles: Array<string>): Promise<void> {
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keepNames = new Set<string>();
|
||||
for (const file of keepFiles) {
|
||||
const base = path.basename(file);
|
||||
keepNames.add(base);
|
||||
if (base.endsWith('.css')) {
|
||||
keepNames.add(`${base}.map`);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(assetsDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.css') && !entry.endsWith('.css.map')) {
|
||||
continue;
|
||||
}
|
||||
if (keepNames.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.rm(path.join(assetsDir, entry), {force: true});
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {PKGS_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import postcss from 'postcss';
|
||||
import postcssModules from 'postcss-modules';
|
||||
|
||||
const RESERVED_KEYWORDS = new Set([
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'continue',
|
||||
'debugger',
|
||||
'default',
|
||||
'delete',
|
||||
'do',
|
||||
'else',
|
||||
'export',
|
||||
'extends',
|
||||
'finally',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'instanceof',
|
||||
'new',
|
||||
'return',
|
||||
'super',
|
||||
'switch',
|
||||
'this',
|
||||
'throw',
|
||||
'try',
|
||||
'typeof',
|
||||
'var',
|
||||
'void',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
'enum',
|
||||
'implements',
|
||||
'interface',
|
||||
'let',
|
||||
'package',
|
||||
'private',
|
||||
'protected',
|
||||
'public',
|
||||
'static',
|
||||
'await',
|
||||
'class',
|
||||
'const',
|
||||
]);
|
||||
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
if (RESERVED_KEYWORDS.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
||||
}
|
||||
|
||||
function generateDtsContent(classNames: Record<string, string>): string {
|
||||
const validClassNames = Object.keys(classNames).filter(isValidIdentifier);
|
||||
const typeMembers = validClassNames.map((name) => `\treadonly ${name}: string;`).join('\n');
|
||||
const defaultExportType =
|
||||
validClassNames.length > 0 ? `{\n${typeMembers}\n\treadonly [key: string]: string;\n}` : 'Record<string, string>';
|
||||
|
||||
return `declare const styles: ${defaultExportType};\nexport default styles;\n`;
|
||||
}
|
||||
|
||||
async function findCssModuleFiles(dir: string): Promise<Array<string>> {
|
||||
const files: Array<string> = [];
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
const entries = await fs.promises.readdir(currentDir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') {
|
||||
await walk(fullPath);
|
||||
}
|
||||
} else if (entry.name.endsWith('.module.css')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function generateDtsForFile(cssPath: string): Promise<void> {
|
||||
const cssContent = await fs.promises.readFile(cssPath, 'utf-8');
|
||||
let exportedClassNames: Record<string, string> = {};
|
||||
|
||||
await postcss([
|
||||
postcssModules({
|
||||
localsConvention: 'camelCaseOnly',
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
getJSON(_cssFileName: string, json: Record<string, string>) {
|
||||
exportedClassNames = json;
|
||||
},
|
||||
}),
|
||||
]).process(cssContent, {from: cssPath});
|
||||
|
||||
const dtsPath = `${cssPath}.d.ts`;
|
||||
const dtsContent = generateDtsContent(exportedClassNames);
|
||||
await fs.promises.writeFile(dtsPath, dtsContent);
|
||||
}
|
||||
|
||||
export async function generateCssDtsForFile(cssPath: string): Promise<void> {
|
||||
if (!cssPath.endsWith('.module.css')) {
|
||||
return;
|
||||
}
|
||||
await generateDtsForFile(cssPath);
|
||||
}
|
||||
|
||||
export async function generateAllCssDts(): Promise<void> {
|
||||
const srcFiles = await findCssModuleFiles(SRC_DIR);
|
||||
const pkgsFiles = await findCssModuleFiles(PKGS_DIR);
|
||||
const allFiles = [...srcFiles, ...pkgsFiles];
|
||||
|
||||
console.log(`Generating .d.ts files for ${allFiles.length} CSS modules...`);
|
||||
|
||||
await Promise.all(allFiles.map(generateDtsForFile));
|
||||
|
||||
console.log(`Generated ${allFiles.length} CSS module type definitions.`);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '@app_scripts/build/Config';
|
||||
|
||||
interface BuildOutput {
|
||||
mainScript: string | null;
|
||||
cssFiles: Array<string>;
|
||||
jsFiles: Array<string>;
|
||||
cssBundleFile: string | null;
|
||||
vendorScripts: Array<string>;
|
||||
}
|
||||
|
||||
interface GenerateHtmlOptions {
|
||||
buildOutput: BuildOutput;
|
||||
production: boolean;
|
||||
}
|
||||
|
||||
async function findCssModulesFile(): Promise<string | null> {
|
||||
if (!fs.existsSync(ASSETS_DIR)) {
|
||||
return null;
|
||||
}
|
||||
const files = await fs.promises.readdir(ASSETS_DIR);
|
||||
const stylesFiles = files.filter((name) => name.startsWith('styles.') && name.endsWith('.css'));
|
||||
if (stylesFiles.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latestFile: string | null = null;
|
||||
let latestMtime = 0;
|
||||
|
||||
for (const fileName of stylesFiles) {
|
||||
const filePath = path.join(ASSETS_DIR, fileName);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (latestFile === null || stats.mtimeMs > latestMtime) {
|
||||
latestFile = fileName;
|
||||
latestMtime = stats.mtimeMs;
|
||||
}
|
||||
}
|
||||
|
||||
return latestFile ? `assets/${latestFile}` : null;
|
||||
}
|
||||
|
||||
export async function generateHtml(options: GenerateHtmlOptions): Promise<string> {
|
||||
const {buildOutput, production} = options;
|
||||
|
||||
const indexHtmlPath = path.join(ROOT_DIR, 'index.html');
|
||||
let html = await fs.promises.readFile(indexHtmlPath, 'utf-8');
|
||||
|
||||
const baseUrl = production ? `${CDN_ENDPOINT}/` : '/';
|
||||
|
||||
const cssModulesFile = buildOutput.cssBundleFile ?? (await findCssModulesFile());
|
||||
const cssFiles = cssModulesFile ? [cssModulesFile] : buildOutput.cssFiles;
|
||||
|
||||
const cssLinks = cssFiles.map((file) => `<link rel="stylesheet" href="${baseUrl}${file}">`).join('\n');
|
||||
|
||||
const crossOriginAttr = production && baseUrl.startsWith('http') ? ' crossorigin="anonymous"' : '';
|
||||
|
||||
const jsScripts = buildOutput.mainScript
|
||||
? `<script type="module" src="${baseUrl}${buildOutput.mainScript}"${crossOriginAttr}></script>`
|
||||
: '';
|
||||
|
||||
const buildScriptPreload = (file: string): string =>
|
||||
`<link rel="preload" as="script" href="${baseUrl}${file}"${crossOriginAttr}>`;
|
||||
|
||||
const preloadScripts = [
|
||||
...(buildOutput.vendorScripts ?? []).map(buildScriptPreload),
|
||||
...buildOutput.jsFiles.filter((file) => !file.includes('messages')).map(buildScriptPreload),
|
||||
].join('\n');
|
||||
|
||||
html = html.replace(/<script type="module" src="\/src\/index\.tsx"><\/script>/, jsScripts);
|
||||
const headInsert = [cssLinks, preloadScripts].filter(Boolean).join('\n');
|
||||
html = html.replace('</head>', `${headInsert}\n</head>`);
|
||||
|
||||
return html;
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {RESOLVE_EXTENSIONS} from '@app_scripts/build/Config';
|
||||
|
||||
export function tryResolveWithExtensions(basePath: string): string | null {
|
||||
if (fs.existsSync(basePath)) {
|
||||
const stat = fs.statSync(basePath);
|
||||
if (stat.isFile()) {
|
||||
return basePath;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const indexPath = path.join(basePath, `index${ext}`);
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return indexPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const withExt = `${basePath}${ext}`;
|
||||
if (fs.existsSync(withExt)) {
|
||||
return withExt;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,29 +1,67 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {promises as fs} from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {DIST_DIR, SRC_DIR} from '@app_scripts/build/Config';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
interface PrecacheEntry {
|
||||
url: string;
|
||||
revision: string;
|
||||
}
|
||||
|
||||
const PRECACHE_ROOT_FILES = ['index.html', 'manifest.json', 'browserconfig.xml', 'robots.txt', 'version.json'];
|
||||
|
||||
async function fileRevision(filePath: string): Promise<string> {
|
||||
const stat = await fs.stat(filePath);
|
||||
return `${stat.size}:${Math.trunc(stat.mtimeMs)}`;
|
||||
}
|
||||
|
||||
function isLocalAssetUrl(value: string): boolean {
|
||||
if (!value.startsWith('/')) {
|
||||
return false;
|
||||
}
|
||||
if (value.startsWith('//')) {
|
||||
return false;
|
||||
}
|
||||
return value.startsWith('/assets/') || PRECACHE_ROOT_FILES.some((file) => value === `/${file}`);
|
||||
}
|
||||
|
||||
async function collectPrecacheManifest(): Promise<Array<PrecacheEntry>> {
|
||||
const entries = new Map<string, string>();
|
||||
for (const file of PRECACHE_ROOT_FILES) {
|
||||
const filePath = path.join(DIST_DIR, file);
|
||||
try {
|
||||
const revision = await fileRevision(filePath);
|
||||
entries.set(`/${file}`, revision);
|
||||
if (file === 'index.html') {
|
||||
entries.set('/', revision);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
try {
|
||||
const html = await fs.readFile(path.join(DIST_DIR, 'index.html'), 'utf8');
|
||||
const attributePattern = /\b(?:href|src)=["']([^"']+)["']/g;
|
||||
for (const match of html.matchAll(attributePattern)) {
|
||||
const url = match[1];
|
||||
if (!isLocalAssetUrl(url)) {
|
||||
continue;
|
||||
}
|
||||
const pathname = new URL(url, 'https://local.invalid').pathname;
|
||||
const filePath = path.join(DIST_DIR, pathname.slice(1));
|
||||
try {
|
||||
entries.set(pathname, await fileRevision(filePath));
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return Array.from(entries, ([url, revision]) => ({url, revision}));
|
||||
}
|
||||
|
||||
export async function buildServiceWorker(production: boolean): Promise<void> {
|
||||
const precacheManifest = await collectPrecacheManifest();
|
||||
const buildVersion = process.env.PUBLIC_BUILD_SHA || process.env.BUILD_SHA || String(Date.now());
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'service_worker', 'Worker.tsx')],
|
||||
entryPoints: [path.join(SRC_DIR, 'features', 'platform', 'service_worker', 'Worker.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: path.join(DIST_DIR, 'sw.js'),
|
||||
@@ -32,6 +70,8 @@ export async function buildServiceWorker(production: boolean): Promise<void> {
|
||||
target: 'esnext',
|
||||
define: {
|
||||
__WB_MANIFEST: '[]',
|
||||
__FLUXER_PRECACHE_MANIFEST__: JSON.stringify(precacheManifest),
|
||||
__FLUXER_SW_VERSION__: JSON.stringify(buildVersion),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function traverseDir(dir: string, callback: (filePath: string) => Promise<void>): Promise<void> {
|
||||
const entries = await fs.promises.readdir(dir, {withFileTypes: true});
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await traverseDir(entryPath, callback);
|
||||
return;
|
||||
}
|
||||
await callback(entryPath);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanEmptySourceMaps(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await traverseDir(dir, async (filePath) => {
|
||||
if (!filePath.endsWith('.js.map')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf-8');
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = (parsed as {sources?: Array<unknown>}).sources ?? [];
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.promises.rm(filePath, {force: true});
|
||||
|
||||
const jsPath = filePath.slice(0, -4);
|
||||
if (!(await fileExists(jsPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsContent = await fs.promises.readFile(jsPath, 'utf-8');
|
||||
const cleaned = jsContent.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*$/, '');
|
||||
if (cleaned !== jsContent) {
|
||||
await fs.promises.writeFile(jsPath, cleaned);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,363 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
console.error('Error: OPENROUTER_API_KEY environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const LOCALES_DIR = new URL('../src/locales', import.meta.url).pathname;
|
||||
const SOURCE_LOCALE = 'en-US';
|
||||
const BATCH_SIZE = 20;
|
||||
const CONCURRENT_LOCALES = 10;
|
||||
const CONCURRENT_BATCHES_PER_LOCALE = 3;
|
||||
|
||||
const LOCALE_NAMES = {
|
||||
ar: 'Arabic',
|
||||
bg: 'Bulgarian',
|
||||
cs: 'Czech',
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
el: 'Greek',
|
||||
'en-GB': 'British English',
|
||||
'es-ES': 'Spanish (Spain)',
|
||||
'es-419': 'Spanish (Latin America)',
|
||||
fi: 'Finnish',
|
||||
fr: 'French',
|
||||
he: 'Hebrew',
|
||||
hi: 'Hindi',
|
||||
hr: 'Croatian',
|
||||
hu: 'Hungarian',
|
||||
id: 'Indonesian',
|
||||
it: 'Italian',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
lt: 'Lithuanian',
|
||||
nl: 'Dutch',
|
||||
no: 'Norwegian',
|
||||
pl: 'Polish',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
ro: 'Romanian',
|
||||
ru: 'Russian',
|
||||
'sv-SE': 'Swedish',
|
||||
th: 'Thai',
|
||||
tr: 'Turkish',
|
||||
uk: 'Ukrainian',
|
||||
vi: 'Vietnamese',
|
||||
'zh-CN': 'Chinese (Simplified)',
|
||||
'zh-TW': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
function parsePo(content) {
|
||||
const entries = [];
|
||||
const lines = content.split('\n');
|
||||
let currentEntry = null;
|
||||
let currentField = null;
|
||||
let isHeader = true;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith('#. ')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.comments.push(line);
|
||||
} else if (line.startsWith('#: ')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.references.push(line);
|
||||
} else if (line.startsWith('msgid "')) {
|
||||
if (!currentEntry) {
|
||||
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
||||
}
|
||||
currentEntry.msgid = line.slice(7, -1);
|
||||
currentField = 'msgid';
|
||||
} else if (line.startsWith('msgstr "')) {
|
||||
if (currentEntry) {
|
||||
currentEntry.msgstr = line.slice(8, -1);
|
||||
currentField = 'msgstr';
|
||||
}
|
||||
} else if (line.startsWith('"') && line.endsWith('"')) {
|
||||
if (currentEntry && currentField) {
|
||||
currentEntry[currentField] += line.slice(1, -1);
|
||||
}
|
||||
} else if (line === '' && currentEntry) {
|
||||
if (isHeader && currentEntry.msgid === '') {
|
||||
isHeader = false;
|
||||
} else if (currentEntry.msgid !== '') {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
currentEntry = null;
|
||||
currentField = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentEntry && currentEntry.msgid !== '') {
|
||||
entries.push(currentEntry);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function rebuildPo(content, translations) {
|
||||
const translationMap = new Map(translations.map((t) => [t.msgid, t.msgstr]));
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const blocks = normalized.trimEnd().split(/\n{2,}/g);
|
||||
const nextBlocks = blocks.map((block) => rebuildPoBlock(block, translationMap));
|
||||
return `${nextBlocks.join('\n\n')}\n`;
|
||||
}
|
||||
|
||||
function rebuildPoBlock(block, translationMap) {
|
||||
const lines = block.split('\n');
|
||||
const msgidRange = getFieldRange(lines, 'msgid');
|
||||
const msgstrRange = getFieldRange(lines, 'msgstr');
|
||||
|
||||
if (!msgidRange || !msgstrRange) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const hasReferences = lines.some((line) => line.startsWith('#: '));
|
||||
const msgid = readFieldRawValue(lines, msgidRange);
|
||||
if (!hasReferences && msgid === '') {
|
||||
return block;
|
||||
}
|
||||
|
||||
const currentMsgstr = readFieldRawValue(lines, msgstrRange);
|
||||
if (currentMsgstr !== '') {
|
||||
return block;
|
||||
}
|
||||
|
||||
if (!translationMap.has(msgid)) {
|
||||
return block;
|
||||
}
|
||||
|
||||
const newMsgstr = translationMap.get(msgid);
|
||||
const newMsgstrLine = `msgstr "${escapePo(newMsgstr)}"`;
|
||||
return [...lines.slice(0, msgstrRange.startIndex), newMsgstrLine, ...lines.slice(msgstrRange.endIndex)].join('\n');
|
||||
}
|
||||
|
||||
function getFieldRange(lines, field) {
|
||||
const startIndex = lines.findIndex((line) => line.startsWith(`${field} `));
|
||||
if (startIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
let endIndex = startIndex + 1;
|
||||
while (endIndex < lines.length && lines[endIndex].startsWith('"') && lines[endIndex].endsWith('"')) {
|
||||
endIndex++;
|
||||
}
|
||||
return {startIndex, endIndex};
|
||||
}
|
||||
|
||||
function readFieldRawValue(lines, range) {
|
||||
const firstLine = lines[range.startIndex];
|
||||
const match = firstLine.match(/^[a-z]+\s+"(.*)"$/);
|
||||
let value = match ? match[1] : '';
|
||||
for (let i = range.startIndex + 1; i < range.endIndex; i++) {
|
||||
value += lines[i].slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function escapePo(str) {
|
||||
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
|
||||
}
|
||||
|
||||
function unescapePo(str) {
|
||||
return str.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
async function translateBatch(strings, targetLocale) {
|
||||
const localeName = LOCALE_NAMES[targetLocale] || targetLocale;
|
||||
|
||||
const prompt = `You are a professional translator. Translate the following UI strings from English to ${localeName}.
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Preserve ALL placeholders exactly as they appear: {0}, {1}, {name}, {count}, etc.
|
||||
2. Preserve ICU plural syntax exactly: {0, plural, one {...} other {...}}
|
||||
3. Keep technical terms, brand names, and special characters intact
|
||||
4. Match the tone and formality of a modern chat/messaging application
|
||||
5. Return ONLY a JSON array of translated strings in the same order as input
|
||||
6. Do NOT add any explanations or notes
|
||||
|
||||
Input strings (JSON array):
|
||||
${JSON.stringify(strings, null, 2)}
|
||||
|
||||
Output (JSON array of translated strings only):`;
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://fluxer.dev',
|
||||
'X-Title': 'Fluxer i18n Translation',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/gpt-4o-mini',
|
||||
messages: [{role: 'user', content: prompt}],
|
||||
temperature: 0.3,
|
||||
max_tokens: 4096,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content;
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Empty response from API');
|
||||
}
|
||||
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
throw new Error(`Failed to parse JSON from response: ${content}`);
|
||||
}
|
||||
|
||||
const translations = JSON.parse(jsonMatch[0]);
|
||||
|
||||
if (translations.length !== strings.length) {
|
||||
throw new Error(`Translation count mismatch: expected ${strings.length}, got ${translations.length}`);
|
||||
}
|
||||
|
||||
return translations;
|
||||
}
|
||||
|
||||
async function pMap(items, mapper, concurrency) {
|
||||
const results = [];
|
||||
const executing = new Set();
|
||||
|
||||
for (const [index, item] of items.entries()) {
|
||||
const promise = Promise.resolve().then(() => mapper(item, index));
|
||||
results.push(promise);
|
||||
executing.add(promise);
|
||||
|
||||
const clean = () => executing.delete(promise);
|
||||
promise.then(clean, clean);
|
||||
|
||||
if (executing.size >= concurrency) {
|
||||
await Promise.race(executing);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(results);
|
||||
}
|
||||
|
||||
async function processLocale(locale) {
|
||||
const poPath = join(LOCALES_DIR, locale, 'messages.po');
|
||||
console.log(`[${locale}] Starting...`);
|
||||
|
||||
let content;
|
||||
try {
|
||||
content = readFileSync(poPath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error(`[${locale}] Error reading file: ${error.message}`);
|
||||
return {locale, translated: 0, errors: 1};
|
||||
}
|
||||
|
||||
const entries = parsePo(content);
|
||||
const untranslated = entries.filter((e) => e.msgstr === '');
|
||||
|
||||
if (untranslated.length === 0) {
|
||||
console.log(`[${locale}] No untranslated strings`);
|
||||
return {locale, translated: 0, errors: 0};
|
||||
}
|
||||
|
||||
console.log(`[${locale}] Found ${untranslated.length} untranslated strings`);
|
||||
|
||||
const batches = [];
|
||||
for (let i = 0; i < untranslated.length; i += BATCH_SIZE) {
|
||||
batches.push({
|
||||
index: Math.floor(i / BATCH_SIZE),
|
||||
total: Math.ceil(untranslated.length / BATCH_SIZE),
|
||||
entries: untranslated.slice(i, i + BATCH_SIZE),
|
||||
});
|
||||
}
|
||||
|
||||
let errorCount = 0;
|
||||
const allTranslations = [];
|
||||
|
||||
const batchResults = await pMap(
|
||||
batches,
|
||||
async (batch) => {
|
||||
const batchStrings = batch.entries.map((e) => unescapePo(e.msgid));
|
||||
|
||||
try {
|
||||
const translatedStrings = await translateBatch(batchStrings, locale);
|
||||
console.log(`[${locale}] Batch ${batch.index + 1}/${batch.total} complete`);
|
||||
|
||||
return batch.entries.map((entry, j) => ({
|
||||
msgid: entry.msgid,
|
||||
msgstr: translatedStrings[j],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[${locale}] Batch ${batch.index + 1}/${batch.total} error: ${error.message}`);
|
||||
errorCount++;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
CONCURRENT_BATCHES_PER_LOCALE,
|
||||
);
|
||||
|
||||
for (const translations of batchResults) {
|
||||
allTranslations.push(...translations);
|
||||
}
|
||||
|
||||
if (allTranslations.length > 0) {
|
||||
const updatedContent = rebuildPo(content, allTranslations);
|
||||
writeFileSync(poPath, updatedContent, 'utf-8');
|
||||
console.log(`[${locale}] Updated ${allTranslations.length} translations`);
|
||||
}
|
||||
|
||||
return {locale, translated: allTranslations.length, errors: errorCount};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Starting i18n translation...');
|
||||
console.log(`Locales directory: ${LOCALES_DIR}`);
|
||||
console.log(`Concurrency: ${CONCURRENT_LOCALES} locales, ${CONCURRENT_BATCHES_PER_LOCALE} batches per locale`);
|
||||
|
||||
const locales = readdirSync(LOCALES_DIR).filter((d) => d !== SOURCE_LOCALE && LOCALE_NAMES[d]);
|
||||
|
||||
console.log(`Found ${locales.length} locales to process\n`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const results = await pMap(locales, processLocale, CONCURRENT_LOCALES);
|
||||
|
||||
const totalTranslated = results.reduce((sum, r) => sum + (r?.translated || 0), 0);
|
||||
const totalErrors = results.reduce((sum, r) => sum + (r?.errors || 0), 0);
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log(`\nTranslation complete in ${elapsed}s`);
|
||||
console.log(`Total: ${totalTranslated} strings translated, ${totalErrors} errors`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user