From 4a72e1e030b13991424e530361d2b9046950e0bf Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Wed, 29 Apr 2026 00:26:58 -0400 Subject: [PATCH] feat: bootstrap foundation application --- .github/workflows/ci.yml | 48 + .gitignore | 11 + Dockerfile | 35 + Makefile | 34 + README.md | 68 + apps/server/cmd/md-hub-secure/main.go | 45 + apps/server/go.mod | 15 + apps/server/go.sum | 20 + apps/server/internal/app/app.go | 98 + apps/server/internal/config/config.go | 99 + apps/server/internal/database/database.go | 79 + apps/server/internal/database/migrate.go | 98 + .../migrations/000001_initial.down.sql | 5 + .../database/migrations/000001_initial.up.sql | 8 + .../migrations/000002_documents.down.sql | 2 + .../migrations/000002_documents.up.sql | 10 + .../000003_document_versions.down.sql | 2 + .../000003_document_versions.up.sql | 11 + .../migrations/000004_attachments.down.sql | 2 + .../migrations/000004_attachments.up.sql | 8 + .../migrations/000005_indexes.down.sql | 3 + .../database/migrations/000005_indexes.up.sql | 3 + apps/server/internal/docs/repository.go | 205 ++ apps/server/internal/docs/service.go | 154 ++ apps/server/internal/httpserver/fs.go | 7 + apps/server/internal/httpserver/handlers.go | 242 ++ apps/server/internal/httpserver/middleware.go | 42 + apps/server/internal/httpserver/server.go | 93 + .../internal/httpserver/static/site.css | 194 ++ .../internal/httpserver/templates/base.gohtml | 32 + .../httpserver/templates/document.gohtml | 22 + .../httpserver/templates/error.gohtml | 9 + .../httpserver/templates/index.gohtml | 23 + apps/server/internal/logging/logging.go | 24 + apps/server/internal/markdown/renderer.go | 168 ++ .../server/internal/markdown/renderer_test.go | 23 + apps/server/internal/store/content_store.go | 70 + .../internal/store/content_store_test.go | 34 + apps/web/.prettierignore | 2 + apps/web/index.html | 12 + apps/web/package-lock.json | 2108 +++++++++++++++++ apps/web/package.json | 22 + apps/web/src/app.tsx | 31 + apps/web/src/main.tsx | 11 + apps/web/src/styles.css | 91 + apps/web/tsconfig.json | 21 + apps/web/vite.config.ts | 10 + content/architecture-overview.md | 11 + content/getting-started.md | 33 + content/project-plan.md | 15 + docker-compose.yml | 23 + go.work | 4 + go.work.sum | 3 + 53 files changed, 4443 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 apps/server/cmd/md-hub-secure/main.go create mode 100644 apps/server/go.mod create mode 100644 apps/server/go.sum create mode 100644 apps/server/internal/app/app.go create mode 100644 apps/server/internal/config/config.go create mode 100644 apps/server/internal/database/database.go create mode 100644 apps/server/internal/database/migrate.go create mode 100644 apps/server/internal/database/migrations/000001_initial.down.sql create mode 100644 apps/server/internal/database/migrations/000001_initial.up.sql create mode 100644 apps/server/internal/database/migrations/000002_documents.down.sql create mode 100644 apps/server/internal/database/migrations/000002_documents.up.sql create mode 100644 apps/server/internal/database/migrations/000003_document_versions.down.sql create mode 100644 apps/server/internal/database/migrations/000003_document_versions.up.sql create mode 100644 apps/server/internal/database/migrations/000004_attachments.down.sql create mode 100644 apps/server/internal/database/migrations/000004_attachments.up.sql create mode 100644 apps/server/internal/database/migrations/000005_indexes.down.sql create mode 100644 apps/server/internal/database/migrations/000005_indexes.up.sql create mode 100644 apps/server/internal/docs/repository.go create mode 100644 apps/server/internal/docs/service.go create mode 100644 apps/server/internal/httpserver/fs.go create mode 100644 apps/server/internal/httpserver/handlers.go create mode 100644 apps/server/internal/httpserver/middleware.go create mode 100644 apps/server/internal/httpserver/server.go create mode 100644 apps/server/internal/httpserver/static/site.css create mode 100644 apps/server/internal/httpserver/templates/base.gohtml create mode 100644 apps/server/internal/httpserver/templates/document.gohtml create mode 100644 apps/server/internal/httpserver/templates/error.gohtml create mode 100644 apps/server/internal/httpserver/templates/index.gohtml create mode 100644 apps/server/internal/logging/logging.go create mode 100644 apps/server/internal/markdown/renderer.go create mode 100644 apps/server/internal/markdown/renderer_test.go create mode 100644 apps/server/internal/store/content_store.go create mode 100644 apps/server/internal/store/content_store_test.go create mode 100644 apps/web/.prettierignore create mode 100644 apps/web/index.html create mode 100644 apps/web/package-lock.json create mode 100644 apps/web/package.json create mode 100644 apps/web/src/app.tsx create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 content/architecture-overview.md create mode 100644 content/getting-started.md create mode 100644 content/project-plan.md create mode 100644 docker-compose.yml create mode 100644 go.work create mode 100644 go.work.sum 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" }} +
+
+

{{ .Path }}

+

{{ .Title }}

+ {{ if .Tags }} +
    + {{ range .Tags }} +
  • #{{ . }}
  • + {{ end }} +
+ {{ end }} +

SHA-256: {{ .Hash }}

+
+ +
+ {{ .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

+ +
+{{ 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=