Compare commits

...

4 Commits

Author SHA1 Message Date
MangoPig
76c24782c8 Feat: Backend scaffolding and local dev stack 2026-06-16 07:34:34 +01:00
MangoPig
4ebee9e695 Merge branch 'Features/Frontend/Adoptive-Theme' 2026-06-15 10:56:01 +01:00
MangoPig
a5ca826a6e Feat: Adoptive theme foundation 2026-06-15 10:54:55 +01:00
MangoPig
ecd214652a Merge branch 'Features/Frontend/Web-Loader' 2026-06-15 07:00:44 +01:00
55 changed files with 2399 additions and 93 deletions

4
.gitignore vendored
View File

@@ -21,3 +21,7 @@ pnpm-debug.log*
# OS / editor files
.DS_Store
.idea/
# Go build output
tmp/
bin/

52
Backend/.air.api.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp/dev"
[build]
args_bin = []
bin = "./tmp/dev/bin/api"
cmd = "go build -o ./tmp/dev/bin/api ./cmd/api"
delay = 1000
exclude_dir = ["tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "sql"]
include_file = []
kill_delay = "0s"
log = "tmp/dev/logs/build-errors.api.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = true
keep_scroll = true

52
Backend/.air.web.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp/dev"
[build]
args_bin = []
bin = "./tmp/dev/bin/web"
cmd = "go build -o ./tmp/dev/bin/web ./cmd/web"
delay = 1000
exclude_dir = ["tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "sql"]
include_file = []
kill_delay = "0s"
log = "tmp/dev/logs/build-errors.web.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = true
keep_scroll = true

52
Backend/.air.worker.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp/dev"
[build]
args_bin = []
bin = "./tmp/dev/bin/worker"
cmd = "go build -o ./tmp/dev/bin/worker ./cmd/worker"
delay = 1000
exclude_dir = ["tmp", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "sql"]
include_file = []
kill_delay = "0s"
log = "tmp/dev/logs/build-errors.worker.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = true
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = true
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = true
keep_scroll = true

4
Backend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
.git
.gitignore
tmp
testdata

View File

36
Backend/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.25.7-alpine AS base
WORKDIR /app
RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates
COPY go.mod go.sum ./
RUN go mod download
FROM base AS development
RUN curl -fsSL https://raw.githubusercontent.com/air-verse/air/master/install.sh | sh -s -- -b /usr/local/bin
COPY . .
CMD ["air", "-c", ".air.web.toml"]
FROM base AS builder
ARG SERVICE_NAME=web
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/app ./cmd/${SERVICE_NAME}
FROM alpine:3.22 AS runtime
RUN apk add --no-cache ca-certificates tzdata && update-ca-certificates
WORKDIR /app
COPY --from=builder /out/app /usr/local/bin/app
CMD ["/usr/local/bin/app"]

35
Backend/cmd/api/main.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"log"
"moku-backend/internal/bootstrap"
"moku-backend/internal/httpx"
"moku-backend/internal/process"
)
func main() {
app, err := bootstrap.New("api")
if err != nil {
log.Fatalf("bootstrap api service: %v", err)
}
defer func() {
if closeErr := app.Close(); closeErr != nil {
app.Logger.Error("close api service", "error", closeErr)
}
}()
handler := httpx.NewRouter(httpx.RouterConfig{
ServiceName: app.ServiceName,
Config: app.Config,
Logger: app.Logger,
BuildInfo: app.BuildInfo,
Database: app.Database,
Cache: app.Cache,
})
if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil {
app.Logger.Error("api service stopped", "error", err)
log.Fatal(err)
}
}

View File

@@ -0,0 +1,49 @@
package main
import (
"fmt"
"log"
"os"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
func main() {
cfg := config.Load()
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
log.Fatalf("connect database for migrations: %v", err)
}
defer db.Close()
command := "up"
if len(os.Args) > 1 {
command = os.Args[1]
}
switch command {
case "up":
if err := db.MigrateUp(); err != nil {
log.Fatalf("run migrations: %v", err)
}
fmt.Println("migrations applied")
case "down":
if err := db.MigrateDown(); err != nil {
log.Fatalf("roll back migration: %v", err)
}
fmt.Println("latest migration rolled back")
case "reset":
if err := db.MigrateReset(); err != nil {
log.Fatalf("reset migrations: %v", err)
}
fmt.Println("migrations reset")
case "status":
if err := db.MigrateStatus(); err != nil {
log.Fatalf("show migration status: %v", err)
}
default:
log.Fatalf("unsupported migrate command %q (supported: up, down, reset, status)", command)
}
}

35
Backend/cmd/web/main.go Normal file
View File

@@ -0,0 +1,35 @@
package main
import (
"log"
"moku-backend/internal/bootstrap"
"moku-backend/internal/httpx"
"moku-backend/internal/process"
)
func main() {
app, err := bootstrap.New("web")
if err != nil {
log.Fatalf("bootstrap web service: %v", err)
}
defer func() {
if closeErr := app.Close(); closeErr != nil {
app.Logger.Error("close web service", "error", closeErr)
}
}()
handler := httpx.NewRouter(httpx.RouterConfig{
ServiceName: app.ServiceName,
Config: app.Config,
Logger: app.Logger,
BuildInfo: app.BuildInfo,
Database: app.Database,
Cache: app.Cache,
})
if err := process.RunHTTPServer(app.ServiceName, app.Config.Address(app.ServiceName), handler, app.Logger, app.Config.ShutdownTimeout); err != nil {
app.Logger.Error("web service stopped", "error", err)
log.Fatal(err)
}
}

View File

@@ -0,0 +1,27 @@
package main
import (
"log"
"moku-backend/internal/bootstrap"
"moku-backend/internal/process"
)
func main() {
app, err := bootstrap.New("worker")
if err != nil {
log.Fatalf("bootstrap worker service: %v", err)
}
defer func() {
if closeErr := app.Close(); closeErr != nil {
app.Logger.Error("close worker service", "error", closeErr)
}
}()
app.Logger.Info("worker ready", "service", app.ServiceName, "environment", app.Config.Environment)
if err := process.WaitForShutdown(app.ServiceName, app.Logger); err != nil {
app.Logger.Error("worker stopped", "error", err)
log.Fatal(err)
}
}

6
Backend/db/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package db
import "embed"
//go:embed migrations/*.sql
var Migrations embed.FS

View File

@@ -0,0 +1,26 @@
-- +goose Up
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (organization_id, slug)
);
CREATE INDEX IF NOT EXISTS idx_workspaces_organization_id ON workspaces (organization_id);
-- +goose Down
DROP TABLE IF EXISTS workspaces;
DROP TABLE IF EXISTS organizations;

View File

@@ -0,0 +1,15 @@
-- name: ListOrganizations :many
SELECT id, name, slug, created_at, updated_at
FROM organizations
ORDER BY created_at DESC;
-- name: CreateOrganization :one
INSERT INTO organizations (name, slug)
VALUES ($1, $2)
RETURNING id, name, slug, created_at, updated_at;
-- name: ListWorkspacesByOrganization :many
SELECT id, organization_id, name, slug, created_at, updated_at
FROM workspaces
WHERE organization_id = $1
ORDER BY created_at DESC;

36
Backend/docker-bake.hcl Normal file
View File

@@ -0,0 +1,36 @@
variable "REGISTRY" {
default = "registry.example.com"
}
variable "TAG" {
default = "latest"
}
target "_app" {
context = "."
dockerfile = "Dockerfile"
}
target "dev" {
inherits = ["_app"]
target = "development"
tags = ["moku/work-backend:dev"]
}
target "dev-image" {
inherits = ["_app"]
target = "development"
tags = ["${REGISTRY}/moku/work-backend:dev-${TAG}"]
}
group "local" {
targets = ["dev"]
}
group "registry" {
targets = ["dev-image"]
}
group "default" {
targets = ["dev"]
}

