initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

View 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)
}

View 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)
}

File diff suppressed because it is too large Load Diff

View 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()
}

View 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)
}