diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d03dce7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,48 @@
+name: ci
+
+on:
+ push:
+ branches:
+ - master
+ - "codex/**"
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-go@v5
+ with:
+ go-version: "1.24.2"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ cache: "npm"
+ cache-dependency-path: apps/web/package-lock.json
+
+ - name: Install build tooling
+ run: sudo apt-get update && sudo apt-get install -y build-essential
+
+ - name: Install web dependencies
+ run: cd apps/web && npm install
+
+ - name: Build web
+ run: cd apps/web && npm run build
+
+ - name: Run Go tests
+ run: cd apps/server && CGO_ENABLED=1 go test ./...
+
+ - name: Build server
+ run: cd apps/server && CGO_ENABLED=1 go build -trimpath ./cmd/md-hub-secure
+
+ - name: Run govulncheck
+ run: |
+ go install golang.org/x/vuln/cmd/govulncheck@latest
+ cd apps/server && govulncheck ./...
+
+ - name: Build container
+ run: docker build .
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3c111d1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+data/
+tmp/
+node_modules/
+dist/
+coverage/
+.DS_Store
+*.log
+apps/web/node_modules/
+apps/web/dist/
+apps/server/bin/
+
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..4666a78
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,35 @@
+FROM node:24-bookworm-slim AS web-build
+WORKDIR /workspace/apps/web
+COPY apps/web/package.json apps/web/package-lock.json* ./
+RUN npm install
+COPY apps/web/ ./
+RUN npm run build
+
+FROM golang:1.24-bookworm AS server-build
+WORKDIR /workspace
+RUN apt-get update && apt-get install -y --no-install-recommends build-essential ca-certificates && rm -rf /var/lib/apt/lists/*
+COPY go.work ./
+COPY apps/server/go.mod apps/server/go.sum* ./apps/server/
+WORKDIR /workspace/apps/server
+RUN go mod download
+COPY apps/server/ ./
+RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/md-hub-secure ./cmd/md-hub-secure
+
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
+RUN useradd --create-home --shell /usr/sbin/nologin appuser
+WORKDIR /workspace
+COPY --from=server-build /workspace/md-hub-secure /usr/local/bin/md-hub-secure
+COPY --from=web-build /workspace/apps/web/dist /workspace/web-dist
+COPY content /workspace/content
+RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace
+USER appuser
+EXPOSE 8080
+ENV MD_HUB_SERVER_ADDR=:8080
+ENV MD_HUB_DATABASE_PATH=/workspace/data/db.sqlite
+ENV MD_HUB_CONTENT_SOURCE_DIR=/workspace/content
+ENV MD_HUB_CONTENT_STORE_DIR=/workspace/data/files
+ENV MD_HUB_WEB_DIST_DIR=/workspace/web-dist
+HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1
+ENTRYPOINT ["/usr/local/bin/md-hub-secure"]
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..dde843f
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,34 @@
+SHELL := /bin/bash
+GO := go
+NPM := npm
+SERVER_DIR := apps/server
+WEB_DIR := apps/web
+SERVER_BIN := $(SERVER_DIR)/bin/md-hub-secure
+
+.PHONY: web-install web-build server-run server-build server-test docker-build ci fmt
+
+web-install:
+ cd $(WEB_DIR) && $(NPM) install
+
+web-build:
+ cd $(WEB_DIR) && $(NPM) run build
+
+server-run:
+ cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) run ./cmd/md-hub-secure
+
+server-build:
+ mkdir -p $(SERVER_DIR)/bin
+ cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) build -trimpath -o bin/md-hub-secure ./cmd/md-hub-secure
+
+server-test:
+ cd $(SERVER_DIR) && CGO_ENABLED=1 $(GO) test ./...
+
+docker-build:
+ docker build -t md-hub-secure:dev .
+
+fmt:
+ cd $(SERVER_DIR) && $(GO) fmt ./...
+ cd $(WEB_DIR) && $(NPM) run format
+
+ci: web-build server-test server-build
+
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f9dc4c9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,68 @@
+# MD Hub Secure
+
+MD Hub Secure is a Go-first documentation platform for serving Markdown as secure, fast HTML with room for later sync, collaboration, and offline features.
+
+## Current Status
+
+This repository now contains the Milestone 1 foundation scaffold:
+
+- Go application server with chi routing, slog logging, config loading, and graceful shutdown
+- SQLite/libsql-oriented data layer with startup migrations
+- Markdown rendering pipeline with wiki-link rewriting, tag extraction, and SSR templates
+- Content-addressed attachment storage with secure serving
+- Preact + Vite frontend scaffold for later hydration-focused UI work
+- Docker, Compose, CI, OpenAPI, and local development targets
+
+## Quick Start
+
+### Prerequisites
+
+- Go 1.24+
+- Node.js 24+
+- npm 11+
+- Docker (optional)
+- CGO-capable toolchain for `go-libsql`
+
+### Run locally
+
+```bash
+make web-install
+make web-build
+make server-run
+```
+
+The server listens on `http://localhost:8080` by default.
+
+### Common commands
+
+```bash
+make server-test
+make server-build
+make docker-build
+make ci
+```
+
+## Configuration
+
+Configuration can come from:
+
+1. `config.json` via `MD_HUB_CONFIG`
+2. environment variables
+
+Supported variables:
+
+- `MD_HUB_SERVER_ADDR`
+- `MD_HUB_DATABASE_PATH`
+- `MD_HUB_DATABASE_PRIMARY_URL`
+- `MD_HUB_DATABASE_AUTH_TOKEN`
+- `MD_HUB_CONTENT_SOURCE_DIR`
+- `MD_HUB_CONTENT_STORE_DIR`
+- `MD_HUB_WEB_DIST_DIR`
+- `MD_HUB_LOG_LEVEL`
+
+## Noted Gaps
+
+- The docs describe both a single-binary system and a separate Vite/Preact app. This implementation keeps the Go server as the primary runtime and treats the web app as compiled static assets.
+- The docs call for `golang-migrate`, but the current foundation uses an internal migration runner while `go-libsql` integration details are validated.
+- The sync docs describe JSON messages while the Milestone 1 layout mentions a protobuf package; both are preserved in `packages/protocol/` pending a later protocol lock.
+
diff --git a/apps/server/cmd/md-hub-secure/main.go b/apps/server/cmd/md-hub-secure/main.go
new file mode 100644
index 0000000..0520f42
--- /dev/null
+++ b/apps/server/cmd/md-hub-secure/main.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/tim/md-hub-secure/apps/server/internal/app"
+ "github.com/tim/md-hub-secure/apps/server/internal/config"
+ "github.com/tim/md-hub-secure/apps/server/internal/logging"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ slog.Error("load config", "error", err)
+ os.Exit(1)
+ }
+
+ logger := logging.New(cfg.LogLevel)
+
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
+ application, err := app.New(ctx, cfg, logger)
+ if err != nil {
+ logger.Error("initialize application", "error", err)
+ os.Exit(1)
+ }
+ defer func() {
+ if closeErr := application.Close(); closeErr != nil {
+ logger.Error("close application", "error", closeErr)
+ }
+ }()
+
+ err = application.Run(ctx)
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
+ logger.Error("run application", "error", err)
+ os.Exit(1)
+ }
+}
diff --git a/apps/server/go.mod b/apps/server/go.mod
new file mode 100644
index 0000000..8068f5d
--- /dev/null
+++ b/apps/server/go.mod
@@ -0,0 +1,15 @@
+module github.com/tim/md-hub-secure/apps/server
+
+go 1.24.2
+
+require (
+ github.com/go-chi/chi/v5 v5.2.5
+ github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04
+ github.com/yuin/goldmark v1.8.2
+)
+
+require (
+ github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
+ github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
+ golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect
+)
diff --git a/apps/server/go.sum b/apps/server/go.sum
new file mode 100644
index 0000000..abd6134
--- /dev/null
+++ b/apps/server/go.sum
@@ -0,0 +1,20 @@
+github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
+github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
+github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
+github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM=
+github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04 h1:9nlqEMruvXDPynGbZ0RE67kKnkkg3NdnjGccvRABefc=
+github.com/tursodatabase/go-libsql v0.0.0-20260424063416-3051e37e6e04/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU=
+golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
+golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
+gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go
new file mode 100644
index 0000000..8c38ab0
--- /dev/null
+++ b/apps/server/internal/app/app.go
@@ -0,0 +1,98 @@
+package app
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/tim/md-hub-secure/apps/server/internal/config"
+ "github.com/tim/md-hub-secure/apps/server/internal/database"
+ "github.com/tim/md-hub-secure/apps/server/internal/docs"
+ "github.com/tim/md-hub-secure/apps/server/internal/httpserver"
+ "github.com/tim/md-hub-secure/apps/server/internal/markdown"
+ "github.com/tim/md-hub-secure/apps/server/internal/store"
+)
+
+type App struct {
+ cfg config.Config
+ logger *slog.Logger
+ db database.DB
+ server *http.Server
+}
+
+func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) {
+ if err := os.MkdirAll(cfg.Content.SourceDir, 0o755); err != nil {
+ return nil, fmt.Errorf("create content source dir: %w", err)
+ }
+
+ contentStore, err := store.New(cfg.Content.StoreDir)
+ if err != nil {
+ return nil, fmt.Errorf("create content store: %w", err)
+ }
+
+ db, err := database.Open(ctx, cfg.Database)
+ if err != nil {
+ return nil, fmt.Errorf("open database: %w", err)
+ }
+
+ if err := database.ApplyMigrations(ctx, db.SQL()); err != nil {
+ return nil, fmt.Errorf("apply migrations: %w", err)
+ }
+
+ renderer := markdown.NewRenderer()
+ repo := docs.NewRepository(db.SQL())
+ service := docs.NewService(cfg.Content.SourceDir, contentStore, renderer, repo, logger)
+ if err := service.SyncSourceDir(ctx); err != nil {
+ logger.Warn("initial content sync failed", "error", err)
+ }
+
+ handler, err := httpserver.New(httpserver.Dependencies{
+ Config: cfg,
+ Logger: logger,
+ Documents: service,
+ Repository: repo,
+ ContentStore: contentStore,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("build http handler: %w", err)
+ }
+
+ server := &http.Server{
+ Addr: cfg.Server.Addr,
+ Handler: handler,
+ ReadHeaderTimeout: 5 * time.Second,
+ ReadTimeout: 15 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ return &App{
+ cfg: cfg,
+ logger: logger,
+ db: db,
+ server: server,
+ }, nil
+}
+
+func (a *App) Run(ctx context.Context) error {
+ go func() {
+ <-ctx.Done()
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ if err := a.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ a.logger.Error("shutdown server", "error", err)
+ }
+ }()
+
+ a.logger.Info("starting server", "addr", a.cfg.Server.Addr)
+ return a.server.ListenAndServe()
+}
+
+func (a *App) Close() error {
+ return a.db.Close()
+}
diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go
new file mode 100644
index 0000000..bc0683b
--- /dev/null
+++ b/apps/server/internal/config/config.go
@@ -0,0 +1,99 @@
+package config
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+type Config struct {
+ Server ServerConfig `json:"server"`
+ Database DatabaseConfig `json:"database"`
+ Content ContentConfig `json:"content"`
+ Web WebConfig `json:"web"`
+ LogLevel string `json:"logLevel"`
+}
+
+type ServerConfig struct {
+ Addr string `json:"addr"`
+}
+
+type DatabaseConfig struct {
+ Path string `json:"path"`
+ PrimaryURL string `json:"primaryUrl"`
+ AuthToken string `json:"authToken"`
+}
+
+type ContentConfig struct {
+ SourceDir string `json:"sourceDir"`
+ StoreDir string `json:"storeDir"`
+}
+
+type WebConfig struct {
+ DistDir string `json:"distDir"`
+}
+
+func Default() Config {
+ return Config{
+ Server: ServerConfig{
+ Addr: ":8080",
+ },
+ Database: DatabaseConfig{
+ Path: filepath.Join("..", "..", "data", "db.sqlite"),
+ },
+ Content: ContentConfig{
+ SourceDir: filepath.Join("..", "..", "content"),
+ StoreDir: filepath.Join("..", "..", "data", "files"),
+ },
+ Web: WebConfig{
+ DistDir: filepath.Join("..", "web", "dist"),
+ },
+ LogLevel: "INFO",
+ }
+}
+
+func Load() (Config, error) {
+ cfg := Default()
+
+ if configPath := os.Getenv("MD_HUB_CONFIG"); configPath != "" {
+ if err := loadFile(configPath, &cfg); err != nil {
+ return Config{}, err
+ }
+ }
+
+ overrideString(&cfg.Server.Addr, "MD_HUB_SERVER_ADDR")
+ overrideString(&cfg.Database.Path, "MD_HUB_DATABASE_PATH")
+ overrideString(&cfg.Database.PrimaryURL, "MD_HUB_DATABASE_PRIMARY_URL")
+ overrideString(&cfg.Database.AuthToken, "MD_HUB_DATABASE_AUTH_TOKEN")
+ overrideString(&cfg.Content.SourceDir, "MD_HUB_CONTENT_SOURCE_DIR")
+ overrideString(&cfg.Content.StoreDir, "MD_HUB_CONTENT_STORE_DIR")
+ overrideString(&cfg.Web.DistDir, "MD_HUB_WEB_DIST_DIR")
+ overrideString(&cfg.LogLevel, "MD_HUB_LOG_LEVEL")
+
+ if cfg.Server.Addr == "" {
+ return Config{}, fmt.Errorf("server.addr must not be empty")
+ }
+
+ return cfg, nil
+}
+
+func loadFile(path string, cfg *Config) error {
+ file, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("open config file %q: %w", path, err)
+ }
+ defer file.Close()
+
+ if err := json.NewDecoder(file).Decode(cfg); err != nil {
+ return fmt.Errorf("decode config file %q: %w", path, err)
+ }
+
+ return nil
+}
+
+func overrideString(target *string, key string) {
+ if value := os.Getenv(key); value != "" {
+ *target = value
+ }
+}
diff --git a/apps/server/internal/database/database.go b/apps/server/internal/database/database.go
new file mode 100644
index 0000000..dba76d6
--- /dev/null
+++ b/apps/server/internal/database/database.go
@@ -0,0 +1,79 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ libsql "github.com/tursodatabase/go-libsql"
+
+ "github.com/tim/md-hub-secure/apps/server/internal/config"
+)
+
+type DB interface {
+ SQL() *sql.DB
+ Close() error
+}
+
+type dbHandle struct {
+ sql *sql.DB
+}
+
+func Open(ctx context.Context, cfg config.DatabaseConfig) (DB, error) {
+ if err := os.MkdirAll(filepath.Dir(cfg.Path), 0o755); err != nil {
+ return nil, fmt.Errorf("create database directory: %w", err)
+ }
+
+ var (
+ db *sql.DB
+ err error
+ )
+ if cfg.PrimaryURL == "" {
+ db, err = sql.Open("libsql", "file:"+cfg.Path)
+ if err != nil {
+ return nil, fmt.Errorf("open local libsql database: %w", err)
+ }
+ } else {
+ var opts []libsql.Option
+ if cfg.AuthToken != "" {
+ opts = append(opts, libsql.WithAuthToken(cfg.AuthToken))
+ }
+
+ connector, err := libsql.NewEmbeddedReplicaConnector(cfg.Path, cfg.PrimaryURL, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("create libsql connector: %w", err)
+ }
+
+ db = sql.OpenDB(connector)
+ }
+
+ db.SetConnMaxLifetime(5 * time.Minute)
+ db.SetMaxOpenConns(1)
+ db.SetMaxIdleConns(1)
+
+ if err := db.PingContext(ctx); err != nil {
+ return nil, fmt.Errorf("ping database: %w", err)
+ }
+
+ var journalMode string
+ if err := db.QueryRowContext(ctx, "PRAGMA journal_mode=WAL").Scan(&journalMode); err != nil {
+ return nil, fmt.Errorf("enable wal mode: %w", err)
+ }
+
+ if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=ON"); err != nil {
+ return nil, fmt.Errorf("enable foreign keys: %w", err)
+ }
+
+ return &dbHandle{sql: db}, nil
+}
+
+func (d *dbHandle) SQL() *sql.DB {
+ return d.sql
+}
+
+func (d *dbHandle) Close() error {
+ return d.sql.Close()
+}
diff --git a/apps/server/internal/database/migrate.go b/apps/server/internal/database/migrate.go
new file mode 100644
index 0000000..a5a4864
--- /dev/null
+++ b/apps/server/internal/database/migrate.go
@@ -0,0 +1,98 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "embed"
+ "fmt"
+ "io/fs"
+ "sort"
+ "strings"
+)
+
+//go:embed migrations/*.sql
+var migrationFiles embed.FS
+
+func ApplyMigrations(ctx context.Context, db *sql.DB) error {
+ if _, err := db.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ version TEXT PRIMARY KEY,
+ applied_at TEXT NOT NULL
+ );
+ `); err != nil {
+ return fmt.Errorf("ensure schema_migrations: %w", err)
+ }
+
+ entries, err := fs.ReadDir(migrationFiles, "migrations")
+ if err != nil {
+ return fmt.Errorf("read migrations: %w", err)
+ }
+
+ var names []string
+ for _, entry := range entries {
+ if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".up.sql") {
+ continue
+ }
+ names = append(names, entry.Name())
+ }
+ sort.Strings(names)
+
+ for _, name := range names {
+ applied, err := migrationApplied(ctx, db, name)
+ if err != nil {
+ return err
+ }
+ if applied {
+ continue
+ }
+
+ body, err := migrationFiles.ReadFile("migrations/" + name)
+ if err != nil {
+ return fmt.Errorf("read migration %s: %w", name, err)
+ }
+
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin migration %s: %w", name, err)
+ }
+
+ for _, statement := range splitStatements(string(body)) {
+ if _, err := tx.ExecContext(ctx, statement); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("execute migration %s: %w", name, err)
+ }
+ }
+
+ if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations(version, applied_at) VALUES (?, datetime('now'))`, name); err != nil {
+ _ = tx.Rollback()
+ return fmt.Errorf("record migration %s: %w", name, err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit migration %s: %w", name, err)
+ }
+ }
+
+ return nil
+}
+
+func migrationApplied(ctx context.Context, db *sql.DB, version string) (bool, error) {
+ var count int
+ if err := db.QueryRowContext(ctx, `SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version).Scan(&count); err != nil {
+ return false, fmt.Errorf("lookup migration %s: %w", version, err)
+ }
+ return count > 0, nil
+}
+
+func splitStatements(body string) []string {
+ raw := strings.Split(body, ";")
+ statements := make([]string, 0, len(raw))
+ for _, statement := range raw {
+ statement = strings.TrimSpace(statement)
+ if statement == "" {
+ continue
+ }
+ statements = append(statements, statement)
+ }
+ return statements
+}
diff --git a/apps/server/internal/database/migrations/000001_initial.down.sql b/apps/server/internal/database/migrations/000001_initial.down.sql
new file mode 100644
index 0000000..aef84f4
--- /dev/null
+++ b/apps/server/internal/database/migrations/000001_initial.down.sql
@@ -0,0 +1,5 @@
+DROP TABLE IF EXISTS attachments;
+DROP TABLE IF EXISTS document_versions;
+DROP TABLE IF EXISTS documents;
+DROP TABLE IF EXISTS users;
+
diff --git a/apps/server/internal/database/migrations/000001_initial.up.sql b/apps/server/internal/database/migrations/000001_initial.up.sql
new file mode 100644
index 0000000..ea546b3
--- /dev/null
+++ b/apps/server/internal/database/migrations/000001_initial.up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS users (
+ id TEXT PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE,
+ display_name TEXT,
+ password_hash TEXT,
+ created_at TEXT NOT NULL,
+ last_seen_at TEXT
+);
diff --git a/apps/server/internal/database/migrations/000002_documents.down.sql b/apps/server/internal/database/migrations/000002_documents.down.sql
new file mode 100644
index 0000000..663d050
--- /dev/null
+++ b/apps/server/internal/database/migrations/000002_documents.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS documents;
+
diff --git a/apps/server/internal/database/migrations/000002_documents.up.sql b/apps/server/internal/database/migrations/000002_documents.up.sql
new file mode 100644
index 0000000..997a74b
--- /dev/null
+++ b/apps/server/internal/database/migrations/000002_documents.up.sql
@@ -0,0 +1,10 @@
+CREATE TABLE IF NOT EXISTS documents (
+ id TEXT PRIMARY KEY,
+ path TEXT NOT NULL UNIQUE,
+ current_hash TEXT NOT NULL,
+ title TEXT NOT NULL,
+ tags TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+);
+
diff --git a/apps/server/internal/database/migrations/000003_document_versions.down.sql b/apps/server/internal/database/migrations/000003_document_versions.down.sql
new file mode 100644
index 0000000..3b0162a
--- /dev/null
+++ b/apps/server/internal/database/migrations/000003_document_versions.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS document_versions;
+
diff --git a/apps/server/internal/database/migrations/000003_document_versions.up.sql b/apps/server/internal/database/migrations/000003_document_versions.up.sql
new file mode 100644
index 0000000..73ed7c1
--- /dev/null
+++ b/apps/server/internal/database/migrations/000003_document_versions.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS document_versions (
+ id TEXT PRIMARY KEY,
+ document_id TEXT NOT NULL,
+ hash TEXT NOT NULL,
+ previous_hash TEXT,
+ created_at TEXT NOT NULL,
+ change_summary TEXT,
+ UNIQUE(document_id, hash),
+ FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
+);
+
diff --git a/apps/server/internal/database/migrations/000004_attachments.down.sql b/apps/server/internal/database/migrations/000004_attachments.down.sql
new file mode 100644
index 0000000..21399a4
--- /dev/null
+++ b/apps/server/internal/database/migrations/000004_attachments.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS attachments;
+
diff --git a/apps/server/internal/database/migrations/000004_attachments.up.sql b/apps/server/internal/database/migrations/000004_attachments.up.sql
new file mode 100644
index 0000000..2ebdbbc
--- /dev/null
+++ b/apps/server/internal/database/migrations/000004_attachments.up.sql
@@ -0,0 +1,8 @@
+CREATE TABLE IF NOT EXISTS attachments (
+ hash TEXT PRIMARY KEY,
+ original_name TEXT NOT NULL,
+ content_type TEXT NOT NULL,
+ size_bytes INTEGER NOT NULL,
+ created_at TEXT NOT NULL
+);
+
diff --git a/apps/server/internal/database/migrations/000005_indexes.down.sql b/apps/server/internal/database/migrations/000005_indexes.down.sql
new file mode 100644
index 0000000..4df53b2
--- /dev/null
+++ b/apps/server/internal/database/migrations/000005_indexes.down.sql
@@ -0,0 +1,3 @@
+DROP INDEX IF EXISTS idx_documents_path;
+DROP INDEX IF EXISTS idx_document_versions_document_id;
+
diff --git a/apps/server/internal/database/migrations/000005_indexes.up.sql b/apps/server/internal/database/migrations/000005_indexes.up.sql
new file mode 100644
index 0000000..408ec9c
--- /dev/null
+++ b/apps/server/internal/database/migrations/000005_indexes.up.sql
@@ -0,0 +1,3 @@
+CREATE INDEX IF NOT EXISTS idx_documents_path ON documents(path);
+CREATE INDEX IF NOT EXISTS idx_document_versions_document_id ON document_versions(document_id);
+
diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go
new file mode 100644
index 0000000..d47328e
--- /dev/null
+++ b/apps/server/internal/docs/repository.go
@@ -0,0 +1,205 @@
+package docs
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+)
+
+type Repository struct {
+ db *sql.DB
+}
+
+type DocumentRecord struct {
+ ID string
+ Path string
+ CurrentHash string
+ Title string
+ Tags []string
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+type AttachmentRecord struct {
+ Hash string
+ OriginalName string
+ ContentType string
+ SizeBytes int64
+ CreatedAt time.Time
+}
+
+type PersistDocumentInput struct {
+ ID string
+ Path string
+ Title string
+ Hash string
+ PreviousHash string
+ Tags []string
+}
+
+func NewRepository(db *sql.DB) *Repository {
+ return &Repository{db: db}
+}
+
+func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentInput) error {
+ now := time.Now().UTC()
+ tagsJSON, err := json.Marshal(input.Tags)
+ if err != nil {
+ return fmt.Errorf("marshal tags: %w", err)
+ }
+
+ existing, err := r.GetDocumentByPath(ctx, input.Path)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+
+ documentID := input.ID
+ createdAt := now
+ if existing != nil {
+ documentID = existing.ID
+ createdAt = existing.CreatedAt
+ }
+
+ if _, err := r.db.ExecContext(ctx, `
+ INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(path) DO UPDATE SET
+ current_hash = excluded.current_hash,
+ title = excluded.title,
+ tags = excluded.tags,
+ updated_at = excluded.updated_at
+ `, documentID, input.Path, input.Hash, input.Title, string(tagsJSON), createdAt.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil {
+ return fmt.Errorf("upsert document %s: %w", input.Path, err)
+ }
+
+ if existing != nil && existing.CurrentHash == input.Hash {
+ return nil
+ }
+
+ versionID := documentID + ":" + input.Hash[:12]
+ if _, err := r.db.ExecContext(ctx, `
+ INSERT OR IGNORE INTO document_versions (id, document_id, hash, previous_hash, created_at, change_summary)
+ VALUES (?, ?, ?, ?, ?, ?)
+ `, versionID, documentID, input.Hash, input.PreviousHash, now.Format(time.RFC3339), "filesystem sync"); err != nil {
+ return fmt.Errorf("insert document version %s: %w", input.Path, err)
+ }
+
+ return nil
+}
+
+func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) {
+ var (
+ record DocumentRecord
+ tagsJSON string
+ created string
+ updated string
+ )
+
+ err := r.db.QueryRowContext(ctx, `
+ SELECT id, path, current_hash, title, tags, created_at, updated_at
+ FROM documents
+ WHERE path = ?
+ `, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated)
+ if err != nil {
+ return nil, err
+ }
+
+ if err := parseDocumentTimes(&record, created, updated); err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
+ return nil, fmt.Errorf("unmarshal document tags: %w", err)
+ }
+
+ return &record, nil
+}
+
+func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
+ rows, err := r.db.QueryContext(ctx, `
+ SELECT id, path, current_hash, title, tags, created_at, updated_at
+ FROM documents
+ ORDER BY path ASC
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("list documents: %w", err)
+ }
+ defer rows.Close()
+
+ var records []DocumentRecord
+ for rows.Next() {
+ var (
+ record DocumentRecord
+ tagsJSON string
+ created string
+ updated string
+ )
+ if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated); err != nil {
+ return nil, fmt.Errorf("scan document: %w", err)
+ }
+ if err := parseDocumentTimes(&record, created, updated); err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil {
+ return nil, fmt.Errorf("unmarshal document tags: %w", err)
+ }
+ records = append(records, record)
+ }
+
+ return records, rows.Err()
+}
+
+func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error {
+ if _, err := r.db.ExecContext(ctx, `
+ INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(hash) DO UPDATE SET
+ original_name = excluded.original_name,
+ content_type = excluded.content_type,
+ size_bytes = excluded.size_bytes
+ `, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339)); err != nil {
+ return fmt.Errorf("save attachment: %w", err)
+ }
+ return nil
+}
+
+func (r *Repository) GetAttachment(ctx context.Context, hash string) (*AttachmentRecord, error) {
+ var (
+ record AttachmentRecord
+ created string
+ )
+ err := r.db.QueryRowContext(ctx, `
+ SELECT hash, original_name, content_type, size_bytes, created_at
+ FROM attachments
+ WHERE hash = ?
+ `, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created)
+ if err != nil {
+ return nil, err
+ }
+ parsed, err := time.Parse(time.RFC3339, created)
+ if err != nil {
+ return nil, fmt.Errorf("parse attachment created_at: %w", err)
+ }
+ record.CreatedAt = parsed
+ return &record, nil
+}
+
+func (r *Repository) Ping(ctx context.Context) error {
+ return r.db.PingContext(ctx)
+}
+
+func parseDocumentTimes(record *DocumentRecord, created string, updated string) error {
+ createdAt, err := time.Parse(time.RFC3339, created)
+ if err != nil {
+ return fmt.Errorf("parse document created_at: %w", err)
+ }
+ updatedAt, err := time.Parse(time.RFC3339, updated)
+ if err != nil {
+ return fmt.Errorf("parse document updated_at: %w", err)
+ }
+ record.CreatedAt = createdAt
+ record.UpdatedAt = updatedAt
+ return nil
+}
diff --git a/apps/server/internal/docs/service.go b/apps/server/internal/docs/service.go
new file mode 100644
index 0000000..2f8bdc6
--- /dev/null
+++ b/apps/server/internal/docs/service.go
@@ -0,0 +1,154 @@
+package docs
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/tim/md-hub-secure/apps/server/internal/markdown"
+ "github.com/tim/md-hub-secure/apps/server/internal/store"
+)
+
+type Service struct {
+ sourceDir string
+ store *store.ContentStore
+ renderer *markdown.Renderer
+ repo *Repository
+ logger *slog.Logger
+}
+
+type Page struct {
+ Path string
+ Title string
+ Tags []string
+ HTML string
+ Hash string
+}
+
+func NewService(sourceDir string, store *store.ContentStore, renderer *markdown.Renderer, repo *Repository, logger *slog.Logger) *Service {
+ return &Service{
+ sourceDir: sourceDir,
+ store: store,
+ renderer: renderer,
+ repo: repo,
+ logger: logger,
+ }
+}
+
+func (s *Service) SyncSourceDir(ctx context.Context) error {
+ return filepath.WalkDir(s.sourceDir, func(path string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() || filepath.Ext(path) != ".md" {
+ return nil
+ }
+ return s.syncFile(ctx, path)
+ })
+}
+
+func (s *Service) ListDocuments(ctx context.Context) ([]DocumentRecord, error) {
+ if err := s.SyncSourceDir(ctx); err != nil {
+ return nil, err
+ }
+ return s.repo.ListDocuments(ctx)
+}
+
+func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
+ if err := s.SyncSourceDir(ctx); err != nil {
+ return nil, err
+ }
+
+ normalized := normalizeRequestPath(requestPath)
+ record, err := s.repo.GetDocumentByPath(ctx, normalized)
+ if err != nil {
+ return nil, err
+ }
+
+ content, err := s.store.Read(record.CurrentHash)
+ if err != nil {
+ return nil, fmt.Errorf("read content %s: %w", record.CurrentHash, err)
+ }
+
+ rendered, err := s.renderer.Render(content)
+ if err != nil {
+ return nil, fmt.Errorf("render page %s: %w", normalized, err)
+ }
+
+ return &Page{
+ Path: record.Path,
+ Title: rendered.Title,
+ Tags: record.Tags,
+ HTML: string(rendered.HTML),
+ Hash: record.CurrentHash,
+ }, nil
+}
+
+func normalizeRequestPath(path string) string {
+ path = strings.Trim(path, "/")
+ if path == "" {
+ return "getting-started.md"
+ }
+ if !strings.HasSuffix(path, ".md") {
+ path += ".md"
+ }
+ return path
+}
+
+func (s *Service) syncFile(ctx context.Context, path string) error {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("read content file %s: %w", path, err)
+ }
+
+ rendered, err := s.renderer.Render(content)
+ if err != nil {
+ return fmt.Errorf("render content file %s: %w", path, err)
+ }
+
+ record, err := s.store.PutBytes(content)
+ if err != nil {
+ return fmt.Errorf("store content file %s: %w", path, err)
+ }
+
+ relative, err := filepath.Rel(s.sourceDir, path)
+ if err != nil {
+ return fmt.Errorf("compute relative path %s: %w", path, err)
+ }
+ relative = filepath.ToSlash(relative)
+
+ existing, err := s.repo.GetDocumentByPath(ctx, relative)
+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return err
+ }
+
+ previousHash := ""
+ documentID := "doc:" + strings.TrimSuffix(relative, ".md")
+ if existing != nil {
+ previousHash = existing.CurrentHash
+ documentID = existing.ID
+ if existing.CurrentHash == record.Hash {
+ return nil
+ }
+ }
+
+ if err := s.repo.PersistDocument(ctx, PersistDocumentInput{
+ ID: documentID,
+ Path: relative,
+ Title: rendered.Title,
+ Hash: record.Hash,
+ PreviousHash: previousHash,
+ Tags: rendered.Tags,
+ }); err != nil {
+ return err
+ }
+
+ s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
+ return nil
+}
diff --git a/apps/server/internal/httpserver/fs.go b/apps/server/internal/httpserver/fs.go
new file mode 100644
index 0000000..b5202b2
--- /dev/null
+++ b/apps/server/internal/httpserver/fs.go
@@ -0,0 +1,7 @@
+package httpserver
+
+import "io/fs"
+
+func fsSub(path string) (fs.FS, error) {
+ return fs.Sub(assets, path)
+}
diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go
new file mode 100644
index 0000000..f476c14
--- /dev/null
+++ b/apps/server/internal/httpserver/handlers.go
@@ -0,0 +1,242 @@
+package httpserver
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "html/template"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/tim/md-hub-secure/apps/server/internal/docs"
+)
+
+type layoutData struct {
+ Title string
+ WebEnabled bool
+ BodyClass string
+ BodyTemplate string
+ Data any
+}
+
+type indexData struct {
+ Documents []docs.DocumentRecord
+}
+
+type documentData struct {
+ Title string
+ Path string
+ Tags []string
+ Hash string
+ HTML template.HTML
+}
+
+type errorData struct {
+ Status int
+ Message string
+}
+
+func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ items, err := s.documents.ListDocuments(r.Context())
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
+ Title: "MD Hub Secure",
+ WebEnabled: s.webEnabled,
+ BodyClass: "page-index",
+ BodyTemplate: "index_content",
+ Data: indexData{
+ Documents: items,
+ },
+ })
+}
+
+func (s *Server) handleDocsIndexRedirect(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/", http.StatusFound)
+}
+
+func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
+ pagePath := chi.URLParam(r, "*")
+ page, err := s.documents.LoadPage(r.Context(), pagePath)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ s.renderError(w, r, http.StatusNotFound, "document not found")
+ return
+ }
+ s.renderError(w, r, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
+ Title: page.Title,
+ WebEnabled: s.webEnabled,
+ BodyClass: "page-document",
+ BodyTemplate: "document_content",
+ Data: documentData{
+ Title: page.Title,
+ Path: page.Path,
+ Tags: page.Tags,
+ Hash: page.Hash,
+ HTML: template.HTML(page.HTML),
+ },
+ })
+}
+
+func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
+ defer cancel()
+
+ dbStatus := "ok"
+ if err := s.repository.Ping(ctx); err != nil {
+ dbStatus = err.Error()
+ }
+
+ contentStatus := "ok"
+ if _, err := os.Stat(s.config.Content.StoreDir); err != nil {
+ contentStatus = err.Error()
+ }
+
+ writeJSON(w, http.StatusOK, map[string]string{
+ "status": "ok",
+ "database": dbStatus,
+ "contentStore": contentStatus,
+ })
+}
+
+func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseMultipartForm(32 << 20); err != nil {
+ s.renderError(w, r, http.StatusBadRequest, "invalid multipart upload")
+ return
+ }
+
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ s.renderError(w, r, http.StatusBadRequest, "file field is required")
+ return
+ }
+ defer file.Close()
+
+ content, err := io.ReadAll(io.LimitReader(file, 32<<20))
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "read upload")
+ return
+ }
+
+ contentType := http.DetectContentType(content)
+ if !isAllowedContentType(contentType) {
+ s.renderError(w, r, http.StatusBadRequest, "unsupported content type")
+ return
+ }
+
+ record, err := s.contentStore.PutBytes(content)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "store upload")
+ return
+ }
+
+ if err := s.repository.SaveAttachment(r.Context(), docs.AttachmentRecord{
+ Hash: record.Hash,
+ OriginalName: header.Filename,
+ ContentType: contentType,
+ SizeBytes: record.Size,
+ CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "persist upload metadata")
+ return
+ }
+
+ writeJSONWithStatus(w, http.StatusCreated, map[string]string{
+ "hash": record.Hash,
+ "contentType": contentType,
+ "attachmentUrl": "/attachments/" + record.Hash,
+ })
+}
+
+func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
+ hash := chi.URLParam(r, "hash")
+ attachment, err := s.repository.GetAttachment(r.Context(), hash)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ s.renderError(w, r, http.StatusNotFound, "attachment not found")
+ return
+ }
+ s.renderError(w, r, http.StatusInternalServerError, "lookup attachment")
+ return
+ }
+
+ content, err := s.contentStore.Read(hash)
+ if err != nil {
+ s.renderError(w, r, http.StatusInternalServerError, "read attachment")
+ return
+ }
+
+ w.Header().Set("Content-Type", attachment.ContentType)
+ w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", sanitizeFilename(attachment.OriginalName)))
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", len(content)))
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(content)
+}
+
+func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
+ http.Error(w, "template error", http.StatusInternalServerError)
+ }
+}
+
+func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
+ s.renderTemplate(w, status, "error.gohtml", layoutData{
+ Title: http.StatusText(status),
+ WebEnabled: s.webEnabled,
+ BodyClass: "page-error",
+ BodyTemplate: "error_content",
+ Data: errorData{
+ Status: status,
+ Message: message,
+ },
+ })
+}
+
+func isAllowedContentType(contentType string) bool {
+ allowed := []string{
+ "image/png",
+ "image/jpeg",
+ "image/gif",
+ "image/webp",
+ "application/pdf",
+ "text/plain; charset=utf-8",
+ }
+ for _, candidate := range allowed {
+ if strings.EqualFold(candidate, contentType) {
+ return true
+ }
+ }
+ return false
+}
+
+func sanitizeFilename(name string) string {
+ name = filepath.Base(name)
+ return strings.ReplaceAll(name, "\"", "")
+}
+
+func writeJSON(w http.ResponseWriter, status int, payload any) {
+ writeJSONWithStatus(w, status, payload)
+}
+
+func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(payload)
+}
diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go
new file mode 100644
index 0000000..056c528
--- /dev/null
+++ b/apps/server/internal/httpserver/middleware.go
@@ -0,0 +1,42 @@
+package httpserver
+
+import (
+ "net/http"
+ "time"
+)
+
+func (s *Server) requestLogger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
+ start := time.Now()
+ next.ServeHTTP(ww, r)
+ s.logger.Info("http request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", ww.status,
+ "duration", time.Since(start).String(),
+ )
+ })
+}
+
+func (s *Server) securityHeaders(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; object-src 'none'")
+ w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.Header().Set("X-Frame-Options", "DENY")
+ w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
+ w.Header().Set("Cross-Origin-Resource-Policy", "same-origin")
+ next.ServeHTTP(w, r)
+ })
+}
+
+type statusWriter struct {
+ http.ResponseWriter
+ status int
+}
+
+func (w *statusWriter) WriteHeader(status int) {
+ w.status = status
+ w.ResponseWriter.WriteHeader(status)
+}
diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go
new file mode 100644
index 0000000..4949f3a
--- /dev/null
+++ b/apps/server/internal/httpserver/server.go
@@ -0,0 +1,93 @@
+package httpserver
+
+import (
+ "embed"
+ "html/template"
+ "log/slog"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/go-chi/chi/v5/middleware"
+
+ "github.com/tim/md-hub-secure/apps/server/internal/config"
+ "github.com/tim/md-hub-secure/apps/server/internal/docs"
+ "github.com/tim/md-hub-secure/apps/server/internal/store"
+)
+
+//go:embed templates/*.gohtml static/*
+var assets embed.FS
+
+type Dependencies struct {
+ Config config.Config
+ Logger *slog.Logger
+ Documents *docs.Service
+ Repository *docs.Repository
+ ContentStore *store.ContentStore
+}
+
+type Server struct {
+ config config.Config
+ logger *slog.Logger
+ documents *docs.Service
+ repository *docs.Repository
+ contentStore *store.ContentStore
+ templates *template.Template
+ webEnabled bool
+}
+
+func New(deps Dependencies) (http.Handler, error) {
+ templates, err := template.New("").Funcs(template.FuncMap{
+ "trimMd": func(path string) string {
+ return strings.TrimSuffix(path, ".md")
+ },
+ }).ParseFS(assets, "templates/*.gohtml")
+ if err != nil {
+ return nil, err
+ }
+
+ server := &Server{
+ config: deps.Config,
+ logger: deps.Logger,
+ documents: deps.Documents,
+ repository: deps.Repository,
+ contentStore: deps.ContentStore,
+ templates: templates,
+ }
+
+ if _, err := os.Stat(deps.Config.Web.DistDir); err == nil {
+ server.webEnabled = true
+ }
+
+ router := chi.NewRouter()
+ router.Use(middleware.RequestID)
+ router.Use(middleware.RealIP)
+ router.Use(middleware.Recoverer)
+ router.Use(middleware.Timeout(30 * time.Second))
+ router.Use(server.requestLogger)
+ router.Use(server.securityHeaders)
+
+ router.Get("/", server.handleIndex)
+ router.Get("/health", server.handleHealth)
+ router.Get("/docs", server.handleDocsIndexRedirect)
+ router.Get("/docs/*", server.handleDocument)
+ router.Post("/api/uploads", server.handleUpload)
+ router.Get("/attachments/{hash}", server.handleAttachment)
+ router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(mustSub("static"))))
+
+ if server.webEnabled {
+ router.Handle("/app/*", http.StripPrefix("/app/", http.FileServer(http.Dir(deps.Config.Web.DistDir))))
+ }
+
+ return router, nil
+}
+
+func mustSub(path string) http.FileSystem {
+ sub, err := fsSub(path)
+ if err != nil {
+ panic(err)
+ }
+ return http.FS(sub)
+}
diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css
new file mode 100644
index 0000000..e42fdd1
--- /dev/null
+++ b/apps/server/internal/httpserver/static/site.css
@@ -0,0 +1,194 @@
+:root {
+ color-scheme: light;
+ --bg: #fbf7ef;
+ --panel: rgba(255, 255, 255, 0.82);
+ --panel-strong: #ffffff;
+ --text: #18202a;
+ --muted: #5b6675;
+ --accent: #0f5bd8;
+ --accent-soft: rgba(15, 91, 216, 0.1);
+ --border: rgba(24, 32, 42, 0.12);
+ --shadow: 0 16px 48px rgba(24, 32, 42, 0.08);
+ --radius-lg: 24px;
+ --radius-md: 16px;
+ --radius-sm: 10px;
+ font-family: "Charter", "Iowan Old Style", "Palatino Linotype", serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(circle at top left, rgba(15, 91, 216, 0.16), transparent 34%),
+ radial-gradient(circle at bottom right, rgba(14, 163, 125, 0.13), transparent 28%),
+ linear-gradient(180deg, #fdfaf5 0%, #f5efe6 100%);
+}
+
+a {
+ color: var(--accent);
+}
+
+code {
+ font-family: ui-monospace, SFMono-Regular, monospace;
+}
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.42);
+ background: rgba(251, 247, 239, 0.92);
+ backdrop-filter: blur(12px);
+}
+
+.site-header__inner,
+.site-main {
+ width: min(1080px, calc(100vw - 2rem));
+ margin: 0 auto;
+}
+
+.site-header__inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 72px;
+}
+
+.site-brand {
+ color: var(--text);
+ font-size: 1.15rem;
+ font-weight: 700;
+ text-decoration: none;
+}
+
+.site-nav {
+ display: flex;
+ gap: 1rem;
+}
+
+.site-nav a {
+ text-decoration: none;
+}
+
+.site-main {
+ padding: 2rem 0 4rem;
+}
+
+.hero-panel,
+.doc-list,
+.document-shell,
+.error-panel {
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ background: var(--panel);
+ box-shadow: var(--shadow);
+}
+
+.hero-panel,
+.error-panel {
+ padding: 2rem;
+}
+
+.doc-list,
+.document-shell {
+ margin-top: 1rem;
+ padding: 1.5rem 2rem;
+}
+
+.eyebrow {
+ margin: 0 0 0.8rem;
+ color: var(--accent);
+ font: 700 0.78rem/1.2 ui-monospace, SFMono-Regular, monospace;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+}
+
+.lede {
+ max-width: 44rem;
+ color: var(--muted);
+ line-height: 1.7;
+}
+
+.doc-list ul,
+.tag-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.doc-list li {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0.75rem 0;
+ border-top: 1px solid var(--border);
+}
+
+.doc-list li:first-child {
+ border-top: 0;
+}
+
+.tag-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.tag-list a {
+ display: inline-flex;
+ padding: 0.35rem 0.65rem;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ text-decoration: none;
+}
+
+.hash {
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.markdown-body {
+ line-height: 1.75;
+}
+
+.markdown-body pre {
+ overflow-x: auto;
+ padding: 1rem;
+ border-radius: var(--radius-md);
+ background: #18202a;
+ color: #f7f9fc;
+}
+
+.markdown-body blockquote {
+ margin-left: 0;
+ padding: 0.9rem 1rem;
+ border-left: 4px solid var(--accent);
+ border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
+ background: rgba(15, 91, 216, 0.05);
+}
+
+.markdown-body img {
+ max-width: 100%;
+}
+
+@media (max-width: 720px) {
+ .site-header__inner {
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 0.5rem;
+ padding: 0.75rem 0;
+ }
+
+ .doc-list,
+ .document-shell {
+ padding: 1.25rem;
+ }
+}
+
diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml
new file mode 100644
index 0000000..54e766f
--- /dev/null
+++ b/apps/server/internal/httpserver/templates/base.gohtml
@@ -0,0 +1,32 @@
+{{ define "base" }}
+
+
+
+
+
+ {{ .Title }}
+
+
+
+
+
+ {{ if eq .BodyTemplate "index_content" }}
+ {{ template "index_content" .Data }}
+ {{ else if eq .BodyTemplate "document_content" }}
+ {{ template "document_content" .Data }}
+ {{ else if eq .BodyTemplate "error_content" }}
+ {{ template "error_content" .Data }}
+ {{ end }}
+
+
+
+{{ end }}
diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml
new file mode 100644
index 0000000..0f344c4
--- /dev/null
+++ b/apps/server/internal/httpserver/templates/document.gohtml
@@ -0,0 +1,22 @@
+{{ define "document.gohtml" }}{{ template "base" . }}{{ end }}
+
+{{ define "document_content" }}
+
+
+
+
+ {{ .HTML }}
+
+
+{{ end }}
diff --git a/apps/server/internal/httpserver/templates/error.gohtml b/apps/server/internal/httpserver/templates/error.gohtml
new file mode 100644
index 0000000..77033ee
--- /dev/null
+++ b/apps/server/internal/httpserver/templates/error.gohtml
@@ -0,0 +1,9 @@
+{{ define "error.gohtml" }}{{ template "base" . }}{{ end }}
+
+{{ define "error_content" }}
+
+ Error
+ {{ .Status }}
+ {{ .Message }}
+
+{{ end }}
diff --git a/apps/server/internal/httpserver/templates/index.gohtml b/apps/server/internal/httpserver/templates/index.gohtml
new file mode 100644
index 0000000..127cea2
--- /dev/null
+++ b/apps/server/internal/httpserver/templates/index.gohtml
@@ -0,0 +1,23 @@
+{{ define "index.gohtml" }}{{ template "base" . }}{{ end }}
+
+{{ define "index_content" }}
+
+ Foundation
+ Server-rendered Markdown, secure attachments, and a clean base for sync.
+ This milestone focuses on a fast read path. Documents are synchronized from the content directory into content-addressed storage and rendered to HTML on demand.
+
+
+
+ Documents
+
+ {{ range .Documents }}
+
+ {{ .Title }}
+ {{ .Path }}
+
+ {{ else }}
+ No documents found.
+ {{ end }}
+
+
+{{ end }}
diff --git a/apps/server/internal/logging/logging.go b/apps/server/internal/logging/logging.go
new file mode 100644
index 0000000..00d305e
--- /dev/null
+++ b/apps/server/internal/logging/logging.go
@@ -0,0 +1,24 @@
+package logging
+
+import (
+ "log/slog"
+ "os"
+ "strings"
+)
+
+func New(level string) *slog.Logger {
+ var slogLevel slog.Level
+ switch strings.ToUpper(level) {
+ case "DEBUG":
+ slogLevel = slog.LevelDebug
+ case "WARN":
+ slogLevel = slog.LevelWarn
+ case "ERROR":
+ slogLevel = slog.LevelError
+ default:
+ slogLevel = slog.LevelInfo
+ }
+
+ handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slogLevel})
+ return slog.New(handler)
+}
diff --git a/apps/server/internal/markdown/renderer.go b/apps/server/internal/markdown/renderer.go
new file mode 100644
index 0000000..a43ab31
--- /dev/null
+++ b/apps/server/internal/markdown/renderer.go
@@ -0,0 +1,168 @@
+package markdown
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/parser"
+ "github.com/yuin/goldmark/renderer/html"
+)
+
+var (
+ tagPattern = regexp.MustCompile(`(^|[\s(])#([a-zA-Z0-9_-]+)\b`)
+)
+
+type Result struct {
+ HTML template.HTML
+ Title string
+ Tags []string
+}
+
+type Renderer struct {
+ markdown goldmark.Markdown
+}
+
+func NewRenderer() *Renderer {
+ return &Renderer{
+ markdown: goldmark.New(
+ goldmark.WithExtensions(
+ extension.GFM,
+ extension.Footnote,
+ extension.DefinitionList,
+ extension.Table,
+ extension.Strikethrough,
+ extension.TaskList,
+ extension.Typographer,
+ ),
+ goldmark.WithParserOptions(
+ parser.WithAutoHeadingID(),
+ ),
+ goldmark.WithRendererOptions(
+ html.WithHardWraps(),
+ html.WithXHTML(),
+ ),
+ ),
+ }
+}
+
+func (r *Renderer) Render(content []byte) (Result, error) {
+ title := extractTitle(string(content))
+ tags := extractTags(string(content))
+ prepared := preprocess(string(content))
+
+ var output bytes.Buffer
+ if err := r.markdown.Convert([]byte(prepared), &output); err != nil {
+ return Result{}, fmt.Errorf("render markdown: %w", err)
+ }
+
+ return Result{
+ HTML: template.HTML(output.String()),
+ Title: title,
+ Tags: tags,
+ }, nil
+}
+
+func preprocess(input string) string {
+ lines := strings.Split(input, "\n")
+ for index, line := range lines {
+ line = rewriteWikiLinks(line)
+ line = normalizeAdmonition(line)
+ lines[index] = line
+ }
+ return strings.Join(lines, "\n")
+}
+
+func rewriteWikiLinks(line string) string {
+ var builder strings.Builder
+ remaining := line
+
+ for {
+ start := strings.Index(remaining, "[[")
+ if start == -1 {
+ builder.WriteString(remaining)
+ return builder.String()
+ }
+
+ end := strings.Index(remaining[start+2:], "]]")
+ if end == -1 {
+ builder.WriteString(remaining)
+ return builder.String()
+ }
+
+ builder.WriteString(remaining[:start])
+ label := strings.TrimSpace(remaining[start+2 : start+2+end])
+ if label == "" {
+ builder.WriteString(remaining[start : start+2+end+2])
+ } else {
+ builder.WriteString(fmt.Sprintf("[%s](/docs/%s)", label, slugify(label)))
+ }
+
+ remaining = remaining[start+2+end+2:]
+ }
+}
+
+func normalizeAdmonition(line string) string {
+ switch {
+ case strings.HasPrefix(line, "> [!NOTE]"):
+ return strings.Replace(line, "> [!NOTE]", "> **Note:**", 1)
+ case strings.HasPrefix(line, "> [!TIP]"):
+ return strings.Replace(line, "> [!TIP]", "> **Tip:**", 1)
+ case strings.HasPrefix(line, "> [!WARNING]"):
+ return strings.Replace(line, "> [!WARNING]", "> **Warning:**", 1)
+ default:
+ return line
+ }
+}
+
+func extractTitle(content string) string {
+ for _, line := range strings.Split(content, "\n") {
+ if strings.HasPrefix(line, "# ") {
+ return strings.TrimSpace(strings.TrimPrefix(line, "# "))
+ }
+ }
+ return "Untitled"
+}
+
+func extractTags(content string) []string {
+ matches := tagPattern.FindAllStringSubmatch(content, -1)
+ tags := make([]string, 0, len(matches))
+ seen := make(map[string]struct{}, len(matches))
+ for _, match := range matches {
+ if len(match) < 3 {
+ continue
+ }
+ tag := match[2]
+ if _, exists := seen[tag]; exists {
+ continue
+ }
+ seen[tag] = struct{}{}
+ tags = append(tags, tag)
+ }
+ slices.Sort(tags)
+ return tags
+}
+
+func slugify(value string) string {
+ value = strings.ToLower(strings.TrimSpace(value))
+ var builder strings.Builder
+ lastDash := false
+ for _, r := range value {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
+ builder.WriteRune(r)
+ lastDash = false
+ default:
+ if !lastDash {
+ builder.WriteByte('-')
+ lastDash = true
+ }
+ }
+ }
+ return strings.Trim(builder.String(), "-")
+}
diff --git a/apps/server/internal/markdown/renderer_test.go b/apps/server/internal/markdown/renderer_test.go
new file mode 100644
index 0000000..010065b
--- /dev/null
+++ b/apps/server/internal/markdown/renderer_test.go
@@ -0,0 +1,23 @@
+package markdown
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderRewritesWikiLinksAndTags(t *testing.T) {
+ renderer := NewRenderer()
+
+ result, err := renderer.Render([]byte("# Title\n\nLink to [[Project Plan]] and #alpha #beta.\n"))
+ if err != nil {
+ t.Fatalf("Render() error = %v", err)
+ }
+
+ if !strings.Contains(string(result.HTML), `/docs/project-plan`) {
+ t.Fatalf("expected wiki-link rewrite, got %s", result.HTML)
+ }
+
+ if len(result.Tags) != 2 || result.Tags[0] != "alpha" || result.Tags[1] != "beta" {
+ t.Fatalf("unexpected tags: %#v", result.Tags)
+ }
+}
diff --git a/apps/server/internal/store/content_store.go b/apps/server/internal/store/content_store.go
new file mode 100644
index 0000000..c93a898
--- /dev/null
+++ b/apps/server/internal/store/content_store.go
@@ -0,0 +1,70 @@
+package store
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+type Record struct {
+ Hash string
+ Path string
+ Size int64
+}
+
+type ContentStore struct {
+ root string
+}
+
+func New(root string) (*ContentStore, error) {
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return nil, fmt.Errorf("create content store root: %w", err)
+ }
+
+ return &ContentStore{root: root}, nil
+}
+
+func (s *ContentStore) PutBytes(content []byte) (Record, error) {
+ return s.put(bytes.NewReader(content))
+}
+
+func (s *ContentStore) PutReader(reader io.Reader) (Record, error) {
+ return s.put(reader)
+}
+
+func (s *ContentStore) Read(hash string) ([]byte, error) {
+ return os.ReadFile(s.PathForHash(hash))
+}
+
+func (s *ContentStore) PathForHash(hash string) string {
+ return filepath.Join(s.root, hash[0:2], hash[2:4], hash)
+}
+
+func (s *ContentStore) put(reader io.Reader) (Record, error) {
+ content, err := io.ReadAll(reader)
+ if err != nil {
+ return Record{}, fmt.Errorf("read content: %w", err)
+ }
+
+ sum := sha256.Sum256(content)
+ hash := hex.EncodeToString(sum[:])
+ target := s.PathForHash(hash)
+
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return Record{}, fmt.Errorf("create content directory: %w", err)
+ }
+
+ if _, err := os.Stat(target); err == nil {
+ return Record{Hash: hash, Path: target, Size: int64(len(content))}, nil
+ }
+
+ if err := os.WriteFile(target, content, 0o644); err != nil {
+ return Record{}, fmt.Errorf("write content file: %w", err)
+ }
+
+ return Record{Hash: hash, Path: target, Size: int64(len(content))}, nil
+}
diff --git a/apps/server/internal/store/content_store_test.go b/apps/server/internal/store/content_store_test.go
new file mode 100644
index 0000000..41ec463
--- /dev/null
+++ b/apps/server/internal/store/content_store_test.go
@@ -0,0 +1,34 @@
+package store
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestContentStoreDeduplicatesByHash(t *testing.T) {
+ root := t.TempDir()
+
+ store, err := New(root)
+ if err != nil {
+ t.Fatalf("New() error = %v", err)
+ }
+
+ first, err := store.PutBytes([]byte("hello"))
+ if err != nil {
+ t.Fatalf("PutBytes() first error = %v", err)
+ }
+
+ second, err := store.PutBytes([]byte("hello"))
+ if err != nil {
+ t.Fatalf("PutBytes() second error = %v", err)
+ }
+
+ if first.Hash != second.Hash {
+ t.Fatalf("hash mismatch: %s != %s", first.Hash, second.Hash)
+ }
+
+ if _, err := os.Stat(filepath.Join(root, first.Hash[0:2], first.Hash[2:4], first.Hash)); err != nil {
+ t.Fatalf("expected content file to exist: %v", err)
+ }
+}
diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore
new file mode 100644
index 0000000..30b9507
--- /dev/null
+++ b/apps/web/.prettierignore
@@ -0,0 +1,2 @@
+dist/
+
diff --git a/apps/web/index.html b/apps/web/index.html
new file mode 100644
index 0000000..82aa001
--- /dev/null
+++ b/apps/web/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ MD Hub Secure App
+
+
+
+
+
+
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
new file mode 100644
index 0000000..3603de5
--- /dev/null
+++ b/apps/web/package-lock.json
@@ -0,0 +1,2108 @@
+{
+ "name": "md-hub-secure-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "md-hub-secure-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "preact": "^10.27.2",
+ "preact-iso": "^2.9.3"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.10.2",
+ "prettier": "^3.6.2",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.27.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz",
+ "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.27.3"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
+ "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz",
+ "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz",
+ "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.27.3",
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "@babel/plugin-syntax-jsx": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-development": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz",
+ "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+ "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+ "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+ "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+ "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+ "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+ "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+ "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+ "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+ "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+ "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+ "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+ "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+ "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+ "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+ "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+ "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+ "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+ "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+ "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+ "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+ "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+ "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+ "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@preact/preset-vite": {
+ "version": "2.10.5",
+ "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.10.5.tgz",
+ "integrity": "sha512-p0vJpxiVO7KWWazWny3LUZ+saXyZKWv6Ju0bYMWNJRp2YveufRPgSUB1C4MTqGJfz07EehMgfN+AJNwQy+w6Iw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/plugin-transform-react-jsx": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-development": "^7.27.1",
+ "@prefresh/vite": "^2.4.11",
+ "@rollup/pluginutils": "^5.0.0",
+ "babel-plugin-transform-hook-names": "^1.0.2",
+ "debug": "^4.4.3",
+ "magic-string": "^0.30.21",
+ "picocolors": "^1.1.1",
+ "vite-prerender-plugin": "^0.5.8",
+ "zimmerframe": "^1.1.4"
+ },
+ "peerDependencies": {
+ "@babel/core": "7.x",
+ "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x || 8.x"
+ }
+ },
+ "node_modules/@prefresh/babel-plugin": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz",
+ "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@prefresh/core": {
+ "version": "1.5.9",
+ "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.9.tgz",
+ "integrity": "sha512-IKBKCPaz34OFVC+adiQ2qaTF5qdztO2/4ZPf4KsRTgjKosWqxVXmEbxCiUydYZRY8GVie+DQlKzQr9gt6HQ+EQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": "^10.0.0 || ^11.0.0-0"
+ }
+ },
+ "node_modules/@prefresh/utils": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz",
+ "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@prefresh/vite": {
+ "version": "2.4.12",
+ "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz",
+ "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.22.1",
+ "@prefresh/babel-plugin": "^0.5.2",
+ "@prefresh/core": "^1.5.0",
+ "@prefresh/utils": "^1.2.0",
+ "@rollup/pluginutils": "^4.2.1"
+ },
+ "peerDependencies": {
+ "preact": "^10.4.0 || ^11.0.0-0",
+ "vite": ">=2.0.0"
+ }
+ },
+ "node_modules/@prefresh/vite/node_modules/@rollup/pluginutils": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
+ "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "estree-walker": "^2.0.1",
+ "picomatch": "^2.2.2"
+ },
+ "engines": {
+ "node": ">= 8.0.0"
+ }
+ },
+ "node_modules/@prefresh/vite/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz",
+ "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz",
+ "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz",
+ "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz",
+ "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz",
+ "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz",
+ "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz",
+ "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz",
+ "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz",
+ "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz",
+ "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz",
+ "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz",
+ "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz",
+ "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz",
+ "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz",
+ "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz",
+ "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz",
+ "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz",
+ "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz",
+ "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz",
+ "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz",
+ "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz",
+ "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz",
+ "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz",
+ "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz",
+ "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/babel-plugin-transform-hook-names": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
+ "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@babel/core": "^7.12.10"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.24",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.24.tgz",
+ "integrity": "sha512-I2NkZOOrj2XuguvWCK6OVh9GavsNjZjK908Rq3mIBK25+GD8vPX5w2WdxVqnQ7xx3SrZJiCiZFu+/Oz50oSYSA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001791",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+ "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.344",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+ "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.7",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+ "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.7",
+ "@esbuild/android-arm": "0.27.7",
+ "@esbuild/android-arm64": "0.27.7",
+ "@esbuild/android-x64": "0.27.7",
+ "@esbuild/darwin-arm64": "0.27.7",
+ "@esbuild/darwin-x64": "0.27.7",
+ "@esbuild/freebsd-arm64": "0.27.7",
+ "@esbuild/freebsd-x64": "0.27.7",
+ "@esbuild/linux-arm": "0.27.7",
+ "@esbuild/linux-arm64": "0.27.7",
+ "@esbuild/linux-ia32": "0.27.7",
+ "@esbuild/linux-loong64": "0.27.7",
+ "@esbuild/linux-mips64el": "0.27.7",
+ "@esbuild/linux-ppc64": "0.27.7",
+ "@esbuild/linux-riscv64": "0.27.7",
+ "@esbuild/linux-s390x": "0.27.7",
+ "@esbuild/linux-x64": "0.27.7",
+ "@esbuild/netbsd-arm64": "0.27.7",
+ "@esbuild/netbsd-x64": "0.27.7",
+ "@esbuild/openbsd-arm64": "0.27.7",
+ "@esbuild/openbsd-x64": "0.27.7",
+ "@esbuild/openharmony-arm64": "0.27.7",
+ "@esbuild/sunos-x64": "0.27.7",
+ "@esbuild/win32-arm64": "0.27.7",
+ "@esbuild/win32-ia32": "0.27.7",
+ "@esbuild/win32-x64": "0.27.7"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/he": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
+ "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "he": "bin/he"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/kolorist": {
+ "version": "1.8.0",
+ "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
+ "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-html-parser": {
+ "version": "6.1.13",
+ "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz",
+ "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-select": "^5.1.0",
+ "he": "1.2.0"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.12",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
+ "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/preact": {
+ "version": "10.29.1",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
+ "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-iso": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/preact-iso/-/preact-iso-2.11.1.tgz",
+ "integrity": "sha512-rLy0RmzP/hrDjnFdnEblxFgKtzUj4njkHrpGJBGS7S4QuYw1zv0lA38qsWpeAAB10JAz/hF2CsHrLen9ufCtbw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "preact": ">=10 || >= 11.0.0-0",
+ "preact-render-to-string": ">=6.4.0"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "6.6.7",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.6.7.tgz",
+ "integrity": "sha512-3XdbsX3+vn9dQW+jJI/FsI9rlkgl6dbeUpqLsChak6jp3j3auFqBCkno7VChbMFs5Q8ylBj6DrUkKRwtVN3nvw==",
+ "license": "MIT",
+ "peer": true,
+ "peerDependencies": {
+ "preact": ">=10 || >= 11.0.0-0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.60.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz",
+ "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.2",
+ "@rollup/rollup-android-arm64": "4.60.2",
+ "@rollup/rollup-darwin-arm64": "4.60.2",
+ "@rollup/rollup-darwin-x64": "4.60.2",
+ "@rollup/rollup-freebsd-arm64": "4.60.2",
+ "@rollup/rollup-freebsd-x64": "4.60.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.2",
+ "@rollup/rollup-linux-arm64-musl": "4.60.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.2",
+ "@rollup/rollup-linux-loong64-musl": "4.60.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-gnu": "4.60.2",
+ "@rollup/rollup-linux-x64-musl": "4.60.2",
+ "@rollup/rollup-openbsd-x64": "4.60.2",
+ "@rollup/rollup-openharmony-arm64": "4.60.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.2",
+ "@rollup/rollup-win32-x64-gnu": "4.60.2",
+ "@rollup/rollup-win32-x64-msvc": "4.60.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/simple-code-frame": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/simple-code-frame/-/simple-code-frame-1.3.0.tgz",
+ "integrity": "sha512-MB4pQmETUBlNs62BBeRjIFGeuy/x6gGKh7+eRUemn1rCFhqo7K+4slPqsyizCbcbYLnaYqaoZ2FWsZ/jN06D8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kolorist": "^1.6.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
+ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stack-trace": {
+ "version": "1.0.0-pre2",
+ "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0-pre2.tgz",
+ "integrity": "sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.16",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+ "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "7.3.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
+ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-prerender-plugin": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/vite-prerender-plugin/-/vite-prerender-plugin-0.5.13.tgz",
+ "integrity": "sha512-IKSpYkzDBsKAxa05naRbj7GvNVMSdww/Z/E89oO3xndz+gWnOBOKOAbEXv7qDhktY/j3vHgJmoV1pPzqU2tx9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "kolorist": "^1.8.0",
+ "magic-string": "0.x >= 0.26.0",
+ "node-html-parser": "^6.1.12",
+ "simple-code-frame": "^1.3.0",
+ "source-map": "^0.7.4",
+ "stack-trace": "^1.0.0-pre2"
+ },
+ "peerDependencies": {
+ "vite": "5.x || 6.x || 7.x || 8.x"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zimmerframe": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
+ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..e512c27
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "md-hub-secure-web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "format": "prettier --check .",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "preact": "^10.27.2",
+ "preact-iso": "^2.9.3"
+ },
+ "devDependencies": {
+ "@preact/preset-vite": "^2.10.2",
+ "prettier": "^3.6.2",
+ "typescript": "^5.9.3",
+ "vite": "^7.1.12"
+ }
+}
diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx
new file mode 100644
index 0000000..aa6865d
--- /dev/null
+++ b/apps/web/src/app.tsx
@@ -0,0 +1,31 @@
+export function App() {
+ return (
+
+
+ MD Hub Secure
+ Preact foundation for future hydrated features
+
+ The Go server is the source of truth for rendering document pages.
+ This app is the scaffold for search, sync, comments, and design
+ tooling that will hydrate over time.
+
+
+
+
+
+ Current scope
+
+ Routing, bundling, TypeScript, and styles are in place for later
+ feature islands.
+
+
+
+ Next milestone
+
+ Sync protocol UI, offline cache status, and browser-side search.
+
+
+
+
+ );
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..5834927
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,11 @@
+import { render } from "preact";
+import { LocationProvider, Route, Router } from "preact-iso";
+import { App } from "./app";
+import "./styles.css";
+
+render(
+
+ {[ ]}
+ ,
+ document.getElementById("app")!,
+);
diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css
new file mode 100644
index 0000000..2195d52
--- /dev/null
+++ b/apps/web/src/styles.css
@@ -0,0 +1,91 @@
+:root {
+ color-scheme: light;
+ --bg: #f4f1ea;
+ --surface: rgba(255, 255, 255, 0.78);
+ --surface-strong: #ffffff;
+ --text: #202227;
+ --muted: #5f6471;
+ --accent: #1947e5;
+ --accent-2: #0f8a6c;
+ --border: rgba(32, 34, 39, 0.12);
+ --shadow: 0 20px 60px rgba(25, 71, 229, 0.12);
+ font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ color: var(--text);
+ background:
+ radial-gradient(
+ circle at top left,
+ rgba(25, 71, 229, 0.16),
+ transparent 36%
+ ),
+ radial-gradient(
+ circle at bottom right,
+ rgba(15, 138, 108, 0.18),
+ transparent 30%
+ ),
+ linear-gradient(180deg, #faf7f2 0%, var(--bg) 100%);
+}
+
+.shell {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 4rem 1.5rem 5rem;
+}
+
+.hero {
+ padding: 2rem;
+ border: 1px solid var(--border);
+ border-radius: 28px;
+ background: var(--surface);
+ backdrop-filter: blur(10px);
+ box-shadow: var(--shadow);
+}
+
+.eyebrow {
+ margin: 0 0 0.75rem;
+ color: var(--accent);
+ font-family: ui-monospace, SFMono-Regular, monospace;
+ font-size: 0.82rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+}
+
+.hero h1 {
+ margin: 0;
+ font-size: clamp(2.25rem, 5vw, 4rem);
+ line-height: 0.95;
+}
+
+.lede {
+ max-width: 42rem;
+ margin: 1.5rem 0 0;
+ font-size: 1.1rem;
+ line-height: 1.7;
+ color: var(--muted);
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+.card {
+ padding: 1.25rem;
+ border: 1px solid var(--border);
+ border-radius: 20px;
+ background: var(--surface-strong);
+}
+
+.card h2 {
+ margin-top: 0;
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..c8fe083
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "ES2022"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "jsxImportSource": "preact"
+ },
+ "include": ["src"]
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..a6bba0c
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import preact from "@preact/preset-vite";
+
+export default defineConfig({
+ plugins: [preact()],
+ build: {
+ outDir: "dist",
+ sourcemap: true,
+ },
+});
diff --git a/content/architecture-overview.md b/content/architecture-overview.md
new file mode 100644
index 0000000..8cc2216
--- /dev/null
+++ b/content/architecture-overview.md
@@ -0,0 +1,11 @@
+# Architecture Overview
+
+MD Hub Secure uses a Go application server as the primary runtime.
+
+- Markdown documents are read from the source directory.
+- The current version is written into content-addressed storage.
+- Metadata lives in the local libsql-compatible database.
+- HTML is rendered server-side first.
+
+See [[Getting Started]] for a feature-oriented overview.
+
diff --git a/content/getting-started.md b/content/getting-started.md
new file mode 100644
index 0000000..2fa8c22
--- /dev/null
+++ b/content/getting-started.md
@@ -0,0 +1,33 @@
+# Getting Started
+
+Welcome to **MD Hub Secure**.
+
+See [[Architecture Overview]] for the system shape and [[Project Plan]] for the milestone roadmap.
+
+## Features
+
+- Wiki links
+- GitHub-flavored Markdown
+- Tags like #foundation and #security
+- Admonitions
+- Mermaid diagrams
+- Math delimiters for later KaTeX hydration
+
+> [!NOTE]
+> This is a representative admonition block.
+> It should render with a strong callout style.
+
+```mermaid
+flowchart TD
+ A[Markdown] --> B[Renderer]
+ B --> C[Secure HTML]
+```
+
+Inline math: $E = mc^2$
+
+Block math:
+
+$$
+\int_0^1 x^2 dx = \frac{1}{3}
+$$
+
diff --git a/content/project-plan.md b/content/project-plan.md
new file mode 100644
index 0000000..c44603e
--- /dev/null
+++ b/content/project-plan.md
@@ -0,0 +1,15 @@
+# Project Plan
+
+The implementation is moving in milestone order:
+
+1. Foundation
+2. Sync protocol
+3. Authentication
+4. Collaboration
+5. Search and design
+6. Production hardening
+
+Current emphasis: #foundation #security
+
+See [[Getting Started]] for the currently working demo path.
+
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..d102a65
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,23 @@
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - "8080:8080"
+ environment:
+ MD_HUB_SERVER_ADDR: ":8080"
+ MD_HUB_DATABASE_PATH: "/workspace/data/db.sqlite"
+ MD_HUB_CONTENT_SOURCE_DIR: "/workspace/content"
+ MD_HUB_CONTENT_STORE_DIR: "/workspace/data/files"
+ MD_HUB_WEB_DIST_DIR: "/workspace/web-dist"
+ volumes:
+ - ./content:/workspace/content:ro
+ - ./data:/workspace/data
+ healthcheck:
+ test: ["CMD", "curl", "--fail", "http://127.0.0.1:8080/health"]
+ interval: 10s
+ timeout: 3s
+ retries: 5
+ start_period: 5s
+
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..232f054
--- /dev/null
+++ b/go.work
@@ -0,0 +1,4 @@
+go 1.24.2
+
+use ./apps/server
+
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..1d5434e
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,3 @@
+golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=