24
Backend/go.mod Normal file
View File

@@ -0,0 +1,24 @@
module moku-backend
go 1.25.7
require (
github.com/go-chi/chi/v5 v5.3.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/pressly/goose/v3 v3.27.1
github.com/redis/go-redis/v9 v9.20.1
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
)

70
Backend/go.sum Normal file
View File

@@ -0,0 +1,70 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w=
github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=

View File

@@ -0,0 +1,85 @@
// Path: Backend/internal/bootstrap/bootstrap.go
package bootstrap
import (
"log/slog"
"os"
"strings"
"moku-backend/internal/buildinfo"
"moku-backend/internal/cache"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
type App struct {
ServiceName string
Config *config.Config
Logger *slog.Logger
BuildInfo buildinfo.Info
Database *database.DB
Cache *cache.Client
}
func New(serviceName string) (*App, error) {
cfg := config.Load()
logger := newLogger(cfg)
db, err := database.NewPostgres(cfg.PostgresURL)
if err != nil {
return nil, err
}
valkey, err := cache.NewValkey(cfg.ValkeyURL)
if err != nil {
db.Close()
return nil, err
}
return &App{
ServiceName: serviceName,
Config: cfg,
Logger: logger.With("service", serviceName),
BuildInfo: buildinfo.Current(),
Database: db,
Cache: valkey,
}, nil
}
func (a *App) Close() error {
var closeErr error
if a.Cache != nil {
if err := a.Cache.Close(); err != nil {
closeErr = err
}
}
if a.Database != nil {
a.Database.Close()
}
return closeErr
}
func newLogger(cfg *config.Config) *slog.Logger {
level := slog.LevelInfo
switch strings.ToLower(cfg.LogLevel) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
options := &slog.HandlerOptions{Level: level}
if cfg.IsDevelopment() {
return slog.New(slog.NewTextHandler(os.Stdout, options))
}
return slog.New(slog.NewJSONHandler(os.Stdout, options))
}

View File

@@ -0,0 +1,23 @@
// Path: Backend/internal/buildinfo/buildinfo.go
package buildinfo
var (
Version = "dev"
Commit = "unknown"
BuiltAt = "unknown"
)
type Info struct {
Version string `json:"version"`
Commit string `json:"commit"`
BuiltAt string `json:"builtAt"`
}
func Current() Info {
return Info{
Version: Version,
Commit: Commit,
BuiltAt: BuiltAt,
}
}

40
Backend/internal/cache/valkey.go vendored Normal file
View File

@@ -0,0 +1,40 @@
// Path: Backend/internal/cache/valkey.go
package cache
import (
"context"
"github.com/redis/go-redis/v9"
)
type Client struct {
Redis *redis.Client
}
func NewValkey(valkeyURL string) (*Client, error) {
options, err := redis.ParseURL(valkeyURL)
if err != nil {
return nil, err
}
client := redis.NewClient(options)
return &Client{Redis: client}, nil
}
func (c *Client) Health(ctx context.Context) error {
if c == nil || c.Redis == nil {
return nil
}
return c.Redis.Ping(ctx).Err()
}
func (c *Client) Close() error {
if c == nil || c.Redis == nil {
return nil
}
return c.Redis.Close()
}

View File

@@ -0,0 +1,79 @@
// Path: Backend/internal/config/config.go
package config
import (
"fmt"
"os"
"strings"
"time"
)
type Config struct {
AppName string
Environment string
LogLevel string
WebPort string
APIPort string
WorkerPort string
PostgresURL string
ValkeyURL string
ShutdownTimeout time.Duration
}
func Load() *Config {
return &Config{
AppName: getEnv("APP_NAME", "moku"),
Environment: getEnv("GO_ENV", "development"),
LogLevel: getEnv("LOG_LEVEL", "debug"),
WebPort: getEnv("BACKEND_WEB_PORT", "8080"),
APIPort: getEnv("BACKEND_API_PORT", "8081"),
WorkerPort: getEnv("BACKEND_WORKER_PORT", "8082"),
PostgresURL: getEnv("DATABASE_URL", "postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable"),
ValkeyURL: getEnv("VALKEY_URL", "redis://localhost:6379/0"),
ShutdownTimeout: getDurationEnv("BACKEND_SHUTDOWN_TIMEOUT", 10*time.Second),
}
}
func (c *Config) Address(serviceName string) string {
var port string
switch strings.ToLower(serviceName) {
case "web":
port = c.WebPort
case "api":
port = c.APIPort
case "worker":
port = c.WorkerPort
default:
port = c.WebPort
}
return fmt.Sprintf(":%s", port)
}
func (c *Config) IsDevelopment() bool {
return strings.EqualFold(c.Environment, "development")
}
func getEnv(key, fallback string) string {
if value, exists := os.LookupEnv(key); exists {
return value
}
return fallback
}
func getDurationEnv(key string, fallback time.Duration) time.Duration {
value, exists := os.LookupEnv(key)
if !exists {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}

View File

@@ -0,0 +1,95 @@
// Path: Backend/internal/database/postgres.go
package database
import (
"context"
"database/sql"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"moku-backend/db"
)
type DB struct {
Pool *pgxpool.Pool
}
func NewPostgres(databaseURL string) (*DB, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, err
}
config.MaxConns = 25
config.MinConns = 5
config.MaxConnLifetime = time.Hour
config.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, err
}
return &DB{Pool: pool}, nil
}
func (d *DB) MigrateUp() error {
return d.runGoose(goose.Up)
}
func (d *DB) MigrateDown() error {
return d.runGoose(goose.Down)
}
func (d *DB) MigrateReset() error {
return d.runGoose(goose.Reset)
}
func (d *DB) MigrateStatus() error {
return d.runGoose(goose.Status)
}
func (d *DB) runGoose(command func(*sql.DB, string, ...goose.OptionsFunc) error) error {
if d == nil || d.Pool == nil {
return nil
}
sqlDB := stdlib.OpenDBFromPool(d.Pool)
defer sqlDB.Close()
goose.SetBaseFS(db.Migrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return command(sqlDB, "migrations")
}
func (d *DB) Health(ctx context.Context) error {
if d == nil || d.Pool == nil {
return nil
}
return d.Pool.Ping(ctx)
}
func (d *DB) Close() {
if d == nil || d.Pool == nil {
return
}
d.Pool.Close()
}

View File

@@ -0,0 +1,53 @@
// Path: Backend/internal/httpx/api_routes.go
package httpx
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type apiRoutes struct {
cfg RouterConfig
}
func newAPIRoutes(cfg RouterConfig) routeRegistrar {
return apiRoutes{cfg: cfg}
}
func (routes apiRoutes) Register(router chi.Router) {
router.Route("/v1", func(apiRouter chi.Router) {
apiRouter.Get("/", routes.handleIndex)
apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces)
})
}
func (routes apiRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"version": "v1",
"status": "scaffolded",
})
}
func (routes apiRoutes) handleOrganizations(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": []any{},
"meta": map[string]any{
"resource": "organizations",
"count": 0,
},
})
}
func (routes apiRoutes) handleWorkspaces(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"data": []any{},
"meta": map[string]any{
"resource": "workspaces",
"count": 0,
},
})
}

View File

