initial commit
This commit is contained in:
52
fluxer_app/scripts/build-electron.mjs
Normal file
52
fluxer_app/scripts/build-electron.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {execSync} from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(scriptDir, '..');
|
||||
|
||||
const allowedChannels = new Set(['stable', 'canary']);
|
||||
const rawChannel = process.env.BUILD_CHANNEL?.toLowerCase() ?? '';
|
||||
const channel = allowedChannels.has(rawChannel) ? rawChannel : 'stable';
|
||||
|
||||
console.log(`Building Electron with channel: ${channel}`);
|
||||
|
||||
execSync('node scripts/set-build-channel.mjs', {cwd: projectRoot, stdio: 'inherit'});
|
||||
|
||||
execSync('npx tsc -p tsconfig.electron.json', {cwd: projectRoot, stdio: 'inherit'});
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(projectRoot, 'src-electron/preload/index.ts')],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: path.join(projectRoot, 'src-electron/dist/preload/index.js'),
|
||||
external: ['electron'],
|
||||
define: {
|
||||
'process.env.BUILD_CHANNEL': JSON.stringify(channel),
|
||||
},
|
||||
sourcemap: true,
|
||||
});
|
||||
|
||||
console.log('Electron build complete');
|
||||
27
fluxer_app/scripts/build-sw.mjs
Normal file
27
fluxer_app/scripts/build-sw.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {buildServiceWorker} from './build/utils/service-worker.js';
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
buildServiceWorker(isProduction).catch((error) => {
|
||||
console.error('Service worker build failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
88
fluxer_app/scripts/build/config.ts
Normal file
88
fluxer_app/scripts/build/config.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..');
|
||||
export const SRC_DIR = path.join(ROOT_DIR, 'src');
|
||||
export const DIST_DIR = path.join(ROOT_DIR, 'dist');
|
||||
export const ASSETS_DIR = path.join(DIST_DIR, 'assets');
|
||||
export const PKGS_DIR = path.join(ROOT_DIR, 'pkgs');
|
||||
export const PUBLIC_DIR = path.join(ROOT_DIR, 'assets');
|
||||
|
||||
export const CDN_ENDPOINT = 'https://fluxerstatic.com';
|
||||
|
||||
export const DEV_PORT = 3000;
|
||||
|
||||
export const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.json', '.mjs', '.cjs'];
|
||||
|
||||
export const LOCALES = [
|
||||
'ar',
|
||||
'bg',
|
||||
'cs',
|
||||
'da',
|
||||
'de',
|
||||
'el',
|
||||
'en-GB',
|
||||
'en-US',
|
||||
'es-419',
|
||||
'es-ES',
|
||||
'fi',
|
||||
'fr',
|
||||
'he',
|
||||
'hi',
|
||||
'hr',
|
||||
'hu',
|
||||
'id',
|
||||
'it',
|
||||
'ja',
|
||||
'ko',
|
||||
'lt',
|
||||
'nl',
|
||||
'no',
|
||||
'pl',
|
||||
'pt-BR',
|
||||
'ro',
|
||||
'ru',
|
||||
'sv-SE',
|
||||
'th',
|
||||
'tr',
|
||||
'uk',
|
||||
'vi',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
];
|
||||
|
||||
export const FILE_LOADERS: Record<string, 'file'> = {
|
||||
'.woff': 'file',
|
||||
'.woff2': 'file',
|
||||
'.ttf': 'file',
|
||||
'.eot': 'file',
|
||||
'.png': 'file',
|
||||
'.jpg': 'file',
|
||||
'.jpeg': 'file',
|
||||
'.gif': 'file',
|
||||
'.webp': 'file',
|
||||
'.ico': 'file',
|
||||
'.mp3': 'file',
|
||||
'.wav': 'file',
|
||||
'.ogg': 'file',
|
||||
'.mp4': 'file',
|
||||
'.webm': 'file',
|
||||
};
|
||||
65
fluxer_app/scripts/build/rspack/externals.mjs
Normal file
65
fluxer_app/scripts/build/rspack/externals.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const EXTERNAL_MODULES = [
|
||||
'@lingui/cli',
|
||||
'@lingui/conf',
|
||||
'cosmiconfig',
|
||||
'jiti',
|
||||
'node:*',
|
||||
'crypto',
|
||||
'path',
|
||||
'fs',
|
||||
'os',
|
||||
'vm',
|
||||
'perf_hooks',
|
||||
'util',
|
||||
'events',
|
||||
'stream',
|
||||
'buffer',
|
||||
'child_process',
|
||||
'cluster',
|
||||
'dgram',
|
||||
'dns',
|
||||
'http',
|
||||
'https',
|
||||
'module',
|
||||
'net',
|
||||
'repl',
|
||||
'tls',
|
||||
'url',
|
||||
'worker_threads',
|
||||
'readline',
|
||||
'zlib',
|
||||
'resolve',
|
||||
];
|
||||
|
||||
const EXTERNAL_PATTERNS = [/^node:.*/];
|
||||
|
||||
export class ExternalsPlugin {
|
||||
apply(compiler) {
|
||||
const existingExternals = compiler.options.externals || [];
|
||||
const externalsArray = Array.isArray(existingExternals) ? existingExternals : [existingExternals];
|
||||
compiler.options.externals = [...externalsArray, ...EXTERNAL_MODULES, ...EXTERNAL_PATTERNS];
|
||||
}
|
||||
}
|
||||
|
||||
export function externalsPlugin() {
|
||||
return new ExternalsPlugin();
|
||||
}
|
||||
48
fluxer_app/scripts/build/rspack/lingui.mjs
Normal file
48
fluxer_app/scripts/build/rspack/lingui.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export function getLinguiSwcPluginConfig() {
|
||||
return [
|
||||
'@lingui/swc-plugin',
|
||||
{
|
||||
localeDir: 'src/locales/{locale}/messages',
|
||||
runtimeModules: {
|
||||
i18n: ['@lingui/core', 'i18n'],
|
||||
trans: ['@lingui/react', 'Trans'],
|
||||
},
|
||||
stripNonEssentialFields: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createPoFileRule() {
|
||||
return {
|
||||
test: /\.po$/,
|
||||
type: 'javascript/auto',
|
||||
use: {
|
||||
loader: path.join(__dirname, 'po-loader.mjs'),
|
||||
},
|
||||
};
|
||||
}
|
||||
164
fluxer_app/scripts/build/rspack/po-loader.mjs
Normal file
164
fluxer_app/scripts/build/rspack/po-loader.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
export default function poLoader(source) {
|
||||
const callback = this.async();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
this.cacheable?.();
|
||||
|
||||
const poPath = this.resourcePath;
|
||||
const compiledPath = `${poPath}.mjs`;
|
||||
|
||||
this.addDependency?.(poPath);
|
||||
|
||||
try {
|
||||
await fs.access(compiledPath);
|
||||
this.addDependency?.(compiledPath);
|
||||
const compiledSource = await fs.readFile(compiledPath, 'utf8');
|
||||
callback(null, compiledSource);
|
||||
return;
|
||||
} catch {}
|
||||
|
||||
const content = Buffer.isBuffer(source) ? source.toString('utf8') : String(source);
|
||||
const messages = parsePoFile(content);
|
||||
|
||||
const code = `export const messages = ${JSON.stringify(messages, null, 2)};\nexport default messages;\n`;
|
||||
|
||||
callback(null, code);
|
||||
} catch (err) {
|
||||
callback(err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function parsePoFile(content) {
|
||||
const messages = {};
|
||||
const entries = splitEntries(content);
|
||||
|
||||
for (const entry of entries) {
|
||||
const parsed = parseEntry(entry);
|
||||
if (!parsed) continue;
|
||||
messages[parsed.key] = parsed.value;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function splitEntries(content) {
|
||||
const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
return normalized
|
||||
.split(/\n{2,}/g)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseEntry(entry) {
|
||||
const lines = entry.split('\n');
|
||||
|
||||
let msgctxt = null;
|
||||
let msgid = null;
|
||||
let msgidPlural = null;
|
||||
|
||||
const msgstrMap = new Map();
|
||||
|
||||
let active = null;
|
||||
let activeMsgstrIndex = 0;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
|
||||
if (line.startsWith('msgctxt ')) {
|
||||
active = 'msgctxt';
|
||||
activeMsgstrIndex = 0;
|
||||
msgctxt = extractPoString(line.slice('msgctxt '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgid_plural ')) {
|
||||
active = 'msgidPlural';
|
||||
activeMsgstrIndex = 0;
|
||||
msgidPlural = extractPoString(line.slice('msgid_plural '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgid ')) {
|
||||
active = 'msgid';
|
||||
activeMsgstrIndex = 0;
|
||||
msgid = extractPoString(line.slice('msgid '.length));
|
||||
continue;
|
||||
}
|
||||
|
||||
const msgstrIndexed = line.match(/^msgstr\[(\d+)\]\s+/);
|
||||
if (msgstrIndexed) {
|
||||
active = 'msgstr';
|
||||
activeMsgstrIndex = Number(msgstrIndexed[1]);
|
||||
const rest = line.slice(msgstrIndexed[0].length);
|
||||
msgstrMap.set(activeMsgstrIndex, extractPoString(rest));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('msgstr ')) {
|
||||
active = 'msgstr';
|
||||
activeMsgstrIndex = 0;
|
||||
msgstrMap.set(0, extractPoString(line.slice('msgstr '.length)));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('"') && line.endsWith('"')) {
|
||||
const part = extractPoString(line);
|
||||
|
||||
if (active === 'msgctxt') msgctxt = (msgctxt ?? '') + part;
|
||||
else if (active === 'msgid') msgid = (msgid ?? '') + part;
|
||||
else if (active === 'msgidPlural') msgidPlural = (msgidPlural ?? '') + part;
|
||||
else if (active === 'msgstr') msgstrMap.set(activeMsgstrIndex, (msgstrMap.get(activeMsgstrIndex) ?? '') + part);
|
||||
}
|
||||
}
|
||||
|
||||
if (msgid == null) return null;
|
||||
|
||||
if (msgid === '') return null;
|
||||
|
||||
const key = msgctxt ? `${msgctxt}\u0004${msgid}` : msgid;
|
||||
|
||||
if (msgidPlural != null) {
|
||||
const keys = Array.from(msgstrMap.keys());
|
||||
const maxIndex = keys.length ? Math.max(...keys.map((n) => Number(n))) : 0;
|
||||
|
||||
const arr = [];
|
||||
for (let i = 0; i <= maxIndex; i++) {
|
||||
arr[i] = msgstrMap.get(i) ?? '';
|
||||
}
|
||||
|
||||
return {key, value: arr};
|
||||
}
|
||||
|
||||
return {key, value: msgstrMap.get(0) ?? ''};
|
||||
}
|
||||
|
||||
function extractPoString(str) {
|
||||
const trimmed = str.trim();
|
||||
if (!trimmed.startsWith('"') || !trimmed.endsWith('"')) return trimmed;
|
||||
|
||||
return trimmed.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
||||
}
|
||||
119
fluxer_app/scripts/build/rspack/static-files.mjs
Normal file
119
fluxer_app/scripts/build/rspack/static-files.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {sources} from '@rspack/core';
|
||||
|
||||
function normalizeEndpoint(cdnEndpoint) {
|
||||
if (!cdnEndpoint) return '';
|
||||
return cdnEndpoint.endsWith('/') ? cdnEndpoint.slice(0, -1) : cdnEndpoint;
|
||||
}
|
||||
|
||||
function generateManifest(cdnEndpointRaw) {
|
||||
const cdnEndpoint = normalizeEndpoint(cdnEndpointRaw);
|
||||
|
||||
const manifest = {
|
||||
name: 'Fluxer',
|
||||
short_name: 'Fluxer',
|
||||
description:
|
||||
'Chat that puts you first. Built for friends, groups, and communities. Text, voice, and video. Open source and community-funded.',
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
theme_color: '#4641D9',
|
||||
background_color: '#2b2d31',
|
||||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{
|
||||
src: `${cdnEndpoint}/web/android-chrome-192x192.png`,
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${cdnEndpoint}/web/android-chrome-512x512.png`,
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable any',
|
||||
},
|
||||
{
|
||||
src: `${cdnEndpoint}/web/apple-touch-icon.png`,
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${cdnEndpoint}/web/favicon-32x32.png`,
|
||||
sizes: '32x32',
|
||||
type: 'image/png',
|
||||
},
|
||||
{
|
||||
src: `${cdnEndpoint}/web/favicon-16x16.png`,
|
||||
sizes: '16x16',
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return JSON.stringify(manifest, null, 2);
|
||||
}
|
||||
|
||||
function generateBrowserConfig(cdnEndpointRaw) {
|
||||
const cdnEndpoint = normalizeEndpoint(cdnEndpointRaw);
|
||||
|
||||
return `<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="${cdnEndpoint}/web/mstile-150x150.png"/>
|
||||
<TileColor>#4641D9</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>`;
|
||||
}
|
||||
|
||||
function generateRobotsTxt() {
|
||||
return 'User-agent: *\nAllow: /\n';
|
||||
}
|
||||
|
||||
export class StaticFilesPlugin {
|
||||
constructor(options) {
|
||||
this.cdnEndpoint = options?.cdnEndpoint ?? '';
|
||||
}
|
||||
|
||||
apply(compiler) {
|
||||
compiler.hooks.thisCompilation.tap('StaticFilesPlugin', (compilation) => {
|
||||
compilation.hooks.processAssets.tap(
|
||||
{
|
||||
name: 'StaticFilesPlugin',
|
||||
stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONAL,
|
||||
},
|
||||
() => {
|
||||
compilation.emitAsset('manifest.json', new sources.RawSource(generateManifest(this.cdnEndpoint)));
|
||||
compilation.emitAsset('browserconfig.xml', new sources.RawSource(generateBrowserConfig(this.cdnEndpoint)));
|
||||
compilation.emitAsset('robots.txt', new sources.RawSource(generateRobotsTxt()));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function staticFilesPlugin(options) {
|
||||
return new StaticFilesPlugin(options);
|
||||
}
|
||||
63
fluxer_app/scripts/build/rspack/wasm.mjs
Normal file
63
fluxer_app/scripts/build/rspack/wasm.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export default function wasmLoader(_source) {
|
||||
const callback = this.async();
|
||||
|
||||
if (!callback) {
|
||||
throw new Error('Async loader not supported');
|
||||
}
|
||||
|
||||
const wasmPath = this.resourcePath;
|
||||
|
||||
fs.promises
|
||||
.readFile(wasmPath)
|
||||
.then((wasmContent) => {
|
||||
const base64 = wasmContent.toString('base64');
|
||||
const code = `
|
||||
const wasmBase64 = "${base64}";
|
||||
const wasmBinary = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
|
||||
export default wasmBinary;
|
||||
`;
|
||||
callback(null, code);
|
||||
})
|
||||
.catch((err) => {
|
||||
callback(err);
|
||||
});
|
||||
}
|
||||
|
||||
export function wasmModuleRule() {
|
||||
return {
|
||||
test: /\.wasm$/,
|
||||
exclude: [/node_modules/],
|
||||
type: 'javascript/auto',
|
||||
use: [
|
||||
{
|
||||
loader: path.join(__dirname, 'wasm.mjs'),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
20
fluxer_app/scripts/build/tsconfig.json
Normal file
20
fluxer_app/scripts/build/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2023"],
|
||||
"noEmit": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"moduleDetection": "force",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
82
fluxer_app/scripts/build/types.d.ts
vendored
Normal file
82
fluxer_app/scripts/build/types.d.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
declare module 'postcss' {
|
||||
interface ProcessOptions {
|
||||
from?: string;
|
||||
to?: string;
|
||||
map?: boolean | {inline?: boolean; prev?: boolean | string | object; annotation?: boolean | string};
|
||||
parser?: unknown;
|
||||
stringifier?: unknown;
|
||||
syntax?: unknown;
|
||||
}
|
||||
|
||||
interface Result {
|
||||
css: string;
|
||||
map?: unknown;
|
||||
root: unknown;
|
||||
processor: unknown;
|
||||
messages: Array<unknown>;
|
||||
opts: ProcessOptions;
|
||||
}
|
||||
|
||||
interface Plugin {
|
||||
postcssPlugin: string;
|
||||
Once?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
Root?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
RootExit?(root: unknown, helpers: unknown): void | Promise<void>;
|
||||
AtRule?(atRule: unknown, helpers: unknown): void | Promise<void>;
|
||||
AtRuleExit?(atRule: unknown, helpers: unknown): void | Promise<void>;
|
||||
Rule?(rule: unknown, helpers: unknown): void | Promise<void>;
|
||||
RuleExit?(rule: unknown, helpers: unknown): void | Promise<void>;
|
||||
Declaration?(declaration: unknown, helpers: unknown): void | Promise<void>;
|
||||
DeclarationExit?(declaration: unknown, helpers: unknown): void | Promise<void>;
|
||||
Comment?(comment: unknown, helpers: unknown): void | Promise<void>;
|
||||
CommentExit?(comment: unknown, helpers: unknown): void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Processor {
|
||||
process(css: string, options?: ProcessOptions): Promise<Result>;
|
||||
}
|
||||
|
||||
function postcss(plugins?: Array<Plugin | ((options?: unknown) => Plugin)>): Processor;
|
||||
|
||||
export default postcss;
|
||||
}
|
||||
|
||||
declare module 'postcss-modules' {
|
||||
interface PostcssModulesOptions {
|
||||
localsConvention?:
|
||||
| 'camelCase'
|
||||
| 'camelCaseOnly'
|
||||
| 'dashes'
|
||||
| 'dashesOnly'
|
||||
| ((originalClassName: string, generatedClassName: string, inputFile: string) => string);
|
||||
generateScopedName?: string | ((name: string, filename: string, css: string) => string);
|
||||
getJSON?(cssFileName: string, json: Record<string, string>, outputFileName?: string): void;
|
||||
hashPrefix?: string;
|
||||
scopeBehaviour?: 'global' | 'local';
|
||||
globalModulePaths?: Array<RegExp>;
|
||||
root?: string;
|
||||
}
|
||||
|
||||
function postcssModules(options?: PostcssModulesOptions): import('postcss').Plugin;
|
||||
|
||||
export default postcssModules;
|
||||
}
|
||||
75
fluxer_app/scripts/build/utils/assets.ts
Normal file
75
fluxer_app/scripts/build/utils/assets.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, DIST_DIR, PKGS_DIR, PUBLIC_DIR} from '../config';
|
||||
|
||||
export async function copyPublicAssets(): Promise<void> {
|
||||
if (!fs.existsSync(PUBLIC_DIR)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await fs.promises.readdir(PUBLIC_DIR, {recursive: true});
|
||||
for (const file of files) {
|
||||
const srcPath = path.join(PUBLIC_DIR, file.toString());
|
||||
const destPath = path.join(DIST_DIR, file.toString());
|
||||
|
||||
const stat = await fs.promises.stat(srcPath);
|
||||
if (stat.isFile()) {
|
||||
await fs.promises.mkdir(path.dirname(destPath), {recursive: true});
|
||||
await fs.promises.copyFile(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function copyWasmFiles(): Promise<void> {
|
||||
const libfluxcoreDir = path.join(PKGS_DIR, 'libfluxcore');
|
||||
const wasmFile = path.join(libfluxcoreDir, 'libfluxcore_bg.wasm');
|
||||
|
||||
if (fs.existsSync(wasmFile)) {
|
||||
await fs.promises.copyFile(wasmFile, path.join(ASSETS_DIR, 'libfluxcore_bg.wasm'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUnusedCssAssets(assetsDir: string, keepFiles: Array<string>): Promise<void> {
|
||||
if (!fs.existsSync(assetsDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keepNames = new Set<string>();
|
||||
for (const file of keepFiles) {
|
||||
const base = path.basename(file);
|
||||
keepNames.add(base);
|
||||
if (base.endsWith('.css')) {
|
||||
keepNames.add(`${base}.map`);
|
||||
}
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(assetsDir);
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.css') && !entry.endsWith('.css.map')) {
|
||||
continue;
|
||||
}
|
||||
if (keepNames.has(entry)) {
|
||||
continue;
|
||||
}
|
||||
await fs.promises.rm(path.join(assetsDir, entry), {force: true});
|
||||
}
|
||||
}
|
||||
147
fluxer_app/scripts/build/utils/css-dts.ts
Normal file
147
fluxer_app/scripts/build/utils/css-dts.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import postcss from 'postcss';
|
||||
import postcssModules from 'postcss-modules';
|
||||
import {PKGS_DIR, SRC_DIR} from '../config';
|
||||
|
||||
const RESERVED_KEYWORDS = new Set([
|
||||
'break',
|
||||
'case',
|
||||
'catch',
|
||||
'continue',
|
||||
'debugger',
|
||||
'default',
|
||||
'delete',
|
||||
'do',
|
||||
'else',
|
||||
'export',
|
||||
'extends',
|
||||
'finally',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'import',
|
||||
'in',
|
||||
'instanceof',
|
||||
'new',
|
||||
'return',
|
||||
'super',
|
||||
'switch',
|
||||
'this',
|
||||
'throw',
|
||||
'try',
|
||||
'typeof',
|
||||
'var',
|
||||
'void',
|
||||
'while',
|
||||
'with',
|
||||
'yield',
|
||||
'enum',
|
||||
'implements',
|
||||
'interface',
|
||||
'let',
|
||||
'package',
|
||||
'private',
|
||||
'protected',
|
||||
'public',
|
||||
'static',
|
||||
'await',
|
||||
'class',
|
||||
'const',
|
||||
]);
|
||||
|
||||
function isValidIdentifier(name: string): boolean {
|
||||
if (RESERVED_KEYWORDS.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name);
|
||||
}
|
||||
|
||||
function generateDtsContent(classNames: Record<string, string>): string {
|
||||
const validClassNames = Object.keys(classNames).filter(isValidIdentifier);
|
||||
const typeMembers = validClassNames.map((name) => `\treadonly ${name}: string;`).join('\n');
|
||||
const defaultExportType =
|
||||
validClassNames.length > 0 ? `{\n${typeMembers}\n\treadonly [key: string]: string;\n}` : 'Record<string, string>';
|
||||
|
||||
return `declare const styles: ${defaultExportType};\nexport default styles;\n`;
|
||||
}
|
||||
|
||||
async function findCssModuleFiles(dir: string): Promise<Array<string>> {
|
||||
const files: Array<string> = [];
|
||||
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
const entries = await fs.promises.readdir(currentDir, {withFileTypes: true});
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules' && entry.name !== 'dist' && entry.name !== '.git') {
|
||||
await walk(fullPath);
|
||||
}
|
||||
} else if (entry.name.endsWith('.module.css')) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dir);
|
||||
return files;
|
||||
}
|
||||
|
||||
async function generateDtsForFile(cssPath: string): Promise<void> {
|
||||
const cssContent = await fs.promises.readFile(cssPath, 'utf-8');
|
||||
let exportedClassNames: Record<string, string> = {};
|
||||
|
||||
await postcss([
|
||||
postcssModules({
|
||||
localsConvention: 'camelCaseOnly',
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
getJSON(_cssFileName: string, json: Record<string, string>) {
|
||||
exportedClassNames = json;
|
||||
},
|
||||
}),
|
||||
]).process(cssContent, {from: cssPath});
|
||||
|
||||
const dtsPath = `${cssPath}.d.ts`;
|
||||
const dtsContent = generateDtsContent(exportedClassNames);
|
||||
await fs.promises.writeFile(dtsPath, dtsContent);
|
||||
}
|
||||
|
||||
export async function generateCssDtsForFile(cssPath: string): Promise<void> {
|
||||
if (!cssPath.endsWith('.module.css')) {
|
||||
return;
|
||||
}
|
||||
await generateDtsForFile(cssPath);
|
||||
}
|
||||
|
||||
export async function generateAllCssDts(): Promise<void> {
|
||||
const srcFiles = await findCssModuleFiles(SRC_DIR);
|
||||
const pkgsFiles = await findCssModuleFiles(PKGS_DIR);
|
||||
const allFiles = [...srcFiles, ...pkgsFiles];
|
||||
|
||||
console.log(`Generating .d.ts files for ${allFiles.length} CSS modules...`);
|
||||
|
||||
await Promise.all(allFiles.map(generateDtsForFile));
|
||||
|
||||
console.log(`Generated ${allFiles.length} CSS module type definitions.`);
|
||||
}
|
||||
94
fluxer_app/scripts/build/utils/html.ts
Normal file
94
fluxer_app/scripts/build/utils/html.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {ASSETS_DIR, CDN_ENDPOINT, ROOT_DIR} from '../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;
|
||||
}
|
||||
25
fluxer_app/scripts/build/utils/index.ts
Normal file
25
fluxer_app/scripts/build/utils/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export {copyPublicAssets, copyWasmFiles, removeUnusedCssAssets} from './assets';
|
||||
export {generateAllCssDts, generateCssDtsForFile} from './css-dts';
|
||||
export {generateHtml} from './html';
|
||||
export {tryResolveWithExtensions} from './resolve';
|
||||
export {buildServiceWorker} from './service-worker';
|
||||
export {cleanEmptySourceMaps} from './sourcemaps';
|
||||
48
fluxer_app/scripts/build/utils/resolve.ts
Normal file
48
fluxer_app/scripts/build/utils/resolve.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import {RESOLVE_EXTENSIONS} from '../config';
|
||||
|
||||
export function tryResolveWithExtensions(basePath: string): string | null {
|
||||
if (fs.existsSync(basePath)) {
|
||||
const stat = fs.statSync(basePath);
|
||||
if (stat.isFile()) {
|
||||
return basePath;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const indexPath = path.join(basePath, `index${ext}`);
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return indexPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const ext of RESOLVE_EXTENSIONS) {
|
||||
const withExt = `${basePath}${ext}`;
|
||||
if (fs.existsSync(withExt)) {
|
||||
return withExt;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
37
fluxer_app/scripts/build/utils/service-worker.ts
Normal file
37
fluxer_app/scripts/build/utils/service-worker.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as path from 'node:path';
|
||||
import * as esbuild from 'esbuild';
|
||||
import {DIST_DIR, SRC_DIR} from '../config';
|
||||
|
||||
export async function buildServiceWorker(production: boolean): Promise<void> {
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(SRC_DIR, 'sw', 'worker.ts')],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
outfile: path.join(DIST_DIR, 'sw.js'),
|
||||
minify: production,
|
||||
sourcemap: true,
|
||||
target: 'esnext',
|
||||
define: {
|
||||
__WB_MANIFEST: '[]',
|
||||
},
|
||||
});
|
||||
}
|
||||
86
fluxer_app/scripts/build/utils/sourcemaps.ts
Normal file
86
fluxer_app/scripts/build/utils/sourcemaps.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.promises.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function traverseDir(dir: string, callback: (filePath: string) => Promise<void>): Promise<void> {
|
||||
const entries = await fs.promises.readdir(dir, {withFileTypes: true});
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await traverseDir(entryPath, callback);
|
||||
return;
|
||||
}
|
||||
await callback(entryPath);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanEmptySourceMaps(dir: string): Promise<void> {
|
||||
if (!(await fileExists(dir))) {
|
||||
return;
|
||||
}
|
||||
|
||||
await traverseDir(dir, async (filePath) => {
|
||||
if (!filePath.endsWith('.js.map')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf-8');
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = (parsed as {sources?: Array<unknown>}).sources ?? [];
|
||||
if (Array.isArray(sources) && sources.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.promises.rm(filePath, {force: true});
|
||||
|
||||
const jsPath = filePath.slice(0, -4);
|
||||
if (!(await fileExists(jsPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jsContent = await fs.promises.readFile(jsPath, 'utf-8');
|
||||
const cleaned = jsContent.replace(/(?:\r?\n)?\/\/# sourceMappingURL=.*$/, '');
|
||||
if (cleaned !== jsContent) {
|
||||
await fs.promises.writeFile(jsPath, cleaned);
|
||||
}
|
||||
});
|
||||
}
|
||||
290
fluxer_app/scripts/cmd/generate-color-system/main.go
Normal file
290
fluxer_app/scripts/cmd/generate-color-system/main.go
Normal file
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type ColorFamily struct {
|
||||
Hue int `yaml:"hue" json:"hue"`
|
||||
Saturation int `yaml:"saturation" json:"saturation"`
|
||||
UseSaturationFactor bool `yaml:"useSaturationFactor" json:"useSaturationFactor"`
|
||||
}
|
||||
|
||||
type ScaleStop struct {
|
||||
Name string `yaml:"name"`
|
||||
Position *float64 `yaml:"position"`
|
||||
}
|
||||
|
||||
type Scale struct {
|
||||
Family string `yaml:"family"`
|
||||
Range [2]float64 `yaml:"range"`
|
||||
Curve string `yaml:"curve"`
|
||||
Stops []ScaleStop `yaml:"stops"`
|
||||
}
|
||||
|
||||
type TokenDef struct {
|
||||
Name string `yaml:"name,omitempty"`
|
||||
Scale string `yaml:"scale,omitempty"`
|
||||
Value string `yaml:"value,omitempty"`
|
||||
Family string `yaml:"family,omitempty"`
|
||||
Hue *int `yaml:"hue,omitempty"`
|
||||
Saturation *int `yaml:"saturation,omitempty"`
|
||||
Lightness *float64 `yaml:"lightness,omitempty"`
|
||||
Alpha *float64 `yaml:"alpha,omitempty"`
|
||||
UseSaturationFactor *bool `yaml:"useSaturationFactor,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Families map[string]ColorFamily `yaml:"families"`
|
||||
Scales map[string]Scale `yaml:"scales"`
|
||||
Tokens struct {
|
||||
Root []TokenDef `yaml:"root"`
|
||||
Light []TokenDef `yaml:"light"`
|
||||
Coal []TokenDef `yaml:"coal"`
|
||||
} `yaml:"tokens"`
|
||||
}
|
||||
|
||||
type OutputToken struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Family string `json:"family,omitempty"`
|
||||
Hue *int `json:"hue,omitempty"`
|
||||
Saturation *int `json:"saturation,omitempty"`
|
||||
Lightness *float64 `json:"lightness,omitempty"`
|
||||
Alpha *float64 `json:"alpha,omitempty"`
|
||||
UseSaturationFactor *bool `json:"useSaturationFactor,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
func clamp01(value float64) float64 {
|
||||
return math.Min(1, math.Max(0, value))
|
||||
}
|
||||
|
||||
func applyCurve(curve string, t float64) float64 {
|
||||
switch curve {
|
||||
case "easeIn":
|
||||
return t * t
|
||||
case "easeOut":
|
||||
return 1 - (1-t)*(1-t)
|
||||
case "easeInOut":
|
||||
if t < 0.5 {
|
||||
return 2 * t * t
|
||||
}
|
||||
return 1 - 2*(1-t)*(1-t)
|
||||
default:
|
||||
return t
|
||||
}
|
||||
}
|
||||
|
||||
func buildScaleTokens(scale Scale) []OutputToken {
|
||||
lastIndex := float64(max(len(scale.Stops)-1, 1))
|
||||
tokens := make([]OutputToken, 0, len(scale.Stops))
|
||||
|
||||
for i, stop := range scale.Stops {
|
||||
pos := 0.0
|
||||
if stop.Position != nil {
|
||||
pos = clamp01(*stop.Position)
|
||||
} else {
|
||||
pos = float64(i) / lastIndex
|
||||
}
|
||||
|
||||
eased := applyCurve(scale.Curve, pos)
|
||||
lightness := scale.Range[0] + (scale.Range[1]-scale.Range[0])*eased
|
||||
lightness = math.Round(lightness*1000) / 1000
|
||||
|
||||
tokens = append(tokens, OutputToken{
|
||||
Type: "tone",
|
||||
Name: stop.Name,
|
||||
Family: scale.Family,
|
||||
Lightness: &lightness,
|
||||
})
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func expandTokens(defs []TokenDef, scales map[string]Scale) []OutputToken {
|
||||
var tokens []OutputToken
|
||||
|
||||
for _, def := range defs {
|
||||
if def.Scale != "" {
|
||||
scale, ok := scales[def.Scale]
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "Warning: unknown scale %q\n", def.Scale)
|
||||
continue
|
||||
}
|
||||
tokens = append(tokens, buildScaleTokens(scale)...)
|
||||
continue
|
||||
}
|
||||
|
||||
if def.Value != "" {
|
||||
tokens = append(tokens, OutputToken{
|
||||
Type: "literal",
|
||||
Name: def.Name,
|
||||
Value: strings.TrimSpace(def.Value),
|
||||
})
|
||||
} else {
|
||||
tokens = append(tokens, OutputToken{
|
||||
Type: "tone",
|
||||
Name: def.Name,
|
||||
Family: def.Family,
|
||||
Hue: def.Hue,
|
||||
Saturation: def.Saturation,
|
||||
Lightness: def.Lightness,
|
||||
Alpha: def.Alpha,
|
||||
UseSaturationFactor: def.UseSaturationFactor,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
func formatNumber(value float64) string {
|
||||
if value == float64(int(value)) {
|
||||
return fmt.Sprintf("%d", int(value))
|
||||
}
|
||||
s := fmt.Sprintf("%.2f", value)
|
||||
s = strings.TrimRight(s, "0")
|
||||
s = strings.TrimRight(s, ".")
|
||||
return s
|
||||
}
|
||||
|
||||
func formatTone(token OutputToken, families map[string]ColorFamily) string {
|
||||
var family *ColorFamily
|
||||
if token.Family != "" {
|
||||
if f, ok := families[token.Family]; ok {
|
||||
family = &f
|
||||
}
|
||||
}
|
||||
|
||||
var hue, saturation int
|
||||
var lightness float64
|
||||
var useFactor bool
|
||||
|
||||
if token.Hue != nil {
|
||||
hue = *token.Hue
|
||||
} else if family != nil {
|
||||
hue = family.Hue
|
||||
}
|
||||
|
||||
if token.Saturation != nil {
|
||||
saturation = *token.Saturation
|
||||
} else if family != nil {
|
||||
saturation = family.Saturation
|
||||
}
|
||||
|
||||
if token.Lightness != nil {
|
||||
lightness = *token.Lightness
|
||||
}
|
||||
|
||||
if token.UseSaturationFactor != nil {
|
||||
useFactor = *token.UseSaturationFactor
|
||||
} else if family != nil {
|
||||
useFactor = family.UseSaturationFactor
|
||||
}
|
||||
|
||||
var satStr string
|
||||
if useFactor {
|
||||
satStr = fmt.Sprintf("calc(%s%% * var(--saturation-factor))", formatNumber(float64(saturation)))
|
||||
} else {
|
||||
satStr = fmt.Sprintf("%s%%", formatNumber(float64(saturation)))
|
||||
}
|
||||
|
||||
if token.Alpha == nil {
|
||||
return fmt.Sprintf("hsl(%s, %s, %s%%)", formatNumber(float64(hue)), satStr, formatNumber(lightness))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("hsla(%s, %s, %s%%, %s)", formatNumber(float64(hue)), satStr, formatNumber(lightness), formatNumber(*token.Alpha))
|
||||
}
|
||||
|
||||
func formatValue(token OutputToken, families map[string]ColorFamily) string {
|
||||
if token.Type == "tone" {
|
||||
return formatTone(token, families)
|
||||
}
|
||||
return strings.TrimSpace(token.Value)
|
||||
}
|
||||
|
||||
func renderBlock(selector string, tokens []OutputToken, families map[string]ColorFamily) string {
|
||||
var lines []string
|
||||
for _, token := range tokens {
|
||||
lines = append(lines, fmt.Sprintf("\t%s: %s;", token.Name, formatValue(token, families)))
|
||||
}
|
||||
return fmt.Sprintf("%s {\n%s\n}", selector, strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
func generateCSS(cfg *Config, rootTokens, lightTokens, coalTokens []OutputToken) string {
|
||||
header := `/*
|
||||
* This file is auto-generated by scripts/cmd/generate-color-system.
|
||||
* Do not edit directly — update color-system.yaml instead.
|
||||
*/`
|
||||
|
||||
blocks := []string{
|
||||
renderBlock(":root", rootTokens, cfg.Families),
|
||||
renderBlock(".theme-light", lightTokens, cfg.Families),
|
||||
renderBlock(".theme-coal", coalTokens, cfg.Families),
|
||||
}
|
||||
|
||||
return header + "\n\n" + strings.Join(blocks, "\n\n") + "\n"
|
||||
}
|
||||
|
||||
func main() {
|
||||
cwd, _ := os.Getwd()
|
||||
configPath := filepath.Join(cwd, "color-system.yaml")
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error reading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error parsing config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rootTokens := expandTokens(cfg.Tokens.Root, cfg.Scales)
|
||||
lightTokens := expandTokens(cfg.Tokens.Light, cfg.Scales)
|
||||
coalTokens := expandTokens(cfg.Tokens.Coal, cfg.Scales)
|
||||
|
||||
parentDir := filepath.Join(cwd, "..")
|
||||
cssPath := filepath.Join(parentDir, "src", "styles", "generated", "color-system.css")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(cssPath), 0755); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error creating CSS directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
css := generateCSS(&cfg, rootTokens, lightTokens, coalTokens)
|
||||
if err := os.WriteFile(cssPath, []byte(css), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing CSS file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
relCSS, _ := filepath.Rel(parentDir, cssPath)
|
||||
fmt.Printf("Wrote %s\n", relCSS)
|
||||
}
|
||||
437
fluxer_app/scripts/cmd/generate-emoji-sprites/main.go
Normal file
437
fluxer_app/scripts/cmd/generate-emoji-sprites/main.go
Normal file
@@ -0,0 +1,437 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
type EmojiSpritesConfig struct {
|
||||
NonDiversityPerRow int
|
||||
DiversityPerRow int
|
||||
PickerPerRow int
|
||||
PickerCount int
|
||||
}
|
||||
|
||||
var EMOJI_SPRITES = EmojiSpritesConfig{
|
||||
NonDiversityPerRow: 42,
|
||||
DiversityPerRow: 10,
|
||||
PickerPerRow: 11,
|
||||
PickerCount: 50,
|
||||
}
|
||||
|
||||
const (
|
||||
EMOJI_SIZE = 32
|
||||
TWEMOJI_CDN = "https://fluxerstatic.com/emoji"
|
||||
)
|
||||
|
||||
var SPRITE_SCALES = []int{1, 2}
|
||||
|
||||
type EmojiObject struct {
|
||||
Surrogates string `json:"surrogates"`
|
||||
Skins []struct {
|
||||
Surrogates string `json:"surrogates"`
|
||||
} `json:"skins,omitempty"`
|
||||
}
|
||||
|
||||
type EmojiEntry struct {
|
||||
Surrogates string
|
||||
}
|
||||
|
||||
type httpResp struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func main() {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
appDir := filepath.Join(cwd, "..")
|
||||
|
||||
outputDir := filepath.Join(appDir, "src", "assets", "emoji-sprites")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Failed to ensure output dir:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
emojiData, err := loadEmojiData(filepath.Join(appDir, "src", "data", "emojis.json"))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error loading emoji data:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
svgCache := newSVGCache()
|
||||
|
||||
if err := generateMainSpriteSheet(client, svgCache, emojiData, outputDir); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error generating main sprite sheet:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := generateDiversitySpriteSheets(client, svgCache, emojiData, outputDir); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error generating diversity sprite sheets:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := generatePickerSpriteSheet(client, svgCache, outputDir); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error generating picker sprite sheet:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Emoji sprites generated successfully.")
|
||||
}
|
||||
|
||||
func loadEmojiData(path string) (map[string][]EmojiObject, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data map[string][]EmojiObject
|
||||
if err := json.Unmarshal(b, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// --- SVG fetching + caching ---
|
||||
|
||||
type svgCache struct {
|
||||
m map[string]*string
|
||||
}
|
||||
|
||||
func newSVGCache() *svgCache {
|
||||
return &svgCache{m: make(map[string]*string)}
|
||||
}
|
||||
|
||||
func (c *svgCache) get(codepoint string) (*string, bool) {
|
||||
v, ok := c.m[codepoint]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (c *svgCache) set(codepoint string, v *string) {
|
||||
c.m[codepoint] = v
|
||||
}
|
||||
|
||||
func downloadSVG(client *http.Client, url string) (httpResp, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return httpResp{}, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "fluxer-emoji-sprites/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return httpResp{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return httpResp{}, err
|
||||
}
|
||||
|
||||
return httpResp{
|
||||
Status: resp.StatusCode,
|
||||
Body: string(bodyBytes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchTwemojiSVG(client *http.Client, cache *svgCache, codepoint string) *string {
|
||||
if v, ok := cache.get(codepoint); ok {
|
||||
return v
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/%s.svg", TWEMOJI_CDN, codepoint)
|
||||
r, err := downloadSVG(client, url)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to fetch Twemoji %s: %v\n", codepoint, err)
|
||||
cache.set(codepoint, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.Status != 200 {
|
||||
fmt.Fprintf(os.Stderr, "Twemoji %s returned %d\n", codepoint, r.Status)
|
||||
cache.set(codepoint, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
body := r.Body
|
||||
cache.set(codepoint, &body)
|
||||
return &body
|
||||
}
|
||||
|
||||
// --- Emoji -> codepoint ---
|
||||
|
||||
func emojiToCodepoint(s string) string {
|
||||
parts := make([]string, 0, len(s))
|
||||
for _, r := range s {
|
||||
if r == 0xFE0F {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, strings.ToLower(strconv.FormatInt(int64(r), 16)))
|
||||
}
|
||||
return strings.Join(parts, "-")
|
||||
}
|
||||
|
||||
// --- Rendering ---
|
||||
|
||||
var svgOpenTagRe = regexp.MustCompile(`(?i)<svg([^>]*)>`)
|
||||
|
||||
func fixSVGSize(svg string, size int) string {
|
||||
return svgOpenTagRe.ReplaceAllString(svg, fmt.Sprintf(`<svg$1 width="%d" height="%d">`, size, size))
|
||||
}
|
||||
|
||||
func renderSVGToImage(svgContent string, size int) (*image.RGBA, error) {
|
||||
fixed := fixSVGSize(svgContent, size)
|
||||
|
||||
icon, err := oksvg.ReadIconStream(strings.NewReader(fixed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
icon.SetTarget(0, 0, float64(size), float64(size))
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
scanner := rasterx.NewScannerGV(size, size, dst, dst.Bounds())
|
||||
r := rasterx.NewDasher(size, size, scanner)
|
||||
icon.Draw(r, 1.0)
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
func createPlaceholder(size int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, size, size))
|
||||
|
||||
h := rand.Float64() * 360.0
|
||||
r, g, b := hslToRGB(h, 0.70, 0.60)
|
||||
|
||||
cx := float64(size) / 2.0
|
||||
cy := float64(size) / 2.0
|
||||
radius := float64(size) * 0.4
|
||||
r2 := radius * radius
|
||||
|
||||
for y := 0; y < size; y++ {
|
||||
for x := 0; x < size; x++ {
|
||||
dx := (float64(x) + 0.5) - cx
|
||||
dy := (float64(y) + 0.5) - cy
|
||||
if dx*dx+dy*dy <= r2 {
|
||||
i := img.PixOffset(x, y)
|
||||
img.Pix[i+0] = r
|
||||
img.Pix[i+1] = g
|
||||
img.Pix[i+2] = b
|
||||
img.Pix[i+3] = 0xFF
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func hslToRGB(h, s, l float64) (uint8, uint8, uint8) {
|
||||
h = math.Mod(h, 360.0) / 360.0
|
||||
|
||||
var r, g, b float64
|
||||
if s == 0 {
|
||||
r, g, b = l, l, l
|
||||
} else {
|
||||
var q float64
|
||||
if l < 0.5 {
|
||||
q = l * (1 + s)
|
||||
} else {
|
||||
q = l + s - l*s
|
||||
}
|
||||
p := 2*l - q
|
||||
r = hueToRGB(p, q, h+1.0/3.0)
|
||||
g = hueToRGB(p, q, h)
|
||||
b = hueToRGB(p, q, h-1.0/3.0)
|
||||
}
|
||||
|
||||
return uint8(clamp01(r) * 255), uint8(clamp01(g) * 255), uint8(clamp01(b) * 255)
|
||||
}
|
||||
|
||||
func hueToRGB(p, q, t float64) float64 {
|
||||
if t < 0 {
|
||||
t += 1
|
||||
}
|
||||
if t > 1 {
|
||||
t -= 1
|
||||
}
|
||||
if t < 1.0/6.0 {
|
||||
return p + (q-p)*6*t
|
||||
}
|
||||
if t < 1.0/2.0 {
|
||||
return q
|
||||
}
|
||||
if t < 2.0/3.0 {
|
||||
return p + (q-p)*(2.0/3.0-t)*6
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func loadEmojiImage(client *http.Client, cache *svgCache, surrogate string, size int) *image.RGBA {
|
||||
codepoint := emojiToCodepoint(surrogate)
|
||||
|
||||
if svg := fetchTwemojiSVG(client, cache, codepoint); svg != nil {
|
||||
if img, err := renderSVGToImage(*svg, size); err == nil {
|
||||
return img
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(codepoint, "-200d-") {
|
||||
basePart := strings.Split(codepoint, "-200d-")[0]
|
||||
if svg := fetchTwemojiSVG(client, cache, basePart); svg != nil {
|
||||
if img, err := renderSVGToImage(*svg, size); err == nil {
|
||||
return img
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "Missing SVG for %s (%s), using placeholder\n", codepoint, surrogate)
|
||||
return createPlaceholder(size)
|
||||
}
|
||||
|
||||
func renderSpriteSheet(client *http.Client, cache *svgCache, emojiEntries []EmojiEntry, perRow int, fileNameBase string, outputDir string) error {
|
||||
if perRow <= 0 {
|
||||
return fmt.Errorf("perRow must be > 0")
|
||||
}
|
||||
rows := int(math.Ceil(float64(len(emojiEntries)) / float64(perRow)))
|
||||
|
||||
for _, scale := range SPRITE_SCALES {
|
||||
size := EMOJI_SIZE * scale
|
||||
dstW := perRow * size
|
||||
dstH := rows * size
|
||||
|
||||
sheet := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
|
||||
for i, item := range emojiEntries {
|
||||
emojiImg := loadEmojiImage(client, cache, item.Surrogates, size)
|
||||
row := i / perRow
|
||||
col := i % perRow
|
||||
x := col * size
|
||||
y := row * size
|
||||
|
||||
r := image.Rect(x, y, x+size, y+size)
|
||||
draw.Draw(sheet, r, emojiImg, image.Point{}, draw.Over)
|
||||
}
|
||||
|
||||
suffix := ""
|
||||
if scale != 1 {
|
||||
suffix = fmt.Sprintf("@%dx", scale)
|
||||
}
|
||||
outPath := filepath.Join(outputDir, fmt.Sprintf("%s%s.png", fileNameBase, suffix))
|
||||
if err := writePNG(outPath, sheet); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Wrote %s\n", outPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePNG(path string, img image.Image) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, buf.Bytes(), 0o644)
|
||||
}
|
||||
|
||||
// --- Generators ---
|
||||
|
||||
func generateMainSpriteSheet(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
|
||||
base := make([]EmojiEntry, 0, 4096)
|
||||
for _, objs := range emojiData {
|
||||
for _, obj := range objs {
|
||||
base = append(base, EmojiEntry{Surrogates: obj.Surrogates})
|
||||
}
|
||||
}
|
||||
return renderSpriteSheet(client, cache, base, EMOJI_SPRITES.NonDiversityPerRow, "spritesheet-emoji", outputDir)
|
||||
}
|
||||
|
||||
func generateDiversitySpriteSheets(client *http.Client, cache *svgCache, emojiData map[string][]EmojiObject, outputDir string) error {
|
||||
skinTones := []string{"🏻", "🏼", "🏽", "🏾", "🏿"}
|
||||
|
||||
for skinIndex, skinTone := range skinTones {
|
||||
skinCodepoint := emojiToCodepoint(skinTone)
|
||||
|
||||
skinEntries := make([]EmojiEntry, 0, 2048)
|
||||
for _, objs := range emojiData {
|
||||
for _, obj := range objs {
|
||||
if len(obj.Skins) > skinIndex && obj.Skins[skinIndex].Surrogates != "" {
|
||||
skinEntries = append(skinEntries, EmojiEntry{Surrogates: obj.Skins[skinIndex].Surrogates})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(skinEntries) == 0 {
|
||||
continue
|
||||
}
|
||||
if err := renderSpriteSheet(client, cache, skinEntries, EMOJI_SPRITES.DiversityPerRow, "spritesheet-"+skinCodepoint, outputDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatePickerSpriteSheet(client *http.Client, cache *svgCache, outputDir string) error {
|
||||
basicEmojis := []string{
|
||||
"😀", "😃", "😄", "😁", "😆", "😅", "😂", "🤣", "😊", "😇",
|
||||
"🙂", "😉", "😌", "😍", "🥰", "😘", "😗", "😙", "😚", "😋",
|
||||
"😛", "😝", "😜", "🤪", "🤨", "🧐", "🤓", "😎", "🥳", "😏",
|
||||
}
|
||||
|
||||
entries := make([]EmojiEntry, 0, len(basicEmojis))
|
||||
for _, e := range basicEmojis {
|
||||
entries = append(entries, EmojiEntry{Surrogates: e})
|
||||
}
|
||||
|
||||
return renderSpriteSheet(client, cache, entries, EMOJI_SPRITES.PickerPerRow, "spritesheet-picker", outputDir)
|
||||
}
|
||||
2129
fluxer_app/scripts/cmd/locales-pending/main.go
Normal file
2129
fluxer_app/scripts/cmd/locales-pending/main.go
Normal file
File diff suppressed because it is too large
Load Diff
279
fluxer_app/scripts/cmd/locales-pending/reset/main.go
Normal file
279
fluxer_app/scripts/cmd/locales-pending/reset/main.go
Normal file
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type POFile struct {
|
||||
HeaderLines []string
|
||||
Entries []POEntry
|
||||
}
|
||||
|
||||
type POEntry struct {
|
||||
Comments []string
|
||||
References []string
|
||||
MsgID string
|
||||
MsgStr string
|
||||
}
|
||||
|
||||
func main() {
|
||||
localesDir := flag.String("locales-dir", "../../../../src/locales", "Path to the locales directory")
|
||||
singleLocale := flag.String("locale", "", "Reset only this locale (empty = all)")
|
||||
dryRun := flag.Bool("dry-run", false, "Show what would be reset without making changes")
|
||||
flag.Parse()
|
||||
|
||||
absLocalesDir, err := absPath(*localesDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to resolve locales directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
locales, err := discoverLocales(absLocalesDir)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to discover locales: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var targetLocales []string
|
||||
for _, locale := range locales {
|
||||
if locale == "en-US" {
|
||||
continue
|
||||
}
|
||||
if *singleLocale != "" && locale != *singleLocale {
|
||||
continue
|
||||
}
|
||||
targetLocales = append(targetLocales, locale)
|
||||
}
|
||||
|
||||
if len(targetLocales) == 0 {
|
||||
fmt.Println("No target locales found")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Resetting translations for %d locales...\n", len(targetLocales))
|
||||
if *dryRun {
|
||||
fmt.Println("(DRY RUN - no changes will be made)")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
totalReset := 0
|
||||
for _, locale := range targetLocales {
|
||||
poPath := filepath.Join(absLocalesDir, locale, "messages.po")
|
||||
poFile, err := parsePOFile(poPath)
|
||||
if err != nil {
|
||||
fmt.Printf(" ✗ %s: failed to parse: %v\n", locale, err)
|
||||
continue
|
||||
}
|
||||
|
||||
resetCount := 0
|
||||
for i := range poFile.Entries {
|
||||
if poFile.Entries[i].MsgStr != "" {
|
||||
resetCount++
|
||||
poFile.Entries[i].MsgStr = ""
|
||||
}
|
||||
}
|
||||
|
||||
if resetCount == 0 {
|
||||
fmt.Printf(" - %s: already empty (0 strings)\n", locale)
|
||||
continue
|
||||
}
|
||||
|
||||
if !*dryRun {
|
||||
if err := writePOFile(poPath, poFile); err != nil {
|
||||
fmt.Printf(" ✗ %s: failed to write: %v\n", locale, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" ✓ %s: reset %d strings\n", locale, resetCount)
|
||||
totalReset += resetCount
|
||||
}
|
||||
|
||||
fmt.Printf("\nTotal: reset %d translations across %d locales\n", totalReset, len(targetLocales))
|
||||
if *dryRun {
|
||||
fmt.Println("(DRY RUN - run without --dry-run to apply changes)")
|
||||
}
|
||||
}
|
||||
|
||||
func absPath(rel string) (string, error) {
|
||||
if filepath.IsAbs(rel) {
|
||||
return rel, nil
|
||||
}
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(wd, rel), nil
|
||||
}
|
||||
|
||||
func discoverLocales(localesDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(localesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var locales []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
locales = append(locales, entry.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(locales)
|
||||
return locales, nil
|
||||
}
|
||||
|
||||
func parsePOFile(path string) (POFile, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return POFile{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var (
|
||||
current []string
|
||||
scanner = bufio.NewScanner(file)
|
||||
trimmed string
|
||||
headerSet bool
|
||||
result POFile
|
||||
)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
trimmed = strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
if len(current) > 0 {
|
||||
entry := parseBlock(current)
|
||||
if !headerSet && entry.MsgID == "" {
|
||||
result.HeaderLines = append([]string{}, current...)
|
||||
headerSet = true
|
||||
} else {
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
current = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
current = append(current, line)
|
||||
}
|
||||
if len(current) > 0 {
|
||||
entry := parseBlock(current)
|
||||
if !headerSet && entry.MsgID == "" {
|
||||
result.HeaderLines = append([]string{}, current...)
|
||||
} else {
|
||||
result.Entries = append(result.Entries, entry)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return POFile{}, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseBlock(lines []string) POEntry {
|
||||
entry := POEntry{}
|
||||
var (
|
||||
inMsgID bool
|
||||
inMsgStr bool
|
||||
)
|
||||
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "#."):
|
||||
entry.Comments = append(entry.Comments, strings.TrimSpace(line[2:]))
|
||||
case strings.HasPrefix(line, "#:"):
|
||||
entry.References = append(entry.References, strings.TrimSpace(line[2:]))
|
||||
case strings.HasPrefix(line, "msgid"):
|
||||
entry.MsgID = parseQuoted(strings.TrimSpace(line[len("msgid"):]))
|
||||
inMsgID = true
|
||||
inMsgStr = false
|
||||
case strings.HasPrefix(line, "msgstr"):
|
||||
entry.MsgStr = parseQuoted(strings.TrimSpace(line[len("msgstr"):]))
|
||||
inMsgStr = true
|
||||
inMsgID = false
|
||||
case strings.HasPrefix(line, "\""):
|
||||
if inMsgID {
|
||||
entry.MsgID += parseQuoted(line)
|
||||
} else if inMsgStr {
|
||||
entry.MsgStr += parseQuoted(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func parseQuoted(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||
return unquoted
|
||||
}
|
||||
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
|
||||
return value[1 : len(value)-1]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func writePOFile(path string, po POFile) error {
|
||||
var lines []string
|
||||
if len(po.HeaderLines) > 0 {
|
||||
lines = append(lines, po.HeaderLines...)
|
||||
lines = append(lines, "")
|
||||
}
|
||||
|
||||
for idx, entry := range po.Entries {
|
||||
lines = append(lines, renderEntry(entry))
|
||||
if idx < len(po.Entries)-1 {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
lines = append(lines, "")
|
||||
return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644)
|
||||
}
|
||||
|
||||
func renderEntry(entry POEntry) string {
|
||||
var sb strings.Builder
|
||||
for _, comment := range entry.Comments {
|
||||
sb.WriteString("#. ")
|
||||
sb.WriteString(comment)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
for _, ref := range entry.References {
|
||||
sb.WriteString("#: ")
|
||||
sb.WriteString(ref)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("msgid ")
|
||||
sb.WriteString(strconv.Quote(entry.MsgID))
|
||||
sb.WriteString("\nmsgstr ")
|
||||
sb.WriteString(strconv.Quote(entry.MsgStr))
|
||||
return sb.String()
|
||||
}
|
||||
66
fluxer_app/scripts/cmd/set-build-channel/main.go
Normal file
66
fluxer_app/scripts/cmd/set-build-channel/main.go
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
)
|
||||
|
||||
var allowedChannels = []string{"stable", "canary"}
|
||||
|
||||
func parseChannel() string {
|
||||
raw := os.Getenv("BUILD_CHANNEL")
|
||||
if raw != "" && slices.Contains(allowedChannels, raw) {
|
||||
return raw
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
func main() {
|
||||
channel := parseChannel()
|
||||
|
||||
cwd, _ := os.Getwd()
|
||||
targetPath := filepath.Join(cwd, "..", "src-electron", "common", "build-channel.ts")
|
||||
|
||||
fileContent := fmt.Sprintf(`/*
|
||||
* This file is generated by scripts/cmd/set-build-channel.
|
||||
* DO NOT EDIT MANUALLY.
|
||||
*/
|
||||
|
||||
export type BuildChannel = 'stable' | 'canary';
|
||||
|
||||
const DEFAULT_BUILD_CHANNEL = '%s' as BuildChannel;
|
||||
|
||||
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
|
||||
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
|
||||
export const IS_CANARY = BUILD_CHANNEL === 'canary';
|
||||
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
|
||||
`, channel)
|
||||
|
||||
if err := os.WriteFile(targetPath, []byte(fileContent), 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Wrote %s with channel '%s'\n", targetPath, channel)
|
||||
}
|
||||
418
fluxer_app/scripts/color-system.yaml
Normal file
418
fluxer_app/scripts/color-system.yaml
Normal file
@@ -0,0 +1,418 @@
|
||||
|
||||
families:
|
||||
neutralDark:
|
||||
hue: 220
|
||||
saturation: 13
|
||||
useSaturationFactor: true
|
||||
|
||||
neutralLight:
|
||||
hue: 210
|
||||
saturation: 20
|
||||
useSaturationFactor: true
|
||||
|
||||
brand:
|
||||
hue: 242
|
||||
saturation: 70
|
||||
useSaturationFactor: true
|
||||
|
||||
link:
|
||||
hue: 210
|
||||
saturation: 100
|
||||
useSaturationFactor: true
|
||||
|
||||
accentPurple:
|
||||
hue: 270
|
||||
saturation: 80
|
||||
useSaturationFactor: true
|
||||
|
||||
statusOnline:
|
||||
hue: 142
|
||||
saturation: 76
|
||||
useSaturationFactor: true
|
||||
|
||||
statusIdle:
|
||||
hue: 45
|
||||
saturation: 93
|
||||
useSaturationFactor: true
|
||||
|
||||
statusDnd:
|
||||
hue: 0
|
||||
saturation: 84
|
||||
useSaturationFactor: true
|
||||
|
||||
statusOffline:
|
||||
hue: 218
|
||||
saturation: 11
|
||||
useSaturationFactor: true
|
||||
|
||||
statusDanger:
|
||||
hue: 1
|
||||
saturation: 77
|
||||
useSaturationFactor: true
|
||||
|
||||
textCode:
|
||||
hue: 340
|
||||
saturation: 50
|
||||
useSaturationFactor: true
|
||||
|
||||
brandIcon:
|
||||
hue: 38
|
||||
saturation: 92
|
||||
useSaturationFactor: true
|
||||
|
||||
scales:
|
||||
darkSurface:
|
||||
family: neutralDark
|
||||
range: [5, 26]
|
||||
curve: easeOut
|
||||
stops:
|
||||
- { name: "--background-primary", position: 0 }
|
||||
- { name: "--background-secondary", position: 0.16 }
|
||||
- { name: "--background-secondary-lighter", position: 0.22 }
|
||||
- { name: "--background-secondary-alt", position: 0.28 }
|
||||
- { name: "--background-tertiary", position: 0.4 }
|
||||
- { name: "--background-channel-header", position: 0.34 }
|
||||
- { name: "--guild-list-foreground", position: 0.38 }
|
||||
- { name: "--background-header-secondary", position: 0.5 }
|
||||
- { name: "--background-header-primary", position: 0.5 }
|
||||
- { name: "--background-textarea", position: 0.68 }
|
||||
- { name: "--background-header-primary-hover", position: 0.85 }
|
||||
|
||||
coalSurface:
|
||||
family: neutralDark
|
||||
range: [1, 12]
|
||||
curve: easeOut
|
||||
stops:
|
||||
- { name: "--background-primary", position: 0 }
|
||||
- { name: "--background-secondary", position: 0.16 }
|
||||
- { name: "--background-secondary-alt", position: 0.28 }
|
||||
- { name: "--background-tertiary", position: 0.4 }
|
||||
- { name: "--background-channel-header", position: 0.34 }
|
||||
- { name: "--guild-list-foreground", position: 0.38 }
|
||||
- { name: "--background-header-secondary", position: 0.5 }
|
||||
- { name: "--background-header-primary", position: 0.5 }
|
||||
- { name: "--background-textarea", position: 0.68 }
|
||||
- { name: "--background-header-primary-hover", position: 0.85 }
|
||||
|
||||
darkText:
|
||||
family: neutralDark
|
||||
range: [52, 96]
|
||||
curve: easeInOut
|
||||
stops:
|
||||
- { name: "--text-tertiary-secondary", position: 0 }
|
||||
- { name: "--text-tertiary-muted", position: 0.2 }
|
||||
- { name: "--text-tertiary", position: 0.38 }
|
||||
- { name: "--text-primary-muted", position: 0.55 }
|
||||
- { name: "--text-chat-muted", position: 0.55 }
|
||||
- { name: "--text-secondary", position: 0.72 }
|
||||
- { name: "--text-chat", position: 0.82 }
|
||||
- { name: "--text-primary", position: 1 }
|
||||
|
||||
lightSurface:
|
||||
family: neutralLight
|
||||
range: [85, 99]
|
||||
curve: easeIn
|
||||
stops:
|
||||
- { name: "--background-header-primary-hover", position: 0 }
|
||||
- { name: "--background-header-primary", position: 0.12 }
|
||||
- { name: "--background-header-secondary", position: 0.2 }
|
||||
- { name: "--guild-list-foreground", position: 0.35 }
|
||||
- { name: "--background-tertiary", position: 0.42 }
|
||||
- { name: "--background-channel-header", position: 0.5 }
|
||||
- { name: "--background-secondary-alt", position: 0.63 }
|
||||
- { name: "--background-textarea", position: 0.88 }
|
||||
- { name: "--background-primary", position: 1 }
|
||||
|
||||
lightText:
|
||||
family: neutralLight
|
||||
range: [15, 60]
|
||||
curve: easeOut
|
||||
stops:
|
||||
- { name: "--text-primary", position: 0 }
|
||||
- { name: "--text-chat", position: 0.08 }
|
||||
- { name: "--text-secondary", position: 0.28 }
|
||||
- { name: "--text-chat-muted", position: 0.45 }
|
||||
- { name: "--text-primary-muted", position: 0.45 }
|
||||
- { name: "--text-tertiary", position: 0.6 }
|
||||
- { name: "--text-tertiary-secondary", position: 0.75 }
|
||||
- { name: "--text-tertiary-muted", position: 0.85 }
|
||||
|
||||
tokens:
|
||||
root:
|
||||
- scale: darkSurface
|
||||
- scale: darkText
|
||||
- name: "--panel-control-bg"
|
||||
value: |
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--background-secondary-alt) 80%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 2%) 20%
|
||||
)
|
||||
- { name: "--panel-control-border", family: neutralDark, saturation: 30, lightness: 65, alpha: 0.45 }
|
||||
- { name: "--panel-control-divider", family: neutralDark, saturation: 30, lightness: 55, alpha: 0.35 }
|
||||
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.04)" }
|
||||
- { name: "--background-modifier-hover", family: neutralDark, lightness: 100, alpha: 0.05 }
|
||||
- { name: "--background-modifier-selected", family: neutralDark, lightness: 100, alpha: 0.1 }
|
||||
- { name: "--background-modifier-accent", family: neutralDark, saturation: 13, lightness: 80, alpha: 0.15 }
|
||||
- { name: "--background-modifier-accent-focus", family: neutralDark, saturation: 13, lightness: 80, alpha: 0.22 }
|
||||
- { name: "--control-button-normal-bg", value: "transparent" }
|
||||
- { name: "--control-button-normal-text", value: "var(--text-primary-muted)" }
|
||||
- { name: "--control-button-hover-bg", family: neutralDark, lightness: 22 }
|
||||
- { name: "--control-button-hover-text", value: "var(--text-primary)" }
|
||||
- { name: "--control-button-active-bg", family: neutralDark, lightness: 24 }
|
||||
- { name: "--control-button-active-text", value: "var(--text-primary)" }
|
||||
- { name: "--control-button-danger-text", hue: 1, saturation: 77, useSaturationFactor: true, lightness: 60 }
|
||||
- { name: "--control-button-danger-hover-bg", hue: 1, saturation: 77, useSaturationFactor: true, lightness: 20 }
|
||||
- { name: "--brand-primary", family: brand, lightness: 55 }
|
||||
- { name: "--brand-secondary", family: brand, saturation: 60, lightness: 49 }
|
||||
- { name: "--brand-primary-light", family: brand, saturation: 100, lightness: 84 }
|
||||
- { name: "--brand-primary-fill", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--status-online", family: statusOnline, lightness: 40 }
|
||||
- { name: "--status-idle", family: statusIdle, lightness: 50 }
|
||||
- { name: "--status-dnd", family: statusDnd, lightness: 60 }
|
||||
- { name: "--status-offline", family: statusOffline, lightness: 65 }
|
||||
- { name: "--status-danger", family: statusDanger, lightness: 55 }
|
||||
- { name: "--status-warning", value: "var(--status-idle)" }
|
||||
- { name: "--text-warning", family: statusIdle, lightness: 55 }
|
||||
- { name: "--plutonium", value: "var(--brand-primary)" }
|
||||
- { name: "--plutonium-hover", value: "var(--brand-secondary)" }
|
||||
- { name: "--plutonium-text", value: "var(--text-on-brand-primary)" }
|
||||
- { name: "--plutonium-icon", family: brandIcon, lightness: 50 }
|
||||
- { name: "--invite-verified-icon-color", value: "var(--text-on-brand-primary)" }
|
||||
- { name: "--text-link", family: link, lightness: 70 }
|
||||
- { name: "--text-on-brand-primary", hue: 0, saturation: 0, lightness: 98 }
|
||||
- { name: "--text-code", family: textCode, lightness: 90 }
|
||||
- { name: "--text-selection", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.35 }
|
||||
|
||||
- { name: "--markup-mention-text", value: "var(--text-link)" }
|
||||
- name: "--markup-mention-fill"
|
||||
value: "color-mix(in srgb, var(--text-link) 20%, transparent)"
|
||||
- { name: "--markup-mention-border", family: link, lightness: 70, alpha: 0.3 }
|
||||
|
||||
- { name: "--markup-jump-link-text", value: "var(--text-link)" }
|
||||
- name: "--markup-jump-link-fill"
|
||||
value: "color-mix(in srgb, var(--text-link) 12%, transparent)"
|
||||
- name: "--markup-jump-link-hover-fill"
|
||||
value: "color-mix(in srgb, var(--text-link) 20%, transparent)"
|
||||
|
||||
- { name: "--markup-everyone-text", hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75 }
|
||||
- name: "--markup-everyone-fill"
|
||||
value: "color-mix(in srgb, hsl(250, calc(80% * var(--saturation-factor)), 75%) 18%, transparent)"
|
||||
- { name: "--markup-everyone-border", hue: 250, saturation: 80, useSaturationFactor: true, lightness: 75, alpha: 0.3 }
|
||||
|
||||
- { name: "--markup-here-text", hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70 }
|
||||
- name: "--markup-here-fill"
|
||||
value: "color-mix(in srgb, hsl(45, calc(90% * var(--saturation-factor)), 70%) 18%, transparent)"
|
||||
- { name: "--markup-here-border", hue: 45, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.3 }
|
||||
|
||||
- { name: "--markup-interactive-hover-text", value: "var(--text-link)" }
|
||||
- name: "--markup-interactive-hover-fill"
|
||||
value: "color-mix(in srgb, var(--text-link) 30%, transparent)"
|
||||
- name: "--interactive-muted"
|
||||
value: |
|
||||
color-mix(
|
||||
in oklab,
|
||||
hsl(228, calc(10% * var(--saturation-factor)), 35%) 100%,
|
||||
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
)
|
||||
- name: "--interactive-active"
|
||||
value: |
|
||||
color-mix(
|
||||
in oklab,
|
||||
hsl(0, calc(0% * var(--saturation-factor)), 100%) 100%,
|
||||
hsl(245, calc(100% * var(--saturation-factor)), 80%) 40%
|
||||
)
|
||||
- { name: "--button-primary-fill", hue: 139, saturation: 55, useSaturationFactor: true, lightness: 44 }
|
||||
- { name: "--button-primary-active-fill", hue: 136, saturation: 60, useSaturationFactor: true, lightness: 38 }
|
||||
- { name: "--button-primary-text", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-secondary-fill", hue: 0, saturation: 0, lightness: 100, alpha: 0.1, useSaturationFactor: false }
|
||||
- { name: "--button-secondary-active-fill", hue: 0, saturation: 0, lightness: 100, alpha: 0.15, useSaturationFactor: false }
|
||||
- { name: "--button-secondary-text", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-secondary-active-text", value: "var(--button-secondary-text)" }
|
||||
- { name: "--button-danger-fill", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 54 }
|
||||
- { name: "--button-danger-active-fill", hue: 359, saturation: 65, useSaturationFactor: true, lightness: 45 }
|
||||
- { name: "--button-danger-text", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-danger-outline-border", value: "1px solid hsl(359, calc(70% * var(--saturation-factor)), 54%)" }
|
||||
- { name: "--button-danger-outline-text", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-danger-outline-active-fill", hue: 359, saturation: 65, useSaturationFactor: true, lightness: 48 }
|
||||
- { name: "--button-danger-outline-active-border", value: "transparent" }
|
||||
- { name: "--button-ghost-text", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-inverted-fill", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-inverted-text", hue: 0, saturation: 0, lightness: 0 }
|
||||
- { name: "--button-outline-border", value: "1px solid hsla(0, 0%, 100%, 0.3)" }
|
||||
- { name: "--button-outline-text", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-outline-active-fill", value: "hsla(0, 0%, 100%, 0.15)" }
|
||||
- { name: "--button-outline-active-border", value: "1px solid hsla(0, 0%, 100%, 0.4)" }
|
||||
- { name: "--theme-border", value: "transparent" }
|
||||
- { name: "--theme-border-width", value: "0px" }
|
||||
- { name: "--bg-primary", value: "var(--background-primary)" }
|
||||
- { name: "--bg-secondary", value: "var(--background-secondary)" }
|
||||
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
|
||||
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
|
||||
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
|
||||
- { name: "--bg-code", family: neutralDark, lightness: 15, alpha: 0.8 }
|
||||
- { name: "--bg-code-block", value: "var(--background-secondary-alt)" }
|
||||
- { name: "--bg-blockquote", value: "var(--background-secondary-alt)" }
|
||||
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
|
||||
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
|
||||
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
|
||||
- { name: "--border-color", family: neutralDark, lightness: 50, alpha: 0.2 }
|
||||
- { name: "--border-color-hover", family: neutralDark, lightness: 50, alpha: 0.3 }
|
||||
- { name: "--border-color-focus", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 70, alpha: 0.45 }
|
||||
- { name: "--accent-primary", value: "var(--brand-primary)" }
|
||||
- { name: "--accent-success", value: "var(--status-online)" }
|
||||
- { name: "--accent-warning", value: "var(--status-idle)" }
|
||||
- { name: "--accent-danger", value: "var(--status-dnd)" }
|
||||
- { name: "--accent-info", value: "var(--text-link)" }
|
||||
- { name: "--accent-purple", family: accentPurple, lightness: 65 }
|
||||
- { name: "--alert-note-color", family: link, lightness: 70 }
|
||||
- { name: "--alert-tip-color", family: statusOnline, lightness: 45 }
|
||||
- { name: "--alert-important-color", family: accentPurple, lightness: 65 }
|
||||
- { name: "--alert-warning-color", family: statusIdle, lightness: 55 }
|
||||
- { name: "--alert-caution-color", hue: 359, saturation: 75, useSaturationFactor: true, lightness: 60 }
|
||||
- { name: "--shadow-sm", value: "0 1px 2px rgba(0, 0, 0, 0.1)" }
|
||||
- { name: "--shadow-md", value: "0 2px 4px rgba(0, 0, 0, 0.15), 0 1px 2px rgba(0, 0, 0, 0.1)" }
|
||||
- { name: "--shadow-lg", value: "0 4px 8px rgba(0, 0, 0, 0.15), 0 2px 4px rgba(0, 0, 0, 0.1)" }
|
||||
- { name: "--shadow-xl", value: "0 10px 20px rgba(0, 0, 0, 0.15), 0 4px 8px rgba(0, 0, 0, 0.1)" }
|
||||
- { name: "--transition-fast", value: "100ms ease" }
|
||||
- { name: "--transition-normal", value: "200ms ease" }
|
||||
- { name: "--transition-slow", value: "300ms ease" }
|
||||
- { name: "--spoiler-overlay-color", value: "rgba(0, 0, 0, 0.2)" }
|
||||
- { name: "--spoiler-overlay-hover-color", value: "rgba(0, 0, 0, 0.3)" }
|
||||
- { name: "--scrollbar-thumb-bg", value: "rgba(121, 122, 124, 0.4)" }
|
||||
- { name: "--scrollbar-thumb-bg-hover", value: "rgba(121, 122, 124, 0.7)" }
|
||||
- { name: "--scrollbar-track-bg", value: "transparent" }
|
||||
- { name: "--user-area-divider-color", value: "color-mix(in srgb, var(--background-modifier-hover) 70%, transparent)" }
|
||||
|
||||
light:
|
||||
- scale: lightSurface
|
||||
- scale: lightText
|
||||
- { name: "--background-secondary", value: "var(--background-primary)" }
|
||||
- { name: "--background-secondary-lighter", value: "var(--background-secondary)" }
|
||||
- { name: "--panel-control-bg", value: "color-mix(in srgb, var(--background-secondary) 65%, hsl(0, 0%, 100%) 35%)" }
|
||||
- { name: "--panel-control-border", family: neutralLight, saturation: 25, lightness: 45, alpha: 0.25 }
|
||||
- { name: "--panel-control-divider", family: neutralLight, saturation: 30, lightness: 35, alpha: 0.2 }
|
||||
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.65)" }
|
||||
- { name: "--background-modifier-hover", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.04 }
|
||||
- { name: "--background-modifier-selected", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.08 }
|
||||
- { name: "--background-modifier-accent", family: neutralLight, saturation: 20, lightness: 40, alpha: 0.2 }
|
||||
- { name: "--background-modifier-accent-focus", family: neutralLight, saturation: 20, lightness: 40, alpha: 0.3 }
|
||||
- { name: "--control-button-normal-bg", value: "transparent" }
|
||||
- { name: "--control-button-normal-text", family: neutralLight, lightness: 50 }
|
||||
- { name: "--control-button-hover-bg", family: neutralLight, lightness: 88 }
|
||||
- { name: "--control-button-hover-text", family: neutralLight, lightness: 20 }
|
||||
- { name: "--control-button-active-bg", family: neutralLight, lightness: 85 }
|
||||
- { name: "--control-button-active-text", family: neutralLight, lightness: 15 }
|
||||
- { name: "--control-button-danger-text", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
|
||||
- { name: "--control-button-danger-hover-bg", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 95 }
|
||||
- { name: "--text-link", family: link, lightness: 45 }
|
||||
- { name: "--text-code", family: textCode, lightness: 45 }
|
||||
- { name: "--text-selection", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.2 }
|
||||
|
||||
- { name: "--markup-mention-border", family: link, lightness: 45, alpha: 0.4 }
|
||||
|
||||
- name: "--markup-jump-link-fill"
|
||||
value: "color-mix(in srgb, var(--text-link) 8%, transparent)"
|
||||
|
||||
- { name: "--markup-everyone-text", hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45 }
|
||||
- name: "--markup-everyone-fill"
|
||||
value: "color-mix(in srgb, hsl(250, calc(70% * var(--saturation-factor)), 45%) 12%, transparent)"
|
||||
- { name: "--markup-everyone-border", hue: 250, saturation: 70, useSaturationFactor: true, lightness: 45, alpha: 0.4 }
|
||||
|
||||
- { name: "--markup-here-text", hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40 }
|
||||
- name: "--markup-here-fill"
|
||||
value: "color-mix(in srgb, hsl(40, calc(85% * var(--saturation-factor)), 40%) 12%, transparent)"
|
||||
- { name: "--markup-here-border", hue: 40, saturation: 85, useSaturationFactor: true, lightness: 40, alpha: 0.4 }
|
||||
|
||||
- { name: "--status-online", family: statusOnline, saturation: 70, lightness: 40 }
|
||||
- { name: "--status-idle", family: statusIdle, saturation: 90, lightness: 45 }
|
||||
- { name: "--status-dnd", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
|
||||
- { name: "--status-offline", family: statusOffline, hue: 210, saturation: 10, lightness: 55 }
|
||||
- { name: "--plutonium", value: "var(--brand-primary)" }
|
||||
- { name: "--plutonium-hover", value: "var(--brand-secondary)" }
|
||||
- { name: "--plutonium-text", value: "var(--text-on-brand-primary)" }
|
||||
- { name: "--plutonium-icon", family: brandIcon, lightness: 45 }
|
||||
- { name: "--invite-verified-icon-color", value: "var(--brand-primary)" }
|
||||
- { name: "--border-color", family: neutralLight, lightness: 40, alpha: 0.15 }
|
||||
- { name: "--border-color-hover", family: neutralLight, lightness: 40, alpha: 0.25 }
|
||||
- { name: "--border-color-focus", hue: 210, saturation: 90, useSaturationFactor: true, lightness: 50, alpha: 0.4 }
|
||||
- { name: "--bg-primary", value: "var(--background-primary)" }
|
||||
- { name: "--bg-secondary", value: "var(--background-secondary)" }
|
||||
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
|
||||
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
|
||||
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
|
||||
- { name: "--bg-code", family: neutralLight, saturation: 22, lightness: 90, alpha: 0.9 }
|
||||
- { name: "--bg-code-block", value: "var(--background-primary)" }
|
||||
- { name: "--bg-blockquote", value: "var(--background-secondary-alt)" }
|
||||
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
|
||||
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
|
||||
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
|
||||
- { name: "--alert-note-color", family: link, lightness: 45 }
|
||||
- { name: "--alert-tip-color", hue: 150, saturation: 80, useSaturationFactor: true, lightness: 35 }
|
||||
- { name: "--alert-important-color", family: accentPurple, lightness: 50 }
|
||||
- { name: "--alert-warning-color", family: statusIdle, saturation: 90, lightness: 45 }
|
||||
- { name: "--alert-caution-color", hue: 358, saturation: 80, useSaturationFactor: true, lightness: 50 }
|
||||
- { name: "--spoiler-overlay-color", value: "rgba(0, 0, 0, 0.1)" }
|
||||
- { name: "--spoiler-overlay-hover-color", value: "rgba(0, 0, 0, 0.15)" }
|
||||
- { name: "--button-secondary-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.1 }
|
||||
- { name: "--button-secondary-active-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.15 }
|
||||
- { name: "--button-secondary-text", family: neutralLight, lightness: 15 }
|
||||
- { name: "--button-secondary-active-text", family: neutralLight, lightness: 10 }
|
||||
- { name: "--button-ghost-text", family: neutralLight, lightness: 20 }
|
||||
- { name: "--button-inverted-fill", hue: 0, saturation: 0, lightness: 100 }
|
||||
- { name: "--button-inverted-text", hue: 0, saturation: 0, lightness: 10 }
|
||||
- { name: "--button-outline-border", value: "1px solid hsla(210, calc(20% * var(--saturation-factor)), 40%, 0.3)" }
|
||||
- { name: "--button-outline-text", family: neutralLight, lightness: 20 }
|
||||
- { name: "--button-outline-active-fill", family: neutralLight, saturation: 20, lightness: 10, alpha: 0.1 }
|
||||
- { name: "--button-outline-active-border", value: "1px solid hsla(210, calc(20% * var(--saturation-factor)), 40%, 0.5)" }
|
||||
- { name: "--button-danger-outline-border", value: "1px solid hsl(359, calc(70% * var(--saturation-factor)), 50%)" }
|
||||
- { name: "--button-danger-outline-text", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 45 }
|
||||
- { name: "--button-danger-outline-active-fill", hue: 359, saturation: 70, useSaturationFactor: true, lightness: 50 }
|
||||
- { name: "--user-area-divider-color", family: neutralLight, lightness: 40, alpha: 0.2 }
|
||||
|
||||
coal:
|
||||
- scale: coalSurface
|
||||
- { name: "--background-secondary", value: "var(--background-primary)" }
|
||||
- { name: "--background-secondary-lighter", value: "var(--background-primary)" }
|
||||
- name: "--panel-control-bg"
|
||||
value: |
|
||||
color-mix(
|
||||
in srgb,
|
||||
var(--background-primary) 90%,
|
||||
hsl(220, calc(13% * var(--saturation-factor)), 0%) 10%
|
||||
)
|
||||
- { name: "--panel-control-border", family: neutralDark, saturation: 20, lightness: 30, alpha: 0.35 }
|
||||
- { name: "--panel-control-divider", family: neutralDark, saturation: 20, lightness: 25, alpha: 0.28 }
|
||||
- { name: "--panel-control-highlight", value: "hsla(0, 0%, 100%, 0.06)" }
|
||||
- { name: "--background-modifier-hover", family: neutralDark, lightness: 100, alpha: 0.04 }
|
||||
- { name: "--background-modifier-selected", family: neutralDark, lightness: 100, alpha: 0.08 }
|
||||
- { name: "--background-modifier-accent", family: neutralDark, saturation: 10, lightness: 65, alpha: 0.18 }
|
||||
- { name: "--background-modifier-accent-focus", family: neutralDark, saturation: 10, lightness: 70, alpha: 0.26 }
|
||||
- { name: "--control-button-normal-bg", value: "transparent" }
|
||||
- { name: "--control-button-normal-text", value: "var(--text-primary-muted)" }
|
||||
- { name: "--control-button-hover-bg", family: neutralDark, lightness: 12 }
|
||||
- { name: "--control-button-hover-text", value: "var(--text-primary)" }
|
||||
- { name: "--control-button-active-bg", family: neutralDark, lightness: 14 }
|
||||
- { name: "--control-button-active-text", value: "var(--text-primary)" }
|
||||
- { name: "--scrollbar-thumb-bg", value: "rgba(160, 160, 160, 0.35)" }
|
||||
- { name: "--scrollbar-thumb-bg-hover", value: "rgba(200, 200, 200, 0.55)" }
|
||||
- { name: "--scrollbar-track-bg", value: "rgba(0, 0, 0, 0.45)" }
|
||||
- { name: "--bg-primary", value: "var(--background-primary)" }
|
||||
- { name: "--bg-secondary", value: "var(--background-secondary)" }
|
||||
- { name: "--bg-tertiary", value: "var(--background-tertiary)" }
|
||||
- { name: "--bg-hover", value: "var(--background-modifier-hover)" }
|
||||
- { name: "--bg-active", value: "var(--background-modifier-selected)" }
|
||||
- { name: "--bg-code", value: "hsl(220, calc(13% * var(--saturation-factor)), 8%)" }
|
||||
- { name: "--bg-code-block", value: "var(--background-secondary-alt)" }
|
||||
- { name: "--bg-blockquote", value: "var(--background-secondary)" }
|
||||
- { name: "--bg-table-header", value: "var(--background-tertiary)" }
|
||||
- { name: "--bg-table-row-odd", value: "var(--background-primary)" }
|
||||
- { name: "--bg-table-row-even", value: "var(--background-secondary)" }
|
||||
- { name: "--button-secondary-fill", value: "hsla(0, 0%, 100%, 0.04)" }
|
||||
- { name: "--button-secondary-active-fill", value: "hsla(0, 0%, 100%, 0.07)" }
|
||||
- { name: "--button-secondary-text", value: "var(--text-primary)" }
|
||||
- { name: "--button-secondary-active-text", value: "var(--text-primary)" }
|
||||
- { name: "--button-outline-border", value: "1px solid hsla(0, 0%, 100%, 0.08)" }
|
||||
- { name: "--button-outline-active-fill", value: "hsla(0, 0%, 100%, 0.12)" }
|
||||
- { name: "--button-outline-active-border", value: "1px solid hsla(0, 0%, 100%, 0.16)" }
|
||||
- { name: "--user-area-divider-color", value: "color-mix(in srgb, var(--background-modifier-hover) 80%, transparent)" }
|
||||
575
fluxer_app/scripts/generate-avatar-masks.ts
Normal file
575
fluxer_app/scripts/generate-avatar-masks.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
type AvatarSize = 16 | 20 | 24 | 32 | 36 | 40 | 48 | 56 | 80 | 120;
|
||||
|
||||
interface StatusConfig {
|
||||
statusSize: number;
|
||||
cutoutRadius: number;
|
||||
cutoutCenter: number;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<number, StatusConfig> = {
|
||||
16: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 13},
|
||||
20: {statusSize: 10, cutoutRadius: 5, cutoutCenter: 17},
|
||||
24: {statusSize: 10, cutoutRadius: 7, cutoutCenter: 20},
|
||||
32: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 27},
|
||||
36: {statusSize: 10, cutoutRadius: 8, cutoutCenter: 30},
|
||||
40: {statusSize: 12, cutoutRadius: 9, cutoutCenter: 34},
|
||||
48: {statusSize: 14, cutoutRadius: 10, cutoutCenter: 42},
|
||||
56: {statusSize: 16, cutoutRadius: 11, cutoutCenter: 49},
|
||||
80: {statusSize: 16, cutoutRadius: 14, cutoutCenter: 68},
|
||||
120: {statusSize: 24, cutoutRadius: 20, cutoutCenter: 100},
|
||||
};
|
||||
|
||||
const DESIGN_RULES = {
|
||||
mobileAspectRatio: 0.75,
|
||||
mobileCornerRadius: 0.12,
|
||||
mobileScreenWidth: 0.72,
|
||||
mobileScreenHeight: 0.7,
|
||||
mobileScreenY: 0.06,
|
||||
mobileWheelRadius: 0.13,
|
||||
mobileWheelY: 0.83,
|
||||
|
||||
typingWidthMultiplier: 1.8,
|
||||
mobilePhoneExtraHeight: 2,
|
||||
mobileDisplayExtraHeight: 2,
|
||||
mobileDisplayExtraWidthPerSide: 2,
|
||||
|
||||
idle: {
|
||||
cutoutRadiusRatio: 0.7,
|
||||
cutoutOffsetRatio: 0.35,
|
||||
},
|
||||
dnd: {
|
||||
barWidthRatio: 1.3,
|
||||
barHeightRatio: 0.4,
|
||||
minBarHeight: 2,
|
||||
},
|
||||
offline: {
|
||||
innerRingRatio: 0.6,
|
||||
},
|
||||
} as const;
|
||||
|
||||
const MOBILE_SCREEN_WIDTH_TRIM_PX = 4;
|
||||
const MOBILE_SCREEN_HEIGHT_TRIM_PX = 2;
|
||||
const MOBILE_SCREEN_X_OFFSET_PX = 0;
|
||||
const MOBILE_SCREEN_Y_OFFSET_PX = 3;
|
||||
|
||||
function getStatusConfig(avatarSize: number): StatusConfig {
|
||||
if (STATUS_CONFIG[avatarSize]) {
|
||||
return STATUS_CONFIG[avatarSize];
|
||||
}
|
||||
const sizes = Object.keys(STATUS_CONFIG)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b);
|
||||
const closest = sizes.reduce((prev, curr) =>
|
||||
Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev,
|
||||
);
|
||||
return STATUS_CONFIG[closest];
|
||||
}
|
||||
|
||||
interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
borderWidth: number;
|
||||
}
|
||||
|
||||
interface MobileStatusGeometry extends StatusGeometry {
|
||||
phoneWidth: number;
|
||||
phoneHeight: number;
|
||||
phoneX: number;
|
||||
phoneY: number;
|
||||
phoneRx: number;
|
||||
bezelHeight: number;
|
||||
}
|
||||
|
||||
function calculateStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry | MobileStatusGeometry {
|
||||
const config = getStatusConfig(avatarSize);
|
||||
|
||||
const statusSize = config.statusSize;
|
||||
const cutoutCenter = config.cutoutCenter;
|
||||
const cutoutRadius = config.cutoutRadius;
|
||||
|
||||
const innerRadius = statusSize / 2;
|
||||
const outerRadius = cutoutRadius;
|
||||
const borderWidth = cutoutRadius - innerRadius;
|
||||
|
||||
const baseGeometry = {
|
||||
size: statusSize,
|
||||
cx: cutoutCenter,
|
||||
cy: cutoutCenter,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
borderWidth,
|
||||
};
|
||||
|
||||
if (!isMobile) {
|
||||
return baseGeometry;
|
||||
}
|
||||
|
||||
const phoneWidth = statusSize;
|
||||
const phoneHeight = Math.round(phoneWidth / DESIGN_RULES.mobileAspectRatio) + DESIGN_RULES.mobilePhoneExtraHeight;
|
||||
const phoneRx = Math.round(phoneWidth * DESIGN_RULES.mobileCornerRadius);
|
||||
const bezelHeight = Math.max(1, Math.round(phoneHeight * 0.05));
|
||||
|
||||
const phoneX = cutoutCenter - phoneWidth / 2;
|
||||
const phoneY = cutoutCenter - phoneHeight / 2;
|
||||
|
||||
return {
|
||||
...baseGeometry,
|
||||
phoneWidth,
|
||||
phoneHeight,
|
||||
phoneX,
|
||||
phoneY,
|
||||
phoneRx,
|
||||
bezelHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function generateAvatarMaskDefault(size: number): string {
|
||||
const r = size / 2;
|
||||
return `<circle fill="white" cx="${r}" cy="${r}" r="${r}" />`;
|
||||
}
|
||||
|
||||
function generateAvatarMaskStatusRound(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateAvatarMaskStatusTyping(size: number): string {
|
||||
const r = size / 2;
|
||||
const status = calculateStatusGeometry(size);
|
||||
|
||||
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier);
|
||||
const typingHeight = status.size;
|
||||
const typingRx = status.outerRadius;
|
||||
|
||||
const x = status.cx - typingWidth / 2;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${r}" cy="${r}" r="${r}" />
|
||||
<rect fill="black" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateMobilePhoneMask(mobileStatus: MobileStatusGeometry): string {
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidth =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
MOBILE_SCREEN_WIDTH_TRIM_PX;
|
||||
const screenHeight =
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
|
||||
const screenX = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidth) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
|
||||
const screenY =
|
||||
mobileStatus.phoneY +
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
const screenRx = Math.min(screenWidth, screenHeight) * 0.1;
|
||||
|
||||
const wheelRadius = mobileStatus.phoneWidth * DESIGN_RULES.mobileWheelRadius;
|
||||
const wheelCx = mobileStatus.phoneX + mobileStatus.phoneWidth / 2;
|
||||
const wheelCy = mobileStatus.phoneY + mobileStatus.phoneHeight * DESIGN_RULES.mobileWheelY;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<rect fill="white" x="${mobileStatus.phoneX}" y="${mobileStatus.phoneY}" width="${mobileStatus.phoneWidth}" height="${mobileStatus.phoneHeight}" rx="${mobileStatus.phoneRx}" ry="${mobileStatus.phoneRx}" />
|
||||
<rect fill="black" x="${screenX}" y="${screenY}" width="${screenWidth}" height="${screenHeight}" rx="${screenRx}" ry="${screenRx}" />
|
||||
<circle fill="black" cx="${wheelCx}" cy="${wheelCy}" r="${wheelRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateStatusOnline(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
return `<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusIdle(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const cutoutRadius = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio);
|
||||
const cutoutOffsetDistance = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio);
|
||||
const cutoutCx = status.cx - cutoutOffsetDistance;
|
||||
const cutoutCy = status.cy - cutoutOffsetDistance;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<circle fill="black" cx="${cutoutCx}" cy="${cutoutCy}" r="${cutoutRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusDnd(size: number, isMobile: boolean = false): string {
|
||||
const status = calculateStatusGeometry(size, isMobile);
|
||||
|
||||
if (!isMobile) {
|
||||
const barWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio);
|
||||
const rawBarHeight = status.outerRadius * DESIGN_RULES.dnd.barHeightRatio;
|
||||
const barHeight = Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(rawBarHeight));
|
||||
const barX = status.cx - barWidth / 2;
|
||||
const barY = status.cy - barHeight / 2;
|
||||
const barRx = barHeight / 2;
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<rect fill="black" x="${barX}" y="${barY}" width="${barWidth}" height="${barHeight}" rx="${barRx}" ry="${barRx}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
return generateMobilePhoneMask(status as MobileStatusGeometry);
|
||||
}
|
||||
|
||||
function generateStatusOffline(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const innerRadius = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio);
|
||||
|
||||
return `(
|
||||
<>
|
||||
<circle fill="white" cx="${status.cx}" cy="${status.cy}" r="${status.outerRadius}" />
|
||||
<circle fill="black" cx="${status.cx}" cy="${status.cy}" r="${innerRadius}" />
|
||||
</>
|
||||
)`;
|
||||
}
|
||||
|
||||
function generateStatusTyping(size: number): string {
|
||||
const status = calculateStatusGeometry(size);
|
||||
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier);
|
||||
const typingHeight = status.size;
|
||||
const rx = status.outerRadius;
|
||||
const x = status.cx - typingWidth / 2;
|
||||
const y = status.cy - typingHeight / 2;
|
||||
|
||||
return `<rect fill="white" x="${x}" y="${y}" width="${typingWidth}" height="${typingHeight}" rx="${rx}" ry="${rx}" />`;
|
||||
}
|
||||
|
||||
const SIZES: Array<AvatarSize> = [16, 20, 24, 32, 36, 40, 48, 56, 80, 120];
|
||||
|
||||
let output = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
type AvatarSize = ${SIZES.join(' | ')};
|
||||
|
||||
interface MaskDefinition {
|
||||
viewBox: string;
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
interface MaskSet {
|
||||
avatarDefault: MaskDefinition;
|
||||
avatarStatusRound: MaskDefinition;
|
||||
avatarStatusTyping: MaskDefinition;
|
||||
statusOnline: MaskDefinition;
|
||||
statusOnlineMobile: MaskDefinition;
|
||||
statusIdle: MaskDefinition;
|
||||
statusIdleMobile: MaskDefinition;
|
||||
statusDnd: MaskDefinition;
|
||||
statusDndMobile: MaskDefinition;
|
||||
statusOffline: MaskDefinition;
|
||||
statusTyping: MaskDefinition;
|
||||
}
|
||||
|
||||
export const AVATAR_MASKS: Record<AvatarSize, MaskSet> = {
|
||||
`;
|
||||
|
||||
for (const size of SIZES) {
|
||||
output += ` ${size}: {
|
||||
avatarDefault: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskDefault(size)},
|
||||
},
|
||||
avatarStatusRound: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskStatusRound(size)},
|
||||
},
|
||||
avatarStatusTyping: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateAvatarMaskStatusTyping(size)},
|
||||
},
|
||||
statusOnline: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOnline(size, false)},
|
||||
},
|
||||
statusOnlineMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOnline(size, true)},
|
||||
},
|
||||
statusIdle: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusIdle(size, false)},
|
||||
},
|
||||
statusIdleMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusIdle(size, true)},
|
||||
},
|
||||
statusDnd: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusDnd(size, false)},
|
||||
},
|
||||
statusDndMobile: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusDnd(size, true)},
|
||||
},
|
||||
statusOffline: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusOffline(size)},
|
||||
},
|
||||
statusTyping: {
|
||||
viewBox: '0 0 ${size} ${size}',
|
||||
content: ${generateStatusTyping(size)},
|
||||
},
|
||||
},
|
||||
`;
|
||||
}
|
||||
|
||||
output += `} as const;
|
||||
|
||||
export const SVGMasks = () => (
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
aria-hidden={true}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'none',
|
||||
top: '-1px',
|
||||
left: '-1px',
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
`;
|
||||
|
||||
for (const size of SIZES) {
|
||||
const status = calculateStatusGeometry(size, false);
|
||||
const mobileStatus = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
|
||||
const cx = status.cx / size;
|
||||
const cy = status.cy / size;
|
||||
const r = status.outerRadius / size;
|
||||
|
||||
const idleCutoutR = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutRadiusRatio) / size;
|
||||
const idleCutoutOffset = Math.round(status.outerRadius * DESIGN_RULES.idle.cutoutOffsetRatio) / size;
|
||||
const idleCutoutCx = cx - idleCutoutOffset;
|
||||
const idleCutoutCy = cy - idleCutoutOffset;
|
||||
|
||||
const dndBarWidth = Math.round(status.outerRadius * DESIGN_RULES.dnd.barWidthRatio) / size;
|
||||
const dndBarHeight =
|
||||
Math.max(DESIGN_RULES.dnd.minBarHeight, Math.round(status.outerRadius * DESIGN_RULES.dnd.barHeightRatio)) / size;
|
||||
const dndBarX = cx - dndBarWidth / 2;
|
||||
const dndBarY = cy - dndBarHeight / 2;
|
||||
const dndBarRx = dndBarHeight / 2;
|
||||
|
||||
const offlineInnerR = Math.round(status.innerRadius * DESIGN_RULES.offline.innerRingRatio) / size;
|
||||
|
||||
const typingWidth = Math.round(status.size * DESIGN_RULES.typingWidthMultiplier) / size;
|
||||
const typingHeight = status.size / size;
|
||||
const typingX = cx - typingWidth / 2;
|
||||
const typingY = cy - typingHeight / 2;
|
||||
const typingRx = status.outerRadius / size;
|
||||
|
||||
const cutoutPhoneWidth = (mobileStatus.phoneWidth + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneHeight = (mobileStatus.phoneHeight + mobileStatus.borderWidth * 2) / size;
|
||||
const cutoutPhoneX = (mobileStatus.phoneX - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneY = (mobileStatus.phoneY - mobileStatus.borderWidth) / size;
|
||||
const cutoutPhoneRx = (mobileStatus.phoneRx + mobileStatus.borderWidth) / size;
|
||||
|
||||
const displayExtraHeight = DESIGN_RULES.mobileDisplayExtraHeight;
|
||||
const displayExtraWidthPerSide = DESIGN_RULES.mobileDisplayExtraWidthPerSide;
|
||||
|
||||
const screenWidthPx =
|
||||
mobileStatus.phoneWidth * DESIGN_RULES.mobileScreenWidth +
|
||||
displayExtraWidthPerSide * 2 -
|
||||
MOBILE_SCREEN_WIDTH_TRIM_PX;
|
||||
const screenHeightPx =
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenHeight + displayExtraHeight - MOBILE_SCREEN_HEIGHT_TRIM_PX;
|
||||
const screenXpx = mobileStatus.phoneX + (mobileStatus.phoneWidth - screenWidthPx) / 2 + MOBILE_SCREEN_X_OFFSET_PX;
|
||||
const screenYpx =
|
||||
mobileStatus.phoneY +
|
||||
mobileStatus.phoneHeight * DESIGN_RULES.mobileScreenY -
|
||||
displayExtraHeight / 2 +
|
||||
MOBILE_SCREEN_Y_OFFSET_PX;
|
||||
|
||||
const screenRxPx = Math.min(screenWidthPx, screenHeightPx) * 0.1;
|
||||
|
||||
const mobileScreenX = ((screenXpx - mobileStatus.phoneX) / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenY = ((screenYpx - mobileStatus.phoneY) / mobileStatus.phoneHeight).toFixed(4);
|
||||
const mobileScreenWidth = (screenWidthPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenHeight = ((screenHeightPx / mobileStatus.phoneHeight) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
const mobileScreenRx = (screenRxPx / mobileStatus.phoneWidth).toFixed(4);
|
||||
const mobileScreenRy = ((screenRxPx / mobileStatus.phoneWidth) * DESIGN_RULES.mobileAspectRatio).toFixed(4);
|
||||
|
||||
output += ` <mask id="svg-mask-avatar-default-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-round-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${cutoutPhoneX}" y="${cutoutPhoneY}" width="${cutoutPhoneWidth}" height="${cutoutPhoneHeight}" rx="${cutoutPhoneRx}" ry="${cutoutPhoneRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
<rect fill="black" x="${mobileScreenX}" y="${mobileScreenY}" width="${mobileScreenWidth}" height="${mobileScreenHeight}" rx="${mobileScreenRx}" ry="${mobileScreenRy}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${idleCutoutCx}" cy="${idleCutoutCy}" r="${idleCutoutR}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<rect fill="black" x="${dndBarX}" y="${dndBarY}" width="${dndBarWidth}" height="${dndBarHeight}" rx="${dndBarRx}" ry="${dndBarRx}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="${cx}" cy="${cy}" r="${r}" />
|
||||
<circle fill="black" cx="${cx}" cy="${cy}" r="${offlineInnerR}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing-${size}" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="${typingX}" y="${typingY}" width="${typingWidth}" height="${typingHeight}" rx="${typingRx}" ry="${typingRx}" />
|
||||
</mask>
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
output += ` <mask id="svg-mask-status-online" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-idle" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.25" cy="0.25" r="0.375" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-dnd" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<rect fill="black" x="0.125" y="0.375" width="0.75" height="0.25" rx="0.125" ry="0.125" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-offline" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
<circle fill="black" cx="0.5" cy="0.5" r="0.25" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-typing" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="0.5" ry="0.5" />
|
||||
</mask>
|
||||
<mask id="svg-mask-status-online-mobile" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<rect fill="white" x="0" y="0" width="1" height="1" rx="${DESIGN_RULES.mobileCornerRadius}" ry="${(DESIGN_RULES.mobileCornerRadius * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<rect fill="black" x="${((1 - DESIGN_RULES.mobileScreenWidth) / 2).toFixed(4)}" y="${DESIGN_RULES.mobileScreenY}" width="${DESIGN_RULES.mobileScreenWidth}" height="${(DESIGN_RULES.mobileScreenHeight * DESIGN_RULES.mobileAspectRatio).toFixed(4)}" rx="0.04" ry="${(0.04 * DESIGN_RULES.mobileAspectRatio).toFixed(2)}" />
|
||||
<ellipse fill="black" cx="0.5" cy="${DESIGN_RULES.mobileWheelY}" rx="${DESIGN_RULES.mobileWheelRadius}" ry="${(DESIGN_RULES.mobileWheelRadius * DESIGN_RULES.mobileAspectRatio).toFixed(3)}" />
|
||||
</mask>
|
||||
<mask id="svg-mask-avatar-default" maskContentUnits="objectBoundingBox" viewBox="0 0 1 1">
|
||||
<circle fill="white" cx="0.5" cy="0.5" r="0.5" />
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
`;
|
||||
|
||||
const outputPath = path.join(__dirname, '../src/components/uikit/SVGMasks.tsx');
|
||||
fs.writeFileSync(outputPath, output);
|
||||
|
||||
console.log(`Generated ${outputPath}`);
|
||||
|
||||
const layoutOutput = `// @generated - DO NOT EDIT MANUALLY
|
||||
// Run: pnpm generate:masks
|
||||
|
||||
export interface StatusGeometry {
|
||||
size: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
radius: number;
|
||||
borderWidth: number;
|
||||
isMobile?: boolean;
|
||||
phoneWidth?: number;
|
||||
phoneHeight?: number;
|
||||
}
|
||||
|
||||
const STATUS_GEOMETRY: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, false);
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: false}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
const STATUS_GEOMETRY_MOBILE: Record<number, StatusGeometry> = {
|
||||
${SIZES.map((size) => {
|
||||
const geom = calculateStatusGeometry(size, true) as MobileStatusGeometry;
|
||||
return ` ${size}: {size: ${geom.size}, cx: ${geom.cx}, cy: ${geom.cy}, radius: ${geom.outerRadius}, borderWidth: ${geom.borderWidth}, isMobile: true, phoneWidth: ${geom.phoneWidth}, phoneHeight: ${geom.phoneHeight}}`;
|
||||
}).join(',\n')},
|
||||
};
|
||||
|
||||
export function getStatusGeometry(avatarSize: number, isMobile: boolean = false): StatusGeometry {
|
||||
const map = isMobile ? STATUS_GEOMETRY_MOBILE : STATUS_GEOMETRY;
|
||||
|
||||
if (map[avatarSize]) {
|
||||
return map[avatarSize];
|
||||
}
|
||||
|
||||
const closestSize = Object.keys(map)
|
||||
.map(Number)
|
||||
.reduce((prev, curr) => (Math.abs(curr - avatarSize) < Math.abs(prev - avatarSize) ? curr : prev));
|
||||
|
||||
return map[closestSize];
|
||||
}
|
||||
`;
|
||||
|
||||
const layoutPath = path.join(__dirname, '../src/components/uikit/AvatarStatusGeometry.ts');
|
||||
fs.writeFileSync(layoutPath, layoutOutput);
|
||||
42
fluxer_app/scripts/go.mod
Normal file
42
fluxer_app/scripts/go.mod
Normal file
@@ -0,0 +1,42 @@
|
||||
module fluxer_app/scripts
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.5
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef
|
||||
golang.org/x/sync v0.19.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 // indirect
|
||||
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/text v0.3.6 // indirect
|
||||
)
|
||||
79
fluxer_app/scripts/go.sum
Normal file
79
fluxer_app/scripts/go.sum
Normal file
@@ -0,0 +1,79 @@
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15 h1:Zn4SfxkULorRqLg/VhxQ5cg9bi8Qhq7Y8W9RUew15oI=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.15/go.mod h1:uFphWOp8hzgUQ6ORHAw2WUf2xeqOWHjhgCDSdAVxzp0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
|
||||
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
|
||||
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
47
fluxer_app/scripts/set-build-channel.mjs
Normal file
47
fluxer_app/scripts/set-build-channel.mjs
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {writeFile} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const allowedChannels = new Set(['stable', 'canary']);
|
||||
const rawChannel = process.env.BUILD_CHANNEL?.toLowerCase() ?? '';
|
||||
const channel = allowedChannels.has(rawChannel) ? rawChannel : 'stable';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const targetPath = path.resolve(scriptDir, '..', 'src-electron', 'common', 'build-channel.ts');
|
||||
|
||||
const fileContent = `/*
|
||||
* This file is generated by scripts/set-build-channel.mjs.
|
||||
* DO NOT EDIT MANUALLY.
|
||||
*/
|
||||
|
||||
export type BuildChannel = 'stable' | 'canary';
|
||||
|
||||
const DEFAULT_BUILD_CHANNEL = '${channel}' as BuildChannel;
|
||||
|
||||
const envChannel = process.env.BUILD_CHANNEL?.toLowerCase();
|
||||
export const BUILD_CHANNEL = (envChannel === 'canary' ? 'canary' : DEFAULT_BUILD_CHANNEL) as BuildChannel;
|
||||
export const IS_CANARY = BUILD_CHANNEL === 'canary';
|
||||
export const CHANNEL_DISPLAY_NAME = BUILD_CHANNEL;
|
||||
`;
|
||||
|
||||
await writeFile(targetPath, fileContent, 'utf8');
|
||||
console.log(`Wrote ${targetPath} with channel '${channel}'`);
|
||||
Reference in New Issue
Block a user