@@ -0,0 +1,80 @@
// Path: Backend/internal/httpx/middleware.go
package httpx
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
)
type contextKey string
const requestIDContextKey contextKey = "requestID"
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
func (r *statusRecorder) WriteHeader(statusCode int) {
r.statusCode = statusCode
r.ResponseWriter.WriteHeader(statusCode)
}
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestID := uuid.NewString()
ctx := context.WithValue(r.Context(), requestIDContextKey, requestID)
w.Header().Set("X-Request-ID", requestID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
requestID := RequestIDFromContext(r.Context())
logger.Error("panic recovered", "request_id", requestID, "panic", recovered, "path", r.URL.Path)
WriteError(w, http.StatusInternalServerError, requestID, "internal_error", "An unexpected error occurred.")
}
}()
next.ServeHTTP(w, r)
})
}
}
func RequestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
recorder := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(recorder, r)
logger.Info(
"http request",
"request_id", RequestIDFromContext(r.Context()),
"method", r.Method,
"path", r.URL.Path,
"status", recorder.statusCode,
"duration", time.Since(startedAt).String(),
)
})
}
}
func RequestIDFromContext(ctx context.Context) string {
requestID, ok := ctx.Value(requestIDContextKey).(string)
if !ok {
return ""
}
return requestID
}

View File

@@ -0,0 +1,33 @@
// Path: Backend/internal/httpx/response.go
package httpx
import (
"encoding/json"
"net/http"
)
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
RequestID string `json:"requestId,omitempty"`
}
func WriteJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
if payload == nil {
return
}
_ = json.NewEncoder(w).Encode(payload)
}
func WriteError(w http.ResponseWriter, status int, requestID, code, message string) {
WriteJSON(w, status, ErrorResponse{
Error: code,
Message: message,
RequestID: requestID,
})
}

View File

@@ -0,0 +1,70 @@
// Path: Backend/internal/httpx/router.go
package httpx
import (
"log/slog"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"moku-backend/internal/buildinfo"
"moku-backend/internal/cache"
"moku-backend/internal/config"
"moku-backend/internal/database"
)
type RouterConfig struct {
ServiceName string
Config *config.Config
Logger *slog.Logger
BuildInfo buildinfo.Info
Database *database.DB
Cache *cache.Client
}
type routeRegistrar interface {
Register(chi.Router)
}
func NewRouter(cfg RouterConfig) http.Handler {
router := chi.NewRouter()
registerMiddleware(router, cfg)
registerRoutes(router, routesForService(cfg)...)
router.NotFound(notFoundHandler)
return router
}
func registerMiddleware(router chi.Router, cfg RouterConfig) {
router.Use(chimiddleware.RealIP)
router.Use(RequestID)
router.Use(Recoverer(cfg.Logger))
router.Use(RequestLogger(cfg.Logger))
}
func registerRoutes(router chi.Router, registrars ...routeRegistrar) {
for _, registrar := range registrars {
registrar.Register(router)
}
}
func routesForService(cfg RouterConfig) []routeRegistrar {
registrars := []routeRegistrar{newSharedRoutes(cfg)}
switch strings.ToLower(cfg.ServiceName) {
case "web":
registrars = append(registrars, newWebRoutes(cfg))
case "api":
registrars = append(registrars, newAPIRoutes(cfg))
}
return registrars
}
func notFoundHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
}

View File

@@ -0,0 +1,69 @@
// Path: Backend/internal/httpx/shared_routes.go
package httpx
import (
"context"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
type sharedRoutes struct {
cfg RouterConfig
}
func newSharedRoutes(cfg RouterConfig) routeRegistrar {
return sharedRoutes{cfg: cfg}
}
func (routes sharedRoutes) Register(router chi.Router) {
router.Get("/", routes.handleIndex)
router.Get("/health", routes.handleHealth)
router.Get("/version", routes.handleVersion)
}
func (routes sharedRoutes) handleIndex(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{
"service": routes.cfg.ServiceName,
"status": "ok",
})
}
func (routes sharedRoutes) handleHealth(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
databaseStatus := "ok"
if err := routes.cfg.Database.Health(ctx); err != nil {
databaseStatus = err.Error()
}
cacheStatus := "ok"
if err := routes.cfg.Cache.Health(ctx); err != nil {
cacheStatus = err.Error()
}
statusCode := http.StatusOK
if databaseStatus != "ok" || cacheStatus != "ok" {
statusCode = http.StatusServiceUnavailable
}
WriteJSON(w, statusCode, map[string]any{
"service": routes.cfg.ServiceName,
"status": map[string]string{
"database": databaseStatus,
"cache": cacheStatus,
},
})
}
func (routes sharedRoutes) handleVersion(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"app": routes.cfg.Config.AppName,
"environment": routes.cfg.Config.Environment,
"build": routes.cfg.BuildInfo,
})
}

View File

@@ -0,0 +1,51 @@
// Path: Backend/internal/httpx/web_routes.go
package httpx
import (
"net/http"
"github.com/go-chi/chi/v5"
)
type webRoutes struct {
cfg RouterConfig
}
func newWebRoutes(cfg RouterConfig) routeRegistrar {
return webRoutes{cfg: cfg}
}
func (routes webRoutes) Register(router chi.Router) {
router.Get("/session", routes.handleSession)
router.Get("/bootstrap", routes.handleBootstrap)
router.Get("/me", routes.handleCurrentUser)
}
func (routes webRoutes) handleSession(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"session": map[string]any{
"authenticated": false,
"mode": "cookie",
},
})
}
func (routes webRoutes) handleBootstrap(w http.ResponseWriter, _ *http.Request) {
WriteJSON(w, http.StatusOK, map[string]any{
"service": routes.cfg.ServiceName,
"app": map[string]any{
"name": routes.cfg.Config.AppName,
"environment": routes.cfg.Config.Environment,
},
"features": map[string]bool{
"auth": false,
"workspaces": false,
},
})
}
func (routes webRoutes) handleCurrentUser(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotImplemented, RequestIDFromContext(r.Context()), "not_implemented", "The current user endpoint is scaffolded but not implemented yet.")
}

View File

@@ -0,0 +1,65 @@
// Path: Backend/internal/process/process.go
package process
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func RunHTTPServer(serviceName, address string, handler http.Handler, logger *slog.Logger, shutdownTimeout time.Duration) error {
server := &http.Server{
Addr: address,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(chan error, 1)
go func() {
logger.Info("http server starting", "service", serviceName, "address", address)
serverErr <- server.ListenAndServe()
}()
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
select {
case err := <-serverErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
case <-signalCtx.Done():
logger.Info("shutdown requested", "service", serviceName)
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown %s server: %w", serviceName, err)
}
logger.Info("http server stopped", "service", serviceName)
return nil
}
func WaitForShutdown(serviceName string, logger *slog.Logger) error {
signalCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
logger.Info("process running", "service", serviceName)
<-signalCtx.Done()
logger.Info("shutdown requested", "service", serviceName)
return nil
}

12
Backend/sqlc.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: "2"
sql:
- engine: "postgresql"
schema: "db/migrations"
queries: "db/queries"
gen:
go:
package: "sqlc"
out: "internal/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_empty_slices: true

View File

@@ -0,0 +1,26 @@
project_root := justfile_directory()
backend_dir := project_root + "/Backend"
# Apply embedded database migrations.
migrate-up:
cd '{{backend_dir}}' && go run ./cmd/migrate up
# Roll back the most recent embedded database migration.
migrate-down:
cd '{{backend_dir}}' && go run ./cmd/migrate down
# Reset all embedded database migrations and reapply from scratch.
migrate-reset:
cd '{{backend_dir}}' && go run ./cmd/migrate reset
# Show the embedded database migration status.
migrate-status:
cd '{{backend_dir}}' && go run ./cmd/migrate status
# Format backend Go source files.
fmt:
cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal
# Run backend test suite.
test:
cd '{{backend_dir}}' && go test ./...

View File

@@ -0,0 +1,14 @@
project_root := justfile_directory()
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
frontend_dir := project_root + "/Frontend"
node_modules_volume := "moku_work_frontend_node_modules"
# Recreate the frontend node_modules Docker volume.
node_modules:
docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true
docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend
# Run the frontend TypeScript check.
tsc:
cd '{{frontend_dir}}' && pnpm typecheck

View File

@@ -0,0 +1,39 @@
stack_runner := justfile_directory() + "/Commands/Local/Dev/scripts/dev-stack.sh"
mod frontend
mod backend
# Build the combined local development stack assets.
build:
bash '{{stack_runner}}' build
# Start the full local development stack in the background.
up:
bash '{{stack_runner}}' up
# Build first, then start the full local development stack in the background.
start:
bash '{{stack_runner}}' start
# Alias for the main full local development flow.
dev: up
# Stop and remove the local development stack.
down:
bash '{{stack_runner}}' down
# Rebuild the full local development stack.
rebuild:
bash '{{stack_runner}}' rebuild
# Follow logs for the full local development stack.
logs:
bash '{{stack_runner}}' logs
# Restart the full local development stack.
restart:
bash '{{stack_runner}}' restart
# Stop the local development stack and remove local images, volumes, and backend dev state.
clean:
bash '{{stack_runner}}' clean

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env bash
# Path: Commands/Local/Dev/scripts/backend-stack.sh
set -euo pipefail
action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd)
backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
runtime_dir="$backend_dir/tmp/dev"
backend_image="moku/work-backend:dev"
backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build"
services=(web api worker)
source "$script_dir/docker.sh"
source "$script_dir/env.sh"
build_backend() {
cd "$backend_dir"
docker buildx bake -f "$backend_bake" dev
}
up_backend() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}"
}
down_backend() {
docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true
docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true
}
restart_backend() {
docker compose -f "$compose_file" restart "${services[@]}"
}
follow_logs() {
docker compose -f "$compose_file" logs -f "${services[@]}"
}
clean_runtime() {
rm -rf "$runtime_dir"
}
case "$action" in
check)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
;;
build)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
build_backend
;;
up)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
up_backend
;;
down)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
down_backend
;;
restart)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
restart_backend
;;
logs)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
follow_logs
;;
clean)
ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.'
ensure_local_env_file "$env_dir"
down_backend
remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$backend_go_pkg_volume"
remove_docker_volume_if_present "$backend_go_build_volume"
clean_runtime
;;
*)
printf 'Unsupported backend stack action: %s\n' "$action" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Path: Commands/Local/Dev/scripts/dev-stack.sh
set -euo pipefail
action=${1:-up}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
project_root=$(cd -- "$script_dir/../../../.." && pwd)
frontend_dir="$project_root/Frontend"
frontend_bake="$frontend_dir/docker-bake.hcl"
backend_dir="$project_root/Backend"
backend_bake="$backend_dir/docker-bake.hcl"
env_dir="$project_root/Env"
compose_file="$project_root/Docker/docker-compose.local.dev.yaml"
frontend_image="moku/work-frontend:dev"
backend_image="moku/work-backend:dev"
frontend_volume="moku_work_frontend_node_modules"
backend_go_pkg_volume="moku_work_backend_go_pkg"
backend_go_build_volume="moku_work_backend_go_build"
backend_runtime_dir="$backend_dir/tmp/dev"
source "$script_dir/docker.sh"
source "$script_dir/env.sh"
build_frontend() {
cd "$frontend_dir"
docker buildx bake -f "$frontend_bake" dev
}
build_backend() {
cd "$backend_dir"
docker buildx bake -f "$backend_bake" dev
}
build_images() {
build_frontend
build_backend
}
up_stack() {
docker compose -f "$compose_file" up -d --remove-orphans --force-recreate
}
down_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes
}
follow_logs() {
docker compose -f "$compose_file" logs -f
}
clean_stack() {
docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true
remove_docker_image_if_present "$frontend_image"
remove_docker_image_if_present "$backend_image"
remove_docker_volume_if_present "$frontend_volume"
remove_docker_volume_if_present "$backend_go_pkg_volume"
remove_docker_volume_if_present "$backend_go_build_volume"
rm -rf "$backend_runtime_dir"
}
start_stack() {
build_images
up_stack
}
case "$action" in
build)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
build_images
;;
up|start|rebuild)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
start_stack
;;
down)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
down_stack
;;
restart)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
docker compose -f "$compose_file" restart
;;
logs)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
follow_logs
;;
clean)
ensure_docker 'docker is required for the local development stack. Install Docker first.'
ensure_local_env_file "$env_dir"
clean_stack
;;
*)
printf 'Unsupported dev stack action: %s\n' "$action" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_docker() {
local error_message=${1:-docker is required. Install Docker first.}
if ! command -v docker >/dev/null 2>&1; then
printf '%s\n' "$error_message" >&2
exit 1
fi
}
remove_docker_image_if_present() {
docker image rm -f "$1" >/dev/null 2>&1 || true
}
remove_docker_volume_if_present() {
docker volume rm -f "$1" >/dev/null 2>&1 || true
}

View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
ensure_local_env_file() {
local env_dir=$1
local example_env_file="$env_dir/.env.example"
local local_env_file="$env_dir/.env.local"
if [[ -f "$local_env_file" ]]; then
return
fi
if [[ ! -f "$example_env_file" ]]; then
printf 'Missing env template: %s\n' "$example_env_file" >&2
exit 1
fi
cp "$example_env_file" "$local_env_file"
printf 'Created %s from %s\n' "$local_env_file" "$example_env_file"
}

View File

@@ -1,36 +0,0 @@
project_root := justfile_directory()
frontend_dir := project_root + "/Frontend"
frontend_bake := project_root + "/Frontend/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.dev.yaml"
# Build the Frontend development image.
build:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev
# Start the local development stack in the background using the current image.
up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local development stack in the background.
start: build
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Alias for the main local development flow.
dev: start
# Stop and remove the local development stack.
down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Rebuild the Frontend development image and recreate the stack.
rebuild:
cd '{{frontend_dir}}' && docker buildx bake -f '{{frontend_bake}}' dev
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Follow logs for the local development stack.
logs:
docker compose -f '{{local_compose}}' logs -f
# Restart the local development stack.
restart:
docker compose -f '{{local_compose}}' restart

View File

@@ -1,2 +1,2 @@
mod dev
mod dev "Dev"
mod prod

View File

@@ -1,32 +1,37 @@
project_root := justfile_directory()
proxy_bake := project_root + "/Proxy/docker-bake.hcl"
local_compose := project_root + "/Docker/docker-compose.local.prod.yaml"
proxy_image := "moku/work-proxy:local-prod"
# Build the local production proxy image locally.
build:
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod
# Start the local production stack in the background using the current image.
up:
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Build first, then start the local production stack in the background.
start: build
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
start: build up
# Rebuild the local production proxy image locally.
rebuild:
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod
docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate
# Stop and remove the local production stack.
down:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
# Follow logs for the local production stack.
logs:
docker compose -f '{{local_compose}}' logs -f
docker compose -f '{{local_compose}}' logs -f
# Restart the local production stack.
restart:
docker compose -f '{{local_compose}}' restart
docker compose -f '{{local_compose}}' restart
# Stop the local production stack and remove local images.
clean:
docker compose -f '{{local_compose}}' down --remove-orphans --volumes
docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true

View File

View File

@@ -1,15 +1,95 @@
x-backend-service: &backend-service
image: moku/work-backend:dev
restart: unless-stopped
env_file:
- ../Env/.env.local
environment:
DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable
VALKEY_URL: redis://valkey:6379/0
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
volumes:
- ../Backend:/app
- moku_work_backend_go_pkg:/go/pkg/mod
- moku_work_backend_go_build:/root/.cache/go-build
services:
frontend:
image: moku/work-frontend:dev
container_name: moku-work-frontend
restart: unless-stopped
env_file:
- ../Env/.env.local
ports:
- "3333:3333"
volumes:
- ../Frontend:/app
- moku_work_frontend_node_modules:/app/node_modules
postgres:
image: postgres:17-alpine
container_name: moku-work-postgres
restart: unless-stopped
environment:
POSTGRES_DB: moku
POSTGRES_USER: moku
POSTGRES_PASSWORD: moku_dev_password
ports:
- "5432:5432"
volumes:
- moku_work_postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U moku -d moku"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
valkey:
image: valkey/valkey:8-alpine
container_name: moku-work-valkey
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- moku_work_valkey_data:/data
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
frontend:
image: moku/work-frontend:dev
container_name: moku-work-frontend
restart: unless-stopped
env_file:
- ../Env/.env.local
depends_on:
postgres:
condition: service_healthy
valkey:
condition: service_healthy
ports:
- "3333:3333"
volumes:
- ../Frontend:/app
- moku_work_frontend_node_modules:/app/node_modules
web:
<<: *backend-service
container_name: moku-work-backend-web
command: ["air", "-c", ".air.web.toml"]
ports:
- "8080:8080"
api:
<<: *backend-service
container_name: moku-work-backend-api
command: ["air", "-c", ".air.api.toml"]
ports:
- "8081:8081"
worker:
<<: *backend-service
container_name: moku-work-backend-worker
command: ["air", "-c", ".air.worker.toml"]
volumes:
moku_work_frontend_node_modules:
moku_work_postgres_data:
moku_work_valkey_data:
moku_work_frontend_node_modules:
moku_work_backend_go_pkg:
moku_work_backend_go_build:

View File

@@ -9,8 +9,8 @@ This project is still in an early scaffold stage, so the goal is to keep changes
### Project structure
- `Frontend/` — SolidStart frontend workspace
- `Backend/` — backend placeholder
- `Proxy/` — proxy placeholder
- `Backend/` — Go backend services (`web`, `api`, `worker`)
- `Proxy/` — local production proxy/runtime assets
- `Docker/` — local Docker Compose files
- `Env/` — local environment files
- `Commands/` — Just command modules and entrypoints
@@ -39,7 +39,8 @@ Main local development flow:
just local dev
```
This command builds the frontend development image and starts the local development stack.
This command builds the frontend and backend development images, then starts the
local development stack for Postgres, Valkey, frontend, and backend services.
### Local environment
@@ -49,7 +50,16 @@ Local development uses:
Env/.env.local
```
If local environment values are missing, create or update that file before starting the stack.
The template lives at:
```bash
Env/.env.example
```
When you start the local Docker stack, the dev scripts will create `Env/.env.local`
from `Env/.env.example` automatically if it does not already exist.
If you need custom local values, edit `Env/.env.local` after it is created.
## Commit Naming Convention

View File

@@ -66,6 +66,14 @@
- [ ] Table
- [ ] CVA
- [ ] Storyboard
- [ ] Theme-System
- [ ] Theme-Registry
- [ ] Built-In-Theme-Presets
- [ ] Active-Theme-Persistence
- [ ] Theme-Switcher
- [ ] Theme-JSON-Upload
- [ ] Theme-JSON-Import-Validation
- [ ] Community-Theme-Readiness
### Version 0.3.0

13
Env/.env.example Normal file
View File

@@ -0,0 +1,13 @@
ALLOWED_HOSTS=
APP_NAME=moku
GO_ENV=development
LOG_LEVEL=debug
BACKEND_WEB_PORT=8080
BACKEND_API_PORT=8081
BACKEND_WORKER_PORT=8082
BACKEND_SHUTDOWN_TIMEOUT=10s
DATABASE_URL=postgres://moku:moku_dev_password@localhost:5432/moku?sslmode=disable
VALKEY_URL=redis://localhost:6379/0

View File

@@ -0,0 +1,170 @@
{
"schemaVersion": "1.0.0",
"id": "moku-default",
"name": "Moku Default",
"description": "Baseline Moku shell tokens for built-in light and dark modes.",
"tokens": {
"shared": {
"palette": {
"gray": {
"0": "hsl(210 20% 99%)",
"50": "hsl(220 20% 97%)",
"100": "hsl(220 16% 93%)",
"200": "hsl(220 13% 87%)",
"300": "hsl(220 11% 75%)",
"400": "hsl(220 9% 58%)",
"500": "hsl(220 10% 45%)",
"600": "hsl(220 14% 34%)",
"700": "hsl(220 18% 24%)",
"800": "hsl(220 22% 16%)",
"900": "hsl(220 28% 10%)"
},
"blue": {
"400": "hsl(218 88% 61%)",
"500": "hsl(221 83% 53%)",
"600": "hsl(224 76% 48%)"
},
"green": {
"500": "hsl(154 60% 40%)"
},
"red": {
"500": "hsl(0 72% 54%)"
},
"amber": {
"500": "hsl(36 100% 50%)"
}
},
"space": {
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"8": "2rem",
"10": "2.5rem",
"12": "3rem"
},
"radius": {
"sm": "0.375rem",
"md": "0.625rem",
"lg": "0.875rem",
"xl": "1.25rem",
"pill": "999px"
},
"size": {
"controlMd": "2.25rem",
"controlLg": "2.5rem",
"contentWidthWide": "72rem",
"blurOverlay": "18px"
},
"shadow": {
"soft": "0 12px 32px hsl(220 30% 10% / 0.08)",
"strong": "0 20px 48px hsl(220 30% 10% / 0.16)"
},
"zIndex": {
"base": "1",
"dropdown": "100",
"sticky": "200",
"overlay": "400",
"modal": "500",
"toast": "600"
},
"motion": {
"durationFast": "140ms",
"durationBase": "220ms",
"durationSlow": "320ms",
"easeStandard": "cubic-bezier(0.2, 0.8, 0.2, 1)"
},
"typography": {
"fontFamily": {
"sans": "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"heading": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"display": "\"Avenir Next\", \"Segoe UI\", \"Helvetica Neue\", Arial, sans-serif",
"serif": "ui-serif, Georgia, Cambria, \"Times New Roman\", Times, serif",
"mono": "ui-monospace, \"SF Mono\", \"SFMono-Regular\", Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace"
},
"fontSize": {
"caption": "0.75rem",
"label": "0.875rem",
"body": "1rem",
"title": "clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem)",
"heading": "clamp(1.5rem, 1.2rem + 1vw, 2.125rem)",
"display": "clamp(2.25rem, 1.7rem + 2.2vw, 3.75rem)"
},
"lineHeight": {
"caption": "1.4",
"label": "1.35",
"body": "1.55",
"title": "1.3",
"heading": "1.15",
"display": "1.05"
},
"fontWeight": {
"caption": "500",
"label": "600",
"body": "400",
"title": "600",
"heading": "600",
"display": "700"
},
"letterSpacing": {
"caption": "0.01em",
"label": "0.005em",
"body": "0",
"title": "-0.01em",
"heading": "-0.02em",
"display": "-0.03em"
}
}
},
"modes": {
"light": {
"colorScheme": "light",
"colors": {
"canvas": "var(--gray-50)",
"surface": "hsl(0 0% 100% / 0.9)",
"surfaceMuted": "var(--gray-0)",
"surfaceHover": "var(--gray-100)",
"border": "hsl(220 15% 85% / 0.9)",
"borderStrong": "hsl(220 12% 70% / 0.9)",
"text": "var(--gray-800)",
"textMuted": "var(--gray-500)",
"accent": "var(--blue-500)",
"accentStrong": "var(--blue-600)",
"accentSoft": "hsl(218 88% 61% / 0.12)",
"accentContrast": "hsl(0 0% 100%)",
"success": "var(--green-500)",
"danger": "var(--red-500)",
"warning": "var(--amber-500)",
"focusRing": "hsl(221 83% 53% / 0.55)"
}
},
"dark": {
"colorScheme": "dark",
"colors": {
"canvas": "var(--gray-900)",
"surface": "hsl(220 23% 14% / 0.92)",
"surfaceMuted": "hsl(220 22% 12% / 0.96)",
"surfaceHover": "hsl(220 18% 20% / 0.96)",
"border": "hsl(220 12% 26% / 0.9)",
"borderStrong": "hsl(220 12% 38% / 0.9)",
"text": "hsl(210 20% 96%)",
"textMuted": "hsl(220 12% 70%)",
"accent": "hsl(217 91% 67%)",
"accentStrong": "hsl(218 88% 61%)",
"accentSoft": "hsl(217 91% 67% / 0.18)",
"accentContrast": "hsl(220 28% 10%)",
"success": "hsl(154 55% 48%)",
"danger": "hsl(0 72% 62%)",
"warning": "hsl(36 100% 60%)",
"focusRing": "hsl(217 91% 67% / 0.65)"
},
"shadow": {
"soft": "0 16px 40px hsl(220 40% 3% / 0.45)",
"strong": "0 24px 60px hsl(220 40% 3% / 0.55)"
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
// Path: Frontend/src/components/shell/AppShell/AppShell.tsx
import { createSignal, onMount, type JSX } from "solid-js";
import { getDocumentTheme, setTheme, type Theme } from "../../../helpers/theme";
import { getDocumentTheme, setTheme, type Theme } from "../../../theme/runtime";
import { WorkspaceHome } from "../../workspace-home/WorkspaceHome/WorkspaceHome";
import { LeftRail } from "../LeftRail/LeftRail";
import { ProfileDock } from "../ProfileDock/ProfileDock";

View File

@@ -1,8 +1,8 @@
// Path: Frontend/src/components/shell/TopBar/TopBar.tsx
import { For, type JSX } from "solid-js";
import type { Theme } from "../../../helpers/theme";
import { ChevronDown } from "../../../lib/icons";
import type { Theme } from "../../../theme/runtime";
import { topBarActions } from "../data/shell.data";
import styles from "./TopBar.module.scss";

View File

@@ -1,8 +1,9 @@
// Path: Frontend/src/entry-client.tsx
// @refresh reload
import type { JSX } from "solid-js";
import { mount, StartClient } from "@solidjs/start/client";
import type { JSX } from "solid-js";
import { initializeThemeRuntime } from "./theme/runtime";
const getAppRoot = (): HTMLElement => {
const appRoot = document.getElementById("app");
@@ -18,4 +19,6 @@ const mountApp = (): void => {
mount((): JSX.Element => <StartClient />, getAppRoot());
};
void initializeThemeRuntime();
mountApp();

View File

@@ -3,19 +3,21 @@
// @refresh reload
import type { JSX } from "solid-js";
import { createHandler, StartServer } from "@solidjs/start/server";
import { DEFAULT_THEME, THEME_STORAGE_KEY } from "./theme/runtime";
import { THEME_MODE_NAMES } from "./theme/schema";
const themeBootstrapScript = `
(() => {
try {
const storageKey = "theme";
const storageKey = ${JSON.stringify(THEME_STORAGE_KEY)};
const stored = localStorage.getItem(storageKey);
const theme = stored === "light" || stored === "dark"
const theme = stored === ${JSON.stringify(THEME_MODE_NAMES[0])} || stored === ${JSON.stringify(THEME_MODE_NAMES[1])}
? stored
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
: (window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(THEME_MODE_NAMES[1])} : ${JSON.stringify(DEFAULT_THEME)});
document.documentElement.setAttribute("data-theme", theme);
} catch {
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.setAttribute("data-theme", ${JSON.stringify(DEFAULT_THEME)});
}
})();
`;
@@ -28,7 +30,7 @@ type DocumentRenderProps = {
const renderDocument = ({ assets, children, scripts }: DocumentRenderProps): JSX.Element => {
return (
<html lang="en" data-theme="light">
<html lang="en" data-theme={DEFAULT_THEME}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@@ -1,22 +0,0 @@
// Path: Frontend/src/helpers/theme.ts
export type Theme = "light" | "dark";
export const THEME_STORAGE_KEY = "theme";
export const resolvePreferredTheme = (): Theme => {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "light" || stored === "dark") {
return stored;
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
};
export const getDocumentTheme = (): Theme => (document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light");
export const setTheme = (theme: Theme): void => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem(THEME_STORAGE_KEY, theme);
};

View File

@@ -0,0 +1,11 @@
// Path: Frontend/src/theme/presets.ts
import type { ThemeDefinition } from "./schema";
export const defaultThemePresetPath = "/themes/moku-default.json";
export const defaultThemePresetMeta = {
id: "moku-default",
name: "Moku Default",
description: "The baseline Moku theme preset, matching the current shell styling tokens.",
} satisfies Pick<ThemeDefinition, "id" | "name" | "description">;

View File

@@ -0,0 +1,129 @@
// Path: Frontend/src/theme/runtime.ts
import { defaultThemePresetPath } from "./presets";
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
export type Theme = ThemeModeName;
export const THEME_STORAGE_KEY = "theme";
export const DEFAULT_THEME: Theme = "light";
let activeThemeDefinition: ThemeDefinition | null = null;
let themeInitializationPromise: Promise<ThemeDefinition | null> | null = null;
const canUseDom = (): boolean => typeof document !== "undefined";
const canUseStorage = (): boolean => typeof localStorage !== "undefined";
const canUseMatchMedia = (): boolean => typeof window !== "undefined" && typeof window.matchMedia === "function";
const getRootElement = (): HTMLElement | null => {
return canUseDom() ? document.documentElement : null;
};
const setDocumentThemeMode = (theme: Theme): void => {
const rootElement = getRootElement();
rootElement?.setAttribute("data-theme", theme);
if (rootElement) {
rootElement.style.colorScheme = theme;
}
if (canUseStorage()) {
localStorage.setItem(THEME_STORAGE_KEY, theme);
}
};
const applyThemeVariables = (themeDefinition: ThemeDefinition, theme: Theme): void => {
const rootElement = getRootElement();
if (!rootElement) {
return;
}
const variableMap = createCssVariableMap(themeDefinition, theme);
for (const [name, value] of Object.entries(variableMap)) {
rootElement.style.setProperty(name, value);
}
};
export const resolvePreferredTheme = (): Theme => {
const stored = canUseStorage() ? localStorage.getItem(THEME_STORAGE_KEY) : null;
if (isThemeModeName(stored)) {
return stored;
}
if (canUseMatchMedia()) {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : DEFAULT_THEME;
}
return DEFAULT_THEME;
};
export const getDocumentTheme = (): Theme => {
const theme = getRootElement()?.getAttribute("data-theme");
return isThemeModeName(theme) ? theme : resolvePreferredTheme();
};
export const applyThemeDefinition = (themeDefinition: ThemeDefinition, theme: Theme): void => {
activeThemeDefinition = themeDefinition;
setDocumentThemeMode(theme);
applyThemeVariables(themeDefinition, theme);
};
export const initializeThemeRuntime = async (): Promise<ThemeDefinition | null> => {
if (typeof window === "undefined") {
return null;
}
if (activeThemeDefinition) {
applyThemeDefinition(activeThemeDefinition, getDocumentTheme());
return activeThemeDefinition;
}
if (!themeInitializationPromise) {
themeInitializationPromise = (async (): Promise<ThemeDefinition | null> => {
try {
const response = await fetch(defaultThemePresetPath, {
headers: {
Accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`Theme preset request failed with status ${response.status}.`);
}
const candidate = (await response.json()) as unknown;
const result = validateThemeDefinition(candidate);
if (!result.success) {
throw new Error(result.errors.join(" "));
}
applyThemeDefinition(result.data, getDocumentTheme());
return result.data;
} catch (error) {
console.error("Failed to initialize theme runtime.", error);
return null;
} finally {
themeInitializationPromise = null;
}
})();
}
return themeInitializationPromise;
};
export const setTheme = (theme: Theme): void => {
setDocumentThemeMode(theme);
if (activeThemeDefinition) {
applyThemeVariables(activeThemeDefinition, theme);
}
};

View File

@@ -0,0 +1,342 @@
// Path: Frontend/src/theme/schema.ts
export const THEME_SCHEMA_VERSION = "1.0.0";
export const THEME_MODE_NAMES = ["light", "dark"] as const;
export const THEME_PALETTE_KEYS = {
gray: ["0", "50", "100", "200", "300", "400", "500", "600", "700", "800", "900"],
blue: ["400", "500", "600"],
green: ["500"],
red: ["500"],
amber: ["500"],
} as const;
export const THEME_SPACE_KEYS = ["1", "2", "3", "4", "5", "6", "8", "10", "12"] as const;
export const THEME_RADIUS_KEYS = ["sm", "md", "lg", "xl", "pill"] as const;
export const THEME_SIZE_KEYS = ["controlMd", "controlLg", "contentWidthWide", "blurOverlay"] as const;
export const THEME_SHADOW_KEYS = ["soft", "strong"] as const;
export const THEME_Z_INDEX_KEYS = ["base", "dropdown", "sticky", "overlay", "modal", "toast"] as const;
export const THEME_MOTION_KEYS = ["durationFast", "durationBase", "durationSlow", "easeStandard"] as const;
export const THEME_TYPE_SCALE_KEYS = ["caption", "label", "body", "title", "heading", "display"] as const;
export const THEME_FONT_FAMILY_KEYS = ["sans", "heading", "display", "serif", "mono"] as const;
export const THEME_MODE_COLOR_KEYS = ["canvas", "surface", "surfaceMuted", "surfaceHover", "border", "borderStrong", "text", "textMuted", "accent", "accentStrong", "accentSoft", "accentContrast", "success", "danger", "warning", "focusRing"] as const;
export type ThemeModeName = (typeof THEME_MODE_NAMES)[number];
type ThemeTokenMap<TKey extends string> = Record<TKey, string>;
export type ThemeSharedTokens = {
palette: {
gray: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.gray)[number]>;
blue: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.blue)[number]>;
green: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.green)[number]>;
red: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.red)[number]>;
amber: ThemeTokenMap<(typeof THEME_PALETTE_KEYS.amber)[number]>;
};
space: ThemeTokenMap<(typeof THEME_SPACE_KEYS)[number]>;
radius: ThemeTokenMap<(typeof THEME_RADIUS_KEYS)[number]>;
size: ThemeTokenMap<(typeof THEME_SIZE_KEYS)[number]>;
shadow: ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>;
zIndex: ThemeTokenMap<(typeof THEME_Z_INDEX_KEYS)[number]>;
motion: ThemeTokenMap<(typeof THEME_MOTION_KEYS)[number]>;
typography: {
fontFamily: ThemeTokenMap<(typeof THEME_FONT_FAMILY_KEYS)[number]>;
fontSize: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
lineHeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
fontWeight: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
letterSpacing: ThemeTokenMap<(typeof THEME_TYPE_SCALE_KEYS)[number]>;
};
};
export type ThemeModeTokens = {
colorScheme: ThemeModeName;
colors: ThemeTokenMap<(typeof THEME_MODE_COLOR_KEYS)[number]>;
shadow?: Partial<ThemeTokenMap<(typeof THEME_SHADOW_KEYS)[number]>>;
};
export type ThemeDefinition = {
schemaVersion: typeof THEME_SCHEMA_VERSION;
id: string;
name: string;
description?: string;
author?: string;
tokens: {
shared: ThemeSharedTokens;
modes: Record<ThemeModeName, ThemeModeTokens>;
};
};
export type ThemeValidationResult =
| {
success: true;
data: ThemeDefinition;
}
| {
success: false;
errors: string[];
};
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
const isNonEmptyString = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0;
const getNestedRecord = (value: unknown, path: string, errors: string[]): Record<string, unknown> | null => {
if (!isRecord(value)) {
errors.push(`${path} must be an object.`);
return null;
}
return value;
};
const validateRequiredStringMap = (record: Record<string, unknown>, path: string, requiredKeys: readonly string[], errors: string[]): void => {
for (const key of requiredKeys) {
if (!isNonEmptyString(record[key])) {
errors.push(`${path}.${key} must be a non-empty string token value.`);
}
}
};
export const isThemeModeName = (value: unknown): value is ThemeModeName => typeof value === "string" && THEME_MODE_NAMES.includes(value as ThemeModeName);
export const validateThemeDefinition = (candidate: unknown): ThemeValidationResult => {
const errors: string[] = [];
const root = getNestedRecord(candidate, "theme", errors);
if (!root) {
return { success: false, errors };
}
if (root.schemaVersion !== THEME_SCHEMA_VERSION) {
errors.push(`theme.schemaVersion must be ${THEME_SCHEMA_VERSION}.`);
}
if (!isNonEmptyString(root.id)) {
errors.push("theme.id must be a non-empty string.");
}
if (!isNonEmptyString(root.name)) {
errors.push("theme.name must be a non-empty string.");
}
if (root.description !== undefined && !isNonEmptyString(root.description)) {
errors.push("theme.description must be a non-empty string when provided.");
}
if (root.author !== undefined && !isNonEmptyString(root.author)) {
errors.push("theme.author must be a non-empty string when provided.");
}
const tokens = getNestedRecord(root.tokens, "theme.tokens", errors);
const shared = tokens ? getNestedRecord(tokens.shared, "theme.tokens.shared", errors) : null;
const modes = tokens ? getNestedRecord(tokens.modes, "theme.tokens.modes", errors) : null;
if (shared) {
const palette = getNestedRecord(shared.palette, "theme.tokens.shared.palette", errors);
const space = getNestedRecord(shared.space, "theme.tokens.shared.space", errors);
const radius = getNestedRecord(shared.radius, "theme.tokens.shared.radius", errors);
const size = getNestedRecord(shared.size, "theme.tokens.shared.size", errors);
const shadow = getNestedRecord(shared.shadow, "theme.tokens.shared.shadow", errors);
const zIndex = getNestedRecord(shared.zIndex, "theme.tokens.shared.zIndex", errors);
const motion = getNestedRecord(shared.motion, "theme.tokens.shared.motion", errors);
const typography = getNestedRecord(shared.typography, "theme.tokens.shared.typography", errors);
if (palette) {
for (const [paletteName, keys] of Object.entries(THEME_PALETTE_KEYS)) {
const paletteScale = getNestedRecord(palette[paletteName], `theme.tokens.shared.palette.${paletteName}`, errors);
if (paletteScale) {
validateRequiredStringMap(paletteScale, `theme.tokens.shared.palette.${paletteName}`, keys, errors);
}
}
}
if (space) {
validateRequiredStringMap(space, "theme.tokens.shared.space", THEME_SPACE_KEYS, errors);
}
if (radius) {
validateRequiredStringMap(radius, "theme.tokens.shared.radius", THEME_RADIUS_KEYS, errors);
}
if (size) {
validateRequiredStringMap(size, "theme.tokens.shared.size", THEME_SIZE_KEYS, errors);
}
if (shadow) {
validateRequiredStringMap(shadow, "theme.tokens.shared.shadow", THEME_SHADOW_KEYS, errors);
}
if (zIndex) {
validateRequiredStringMap(zIndex, "theme.tokens.shared.zIndex", THEME_Z_INDEX_KEYS, errors);
}
if (motion) {
validateRequiredStringMap(motion, "theme.tokens.shared.motion", THEME_MOTION_KEYS, errors);
}
if (typography) {
const fontFamily = getNestedRecord(typography.fontFamily, "theme.tokens.shared.typography.fontFamily", errors);
const fontSize = getNestedRecord(typography.fontSize, "theme.tokens.shared.typography.fontSize", errors);
const lineHeight = getNestedRecord(typography.lineHeight, "theme.tokens.shared.typography.lineHeight", errors);
const fontWeight = getNestedRecord(typography.fontWeight, "theme.tokens.shared.typography.fontWeight", errors);
const letterSpacing = getNestedRecord(typography.letterSpacing, "theme.tokens.shared.typography.letterSpacing", errors);
if (fontFamily) {
validateRequiredStringMap(fontFamily, "theme.tokens.shared.typography.fontFamily", THEME_FONT_FAMILY_KEYS, errors);
}
if (fontSize) {
validateRequiredStringMap(fontSize, "theme.tokens.shared.typography.fontSize", THEME_TYPE_SCALE_KEYS, errors);
}
if (lineHeight) {
validateRequiredStringMap(lineHeight, "theme.tokens.shared.typography.lineHeight", THEME_TYPE_SCALE_KEYS, errors);
}
if (fontWeight) {
validateRequiredStringMap(fontWeight, "theme.tokens.shared.typography.fontWeight", THEME_TYPE_SCALE_KEYS, errors);
}
if (letterSpacing) {
validateRequiredStringMap(letterSpacing, "theme.tokens.shared.typography.letterSpacing", THEME_TYPE_SCALE_KEYS, errors);
}
}
}
if (modes) {
for (const modeName of THEME_MODE_NAMES) {
const mode = getNestedRecord(modes[modeName], `theme.tokens.modes.${modeName}`, errors);
if (!mode) {
continue;
}
if (mode.colorScheme !== modeName) {
errors.push(`theme.tokens.modes.${modeName}.colorScheme must be ${modeName}.`);
}
const colors = getNestedRecord(mode.colors, `theme.tokens.modes.${modeName}.colors`, errors);
if (colors) {
validateRequiredStringMap(colors, `theme.tokens.modes.${modeName}.colors`, THEME_MODE_COLOR_KEYS, errors);
}
if (mode.shadow !== undefined) {
const modeShadow = getNestedRecord(mode.shadow, `theme.tokens.modes.${modeName}.shadow`, errors);
if (modeShadow) {
for (const key of THEME_SHADOW_KEYS) {
if (modeShadow[key] !== undefined && !isNonEmptyString(modeShadow[key])) {
errors.push(`theme.tokens.modes.${modeName}.shadow.${key} must be a non-empty string token value when provided.`);
}
}
}
}
}
}
if (errors.length > 0) {
return { success: false, errors };
}
return { success: true, data: candidate as ThemeDefinition };
};
export const createCssVariableMap = (theme: ThemeDefinition, mode: ThemeModeName): Record<string, string> => {
const shared = theme.tokens.shared;
const modeTokens = theme.tokens.modes[mode];
return {
"--gray-0": shared.palette.gray["0"],
"--gray-50": shared.palette.gray["50"],
"--gray-100": shared.palette.gray["100"],
"--gray-200": shared.palette.gray["200"],
"--gray-300": shared.palette.gray["300"],
"--gray-400": shared.palette.gray["400"],
"--gray-500": shared.palette.gray["500"],
"--gray-600": shared.palette.gray["600"],
"--gray-700": shared.palette.gray["700"],
"--gray-800": shared.palette.gray["800"],
"--gray-900": shared.palette.gray["900"],
"--blue-400": shared.palette.blue["400"],
"--blue-500": shared.palette.blue["500"],
"--blue-600": shared.palette.blue["600"],
"--green-500": shared.palette.green["500"],
"--red-500": shared.palette.red["500"],
"--amber-500": shared.palette.amber["500"],
"--space-1": shared.space["1"],
"--space-2": shared.space["2"],
"--space-3": shared.space["3"],
"--space-4": shared.space["4"],
"--space-5": shared.space["5"],
"--space-6": shared.space["6"],
"--space-8": shared.space["8"],
"--space-10": shared.space["10"],
"--space-12": shared.space["12"],
"--radius-sm": shared.radius.sm,
"--radius-md": shared.radius.md,
"--radius-lg": shared.radius.lg,
"--radius-xl": shared.radius.xl,
"--radius-pill": shared.radius.pill,
"--control-size-md": shared.size.controlMd,
"--control-size-lg": shared.size.controlLg,
"--content-width-wide": shared.size.contentWidthWide,
"--blur-overlay": shared.size.blurOverlay,
"--shadow-soft": modeTokens.shadow?.soft ?? shared.shadow.soft,
"--shadow-strong": modeTokens.shadow?.strong ?? shared.shadow.strong,
"--z-base": shared.zIndex.base,
"--z-dropdown": shared.zIndex.dropdown,
"--z-sticky": shared.zIndex.sticky,
"--z-overlay": shared.zIndex.overlay,
"--z-modal": shared.zIndex.modal,
"--z-toast": shared.zIndex.toast,
"--motion-duration-fast": shared.motion.durationFast,
"--motion-duration-base": shared.motion.durationBase,
"--motion-duration-slow": shared.motion.durationSlow,
"--motion-ease-standard": shared.motion.easeStandard,
"--font-family-sans": shared.typography.fontFamily.sans,
"--font-family-heading": shared.typography.fontFamily.heading,
"--font-family-display": shared.typography.fontFamily.display,
"--font-family-serif": shared.typography.fontFamily.serif,
"--font-family-mono": shared.typography.fontFamily.mono,
"--font-size-caption": shared.typography.fontSize.caption,
"--font-size-label": shared.typography.fontSize.label,
"--font-size-body": shared.typography.fontSize.body,
"--font-size-title": shared.typography.fontSize.title,
"--font-size-heading": shared.typography.fontSize.heading,
"--font-size-display": shared.typography.fontSize.display,
"--line-height-caption": shared.typography.lineHeight.caption,
"--line-height-label": shared.typography.lineHeight.label,
"--line-height-body": shared.typography.lineHeight.body,
"--line-height-title": shared.typography.lineHeight.title,
"--line-height-heading": shared.typography.lineHeight.heading,
"--line-height-display": shared.typography.lineHeight.display,
"--font-weight-caption": shared.typography.fontWeight.caption,
"--font-weight-label": shared.typography.fontWeight.label,
"--font-weight-body": shared.typography.fontWeight.body,
"--font-weight-title": shared.typography.fontWeight.title,
"--font-weight-heading": shared.typography.fontWeight.heading,
"--font-weight-display": shared.typography.fontWeight.display,
"--letter-spacing-caption": shared.typography.letterSpacing.caption,
"--letter-spacing-label": shared.typography.letterSpacing.label,
"--letter-spacing-body": shared.typography.letterSpacing.body,
"--letter-spacing-title": shared.typography.letterSpacing.title,
"--letter-spacing-heading": shared.typography.letterSpacing.heading,
"--letter-spacing-display": shared.typography.letterSpacing.display,
"--color-canvas": modeTokens.colors.canvas,
"--color-surface": modeTokens.colors.surface,
"--color-surface-muted": modeTokens.colors.surfaceMuted,
"--color-surface-hover": modeTokens.colors.surfaceHover,
"--color-border": modeTokens.colors.border,
"--color-border-strong": modeTokens.colors.borderStrong,
"--color-text": modeTokens.colors.text,
"--color-text-muted": modeTokens.colors.textMuted,
"--color-accent": modeTokens.colors.accent,
"--color-accent-strong": modeTokens.colors.accentStrong,
"--color-accent-soft": modeTokens.colors.accentSoft,
"--color-accent-contrast": modeTokens.colors.accentContrast,
"--color-success": modeTokens.colors.success,
"--color-danger": modeTokens.colors.danger,
"--color-warning": modeTokens.colors.warning,
"--color-focus-ring": modeTokens.colors.focusRing,
};
};

View File

@@ -4,4 +4,4 @@ mod local "Commands/Local"
[default]
help:
just --list --list-submodules
just --list --list-submodules