Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ad26910a9 | ||
|
|
e575f34b34 | ||
|
|
2491d21963 | ||
|
|
6c1387c1cd | ||
|
|
3cc632e358 | ||
|
|
e689ab08c9 | ||
|
|
19fb5b9292 | ||
|
|
9a1f7e0d99 | ||
|
|
dd5e32cc4d | ||
|
|
b4e8baabdc | ||
|
|
ab3c8982e2 | ||
|
|
df9e6e95cb | ||
|
|
2154d6cfa8 | ||
|
|
9002f13f80 |
@@ -0,0 +1,53 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go generate ./... && go build -o ./tmp/main ./cmd/schreifuchs-ch/main.go"
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules", ".git", ".gitea"]
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go", "_templ.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = "dlv exec ./tmp/main --listen=127.0.0.1:2345 --headless=true --api-version=2 --accept-multiclient --continue --log --"
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "tpl", "tmpl", "templ", "html", "css"]
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
rerun = false
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
enabled = true
|
||||||
|
proxy_port = 8888
|
||||||
|
app_port = 8080
|
||||||
|
app_start_timeout = 50000000
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[env]
|
||||||
|
TEST_PW = "test"
|
||||||
|
VALKEY_ADDR = "127.0.0.1:6379"
|
||||||
|
VALKEY_PASSWORD = "valkey"
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json",
|
|
||||||
"mcpServers": {
|
|
||||||
"svelte": {
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@sveltejs/mcp"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
|
/tmp
|
||||||
|
/tmp/*
|
||||||
/build
|
/build
|
||||||
/.svelte-kit
|
|
||||||
/package
|
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
/web/**/*_templ.go
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Debug Server",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "debug",
|
||||||
|
"program": "${workspaceFolder}/cmd/schreifuchs-ch/main.go",
|
||||||
|
"cwd": "${workspaceFolder}",
|
||||||
|
"showLog": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Attach to Air",
|
||||||
|
"type": "go",
|
||||||
|
"request": "attach",
|
||||||
|
"mode": "remote",
|
||||||
|
"port": 2345,
|
||||||
|
"host": "127.0.0.1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,46 +1,54 @@
|
|||||||
# Stage 1: Build the application
|
# Stage 1: Build frontend assets
|
||||||
FROM node:24-slim AS builder
|
FROM node:24-alpine AS frontend-builder
|
||||||
|
|
||||||
# Enable corepack to use pnpm
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy configuration files
|
# Enable pnpm via corepack
|
||||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Install all dependencies (including devDependencies)
|
# Install dependencies
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
# Copy the rest of the application code
|
# Copy source and build assets
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pnpm run build:css && pnpm run build:js
|
||||||
|
|
||||||
# Build the SvelteKit application
|
# Stage 2: Build Go binary
|
||||||
RUN pnpm run build
|
FROM golang:1.26-alpine AS go-builder
|
||||||
|
|
||||||
# Stage 2: Run the application
|
|
||||||
FROM node:24-slim AS runner
|
|
||||||
|
|
||||||
# Enable corepack to use pnpm
|
|
||||||
RUN corepack enable
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the build output and necessary files for production
|
# Install git for potential private modules (though not strictly needed here)
|
||||||
COPY --from=builder /app/build ./build
|
RUN apk add --no-cache git
|
||||||
COPY --from=builder /app/package.json ./package.json
|
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
|
||||||
COPY --from=builder /app/.npmrc ./.npmrc
|
|
||||||
|
|
||||||
# Install only production dependencies
|
# Download Go modules
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
# Expose the port the app runs on (SvelteKit defaults to 3000)
|
# Copy source code
|
||||||
EXPOSE 3000
|
COPY . .
|
||||||
|
|
||||||
# Set environment variables
|
# Copy built frontend assets from the previous stage
|
||||||
ENV NODE_ENV=production
|
COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/
|
||||||
ENV PORT=3000
|
COPY --from=frontend-builder /app/web/static/js/htmx.min.js ./web/static/js/
|
||||||
|
|
||||||
# Run the application
|
# Generate templ files using the tool defined in go.mod
|
||||||
CMD ["node", "build"]
|
RUN go tool templ generate
|
||||||
|
|
||||||
|
# Build the optimized binary
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/server ./cmd/schreifuchs-ch
|
||||||
|
|
||||||
|
# Stage 3: Final minimal image
|
||||||
|
FROM alpine:latest
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install root certificates and timezone data
|
||||||
|
RUN apk --no-cache add ca-certificates tzdata
|
||||||
|
|
||||||
|
# Copy the compiled binary from the builder stage
|
||||||
|
COPY --from=go-builder /app/server .
|
||||||
|
|
||||||
|
# Expose the application port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Set the entrypoint
|
||||||
|
ENTRYPOINT ["/app/server"]
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# Project Context: Schreifuchs.ch
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This project is a personal portfolio website for "Schreifuchs", featuring sections for Photography (Fotos), Informatics (Informatik), Music (Musig), and Video. It is currently being transitioned from a static site to a dynamic Node.js site powered by Nextcloud via WebDAV.
|
|
||||||
|
|
||||||
## Tech Stack
|
|
||||||
|
|
||||||
- **Framework:** Svelte 5 (using Runes) & SvelteKit
|
|
||||||
- **Language:** TypeScript
|
|
||||||
- **Styling:** Tailwind CSS (with custom text-shadow plugin)
|
|
||||||
- **Bundler:** Vite
|
|
||||||
- **Adapter:** `@sveltejs/adapter-static` (SSG)
|
|
||||||
- **Markdown:** `@ts-stack/markdown` for content rendering
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
- `src/routes/`: File-based routing.
|
|
||||||
- `src/routes/fotos`, `informatik`, `musig`, `video`: Feature-specific pages.
|
|
||||||
- `src/lib/`: Shared utilities and components.
|
|
||||||
- `src/lib/components/`: Reusable UI components (`ImageTile`, `ImageLinkTile`, `Markdown`).
|
|
||||||
- `src/lib/images/`: Local image assets imported in components.
|
|
||||||
- `static/`: Static assets served as-is.
|
|
||||||
- Contains Markdown content files (`Fotos.md`, etc.) and their associated attachments.
|
|
||||||
|
|
||||||
## Key Conventions
|
|
||||||
|
|
||||||
- **Svelte 5:** Use Runes (`$state`, `$derived`, `$props`, etc.) for reactivity.
|
|
||||||
- **Styling:** Use Tailwind utility classes. The font family is configured to 'Outfit'.
|
|
||||||
- **Content:** Content is largely driven by Markdown files located on Nextcloud which are fetched and parsed.
|
|
||||||
|
|
||||||
## Development Scripts
|
|
||||||
|
|
||||||
- `npm run dev`: Start development server.
|
|
||||||
- `npm run build`: Build for production.
|
|
||||||
- `npm run check`: Run Svelte and TypeScript checks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Agent Instructions (Svelte MCP)
|
|
||||||
|
|
||||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
|
||||||
|
|
||||||
## Available MCP Tools:
|
|
||||||
|
|
||||||
### 1. list-sections
|
|
||||||
|
|
||||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
|
||||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
|
||||||
|
|
||||||
### 2. get-documentation
|
|
||||||
|
|
||||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
|
||||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
|
||||||
|
|
||||||
### 3. svelte-autofixer
|
|
||||||
|
|
||||||
Analyzes Svelte code and returns issues and suggestions.
|
|
||||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
|
||||||
|
|
||||||
### 4. playground-link
|
|
||||||
|
|
||||||
Generates a Svelte Playground link with the provided code.
|
|
||||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/server"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
server := server.NewServer()
|
||||||
|
|
||||||
|
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||||
|
slog.Info("Server started", "addr", server.Addr)
|
||||||
|
|
||||||
|
err := server.ListenAndServe()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Server failed to start", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
services:
|
||||||
|
valkey:
|
||||||
|
image: valkey/valkey:8
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: valkey-server --requirepass "valkey"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "valkey-cli", "-a", "valkey", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
module git.schreifuchs.ch/schreifuchs/schreifuchs.ch
|
||||||
|
|
||||||
|
go 1.26
|
||||||
|
|
||||||
|
tool github.com/a-h/templ/cmd/templ
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/templ v0.3.977
|
||||||
|
github.com/alicebob/miniredis/v2 v2.36.1
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
|
||||||
|
github.com/studio-b12/gowebdav v0.12.0
|
||||||
|
github.com/valkey-io/valkey-go v1.0.72
|
||||||
|
golang.org/x/image v0.36.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||||
|
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cli/browser v1.3.0 // indirect
|
||||||
|
github.com/fatih/color v1.16.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/natefinch/atomic v1.0.1 // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
|
golang.org/x/mod v0.26.0 // indirect
|
||||||
|
golang.org/x/net v0.48.0 // indirect
|
||||||
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
|
golang.org/x/tools v0.35.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||||
|
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||||
|
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||||
|
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
|
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||||
|
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||||
|
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||||
|
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
|
||||||
|
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||||
|
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||||
|
github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM=
|
||||||
|
github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOVUs5kM=
|
||||||
|
github.com/studio-b12/gowebdav v0.12.0/go.mod h1:bHA7t77X/QFExdeAnDzK6vKM34kEZAcE1OX4MfiwjkE=
|
||||||
|
github.com/valkey-io/valkey-go v1.0.72 h1:iRWt1hJyOchcEgbHSkRY3aKkcBudxvMaVMsmxuYxuxE=
|
||||||
|
github.com/valkey-io/valkey-go v1.0.72/go.mod h1:VGhZ6fs68Qrn2+OhH+6waZH27bjpgQOiLyUQyXuYK5k=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||||
|
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||||
|
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||||
|
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||||
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||||
|
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotAnImage = errors.New("not an image")
|
||||||
|
|
||||||
|
func (s *Service) getMime(path string) (mimeType string, err error) {
|
||||||
|
info, err := s.fs.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("image file info could not be fetched: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mimer, ok := info.(interface{ ContentType() string }); ok {
|
||||||
|
mimeType = mimer.ContentType()
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) getImage(uid string) (file []byte, mimeType string, err error) {
|
||||||
|
path, err := PathFromUID(uid)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("could not get path: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mimeType, err = s.getMime(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(mimeType, "image") {
|
||||||
|
err = ErrNotAnImage
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err = s.fs.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("image file could not be fetched")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetImage(ctx context.Context, uid string, options Options) (img io.Reader, mimeType string, err error) {
|
||||||
|
rawImage, mimeType, err := s.getImage(uid)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := hash(rawImage, options)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if imgBytes, err := s.cache.Do(ctx, s.cache.B().Get().Key("img:"+hash).Build()).AsBytes(); err == nil {
|
||||||
|
return bytes.NewBuffer(imgBytes), mimeType, err
|
||||||
|
}
|
||||||
|
|
||||||
|
decImage, _, err := image.Decode(bytes.NewBuffer(rawImage))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
decImage = scale(decImage, options)
|
||||||
|
outBuff := bytes.NewBuffer(make([]byte, 0, 1024))
|
||||||
|
|
||||||
|
if options.Quality == 0 {
|
||||||
|
options.Quality = 80
|
||||||
|
}
|
||||||
|
|
||||||
|
err = jpeg.Encode(outBuff, decImage, &jpeg.Options{Quality: options.Quality})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = s.cache.Do(ctx, s.cache.B().Set().Key("img:"+hash).Value(valkey.BinaryString(outBuff.Bytes())).Ex(time.Hour*48).Build()).Error(); err == nil {
|
||||||
|
return outBuff, "image/jpeg", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return outBuff, "image/jpeg", err
|
||||||
|
}
|
||||||
|
|
||||||
|
func hash(bytes []byte, options Options) (hash string, err error) {
|
||||||
|
h := fnv.New128()
|
||||||
|
|
||||||
|
if _, err = h.Write(bytes); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = binary.Write(h, binary.LittleEndian, int64(options.Height)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = binary.Write(h, binary.LittleEndian, int64(options.Width)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = binary.Write(h, binary.LittleEndian, int64(options.Quality)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := h.Sum(make([]byte, 0, 128/8))
|
||||||
|
|
||||||
|
return base64.RawStdEncoding.EncodeToString(res), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/flate"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UIDFromPath(path string) (uid string, err error) {
|
||||||
|
var b bytes.Buffer
|
||||||
|
// NewWriter with NoDict (nil) creates a raw DEFLATE compressor
|
||||||
|
zw, _ := flate.NewWriter(&b, flate.BestCompression)
|
||||||
|
|
||||||
|
_, err = zw.Write([]byte(path))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = zw.Close() // Essential to flush the final bits
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64.RawURLEncoding.EncodeToString(b.Bytes()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func URLFromPath(path string) (url string, err error) {
|
||||||
|
uid, err := UIDFromPath(path)
|
||||||
|
url = "/images/" + uid
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func PathFromUID(uid string) (string, error) {
|
||||||
|
// 1. Decode the Base64 string back to compressed bytes
|
||||||
|
compressedBytes, err := base64.RawURLEncoding.DecodeString(uid)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create a flate reader (NoDict/nil since you used nil in the writer)
|
||||||
|
// flate.NewReader returns an io.ReadCloser
|
||||||
|
reader := flate.NewReader(bytes.NewReader(compressedBytes))
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// 3. Read the decompressed data into a buffer
|
||||||
|
var out bytes.Buffer
|
||||||
|
_, err = io.Copy(&out, reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SourceSet(src string) string {
|
||||||
|
sb := strings.Builder{}
|
||||||
|
|
||||||
|
for i := range 19 {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s?w=%d %dw, ", src, 100*(i+1), 100*(i+1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
i := 20
|
||||||
|
sb.WriteString(fmt.Sprintf("%s?w=%d %dw", src, 100*(i+1), 100*(i+1)))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Width int // max width, 0 is unlimited
|
||||||
|
Height int // max Height, 0 is unlimited
|
||||||
|
Quality int // quality of the output jpeg
|
||||||
|
}
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
fs fileSystem
|
||||||
|
cache valkey.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fs fileSystem, cache valkey.Client) *Service {
|
||||||
|
return &Service{
|
||||||
|
fs: fs,
|
||||||
|
cache: cache,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileSystem interface {
|
||||||
|
Read(path string) ([]byte, error)
|
||||||
|
Stat(path string) (os.FileInfo, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package images
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
func scale(src image.Image, options Options) image.Image {
|
||||||
|
// Set the expected size that you want:
|
||||||
|
dst := image.NewRGBA(
|
||||||
|
calculateFit(src.Bounds(), options),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Resize:
|
||||||
|
draw.BiLinear.Scale(dst, dst.Rect, src, src.Bounds(), draw.Over, nil)
|
||||||
|
|
||||||
|
// Encode to `output`:
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateFit returns the new dimensions (width, height) that fit within the
|
||||||
|
// provided Options without upscaling and while maintaining the aspect ratio.
|
||||||
|
func calculateFit(bounds image.Rectangle, opt Options) image.Rectangle {
|
||||||
|
// If no limits are set, or the image is already smaller than the limits,
|
||||||
|
// return original dimensions (No Upscaling).
|
||||||
|
if (opt.Width == 0 || bounds.Max.X <= opt.Width) && (opt.Height == 0 || bounds.Max.Y <= opt.Height) {
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate ratios for width and height
|
||||||
|
ratioW := float64(opt.Width) / float64(bounds.Max.X)
|
||||||
|
ratioH := float64(opt.Height) / float64(bounds.Max.Y)
|
||||||
|
|
||||||
|
// Determine the scale factor
|
||||||
|
var scale float64
|
||||||
|
if opt.Width > 0 && opt.Height > 0 {
|
||||||
|
// Use the smaller ratio to ensure it fits in both bounds
|
||||||
|
scale = math.Min(ratioW, ratioH)
|
||||||
|
} else if opt.Width > 0 {
|
||||||
|
scale = ratioW
|
||||||
|
} else if opt.Height > 0 {
|
||||||
|
scale = ratioH
|
||||||
|
} else {
|
||||||
|
return bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
return image.Rect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
int(math.Round(float64(bounds.Max.X)*scale)),
|
||||||
|
int(math.Round(float64(bounds.Max.Y)*scale)),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PageHeader struct {
|
||||||
|
UID string
|
||||||
|
Title string
|
||||||
|
CoverImageUID string
|
||||||
|
md []byte // markdown of the page //markdown of the page
|
||||||
|
}
|
||||||
|
|
||||||
|
type Page struct {
|
||||||
|
PageHeader
|
||||||
|
Content string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPages(ctx context.Context) (pages []PageHeader, err error) {
|
||||||
|
paths, err := s.fs.ReadDir("/")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, path := range paths {
|
||||||
|
if strings.HasPrefix(path.Name(), ".") || // hidden files
|
||||||
|
strings.HasPrefix(path.Name(), "_") { // hidden pages
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
page, err := s.getPageHeaders(ctx, path.Name())
|
||||||
|
if err != nil { // just ignore missing pages
|
||||||
|
slog.Error("could not open path", "path", path.Name(), "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pages = append(pages, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetPage(ctx context.Context, uid string) (page Page, err error) {
|
||||||
|
p, err := s.getPageHeaders(ctx, uid)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := s.getHTML(ctx, p)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page = Page{
|
||||||
|
PageHeader: p,
|
||||||
|
Content: string(html),
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
|
"github.com/gomarkdown/markdown"
|
||||||
|
"github.com/gomarkdown/markdown/ast"
|
||||||
|
"github.com/gomarkdown/markdown/html"
|
||||||
|
"github.com/gomarkdown/markdown/parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) getHTML(ctx context.Context, page PageHeader) (out []byte, err error) {
|
||||||
|
extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
|
||||||
|
p := parser.NewWithExtensions(extensions)
|
||||||
|
|
||||||
|
doc := p.Parse(page.md)
|
||||||
|
|
||||||
|
// create HTML renderer with extensions
|
||||||
|
htmlFlags := html.CommonFlags | html.HrefTargetBlank
|
||||||
|
opts := html.RendererOptions{
|
||||||
|
Flags: htmlFlags,
|
||||||
|
RenderNodeHook: imageMiddleware(page),
|
||||||
|
}
|
||||||
|
renderer := html.NewRenderer(opts)
|
||||||
|
|
||||||
|
out = markdown.Render(doc, renderer)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageMiddleware(page PageHeader) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
|
||||||
|
return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
|
||||||
|
if !entering {
|
||||||
|
return ast.GoToNext, false
|
||||||
|
}
|
||||||
|
img, ok := node.(*ast.Image)
|
||||||
|
if !ok {
|
||||||
|
return ast.GoToNext, false
|
||||||
|
}
|
||||||
|
src := string(img.Destination)
|
||||||
|
if strings.HasPrefix(src, ".attachments") {
|
||||||
|
imgPath, err := url.PathUnescape(src)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("could not unescape path", "err", err)
|
||||||
|
return ast.GoToNext, false
|
||||||
|
}
|
||||||
|
src, err = images.URLFromPath(page.UID + "/" + imgPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("could not get image url", "err", err)
|
||||||
|
return ast.GoToNext, false
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Attribute = &ast.Attribute{Attrs: map[string][]byte{
|
||||||
|
"srcset": []byte(images.SourceSet(src)),
|
||||||
|
"src": []byte(src),
|
||||||
|
}}
|
||||||
|
// // img.Attrs["srcset"] =
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
img.Destination = []byte(src)
|
||||||
|
|
||||||
|
return ast.GoToNext, false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
|
||||||
|
"github.com/studio-b12/gowebdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
var titleRGX = regexp.MustCompile(`(?m)^#\s+(.+)$`)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrMalFormedInput = errors.New("mal formed input")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) getPageHeaders(ctx context.Context, uid string) (page PageHeader, err error) {
|
||||||
|
info, err := s.fs.ReadDir(uid)
|
||||||
|
if gowebdav.IsErrNotFound(err) { // try private
|
||||||
|
err = ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := translate.Language(ctx)
|
||||||
|
|
||||||
|
var coverImage filesystem.FileInfo
|
||||||
|
var contentMD filesystem.FileInfo
|
||||||
|
|
||||||
|
for _, f := range info {
|
||||||
|
file, ok := f.(filesystem.FileInfo)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(file.ContentType(), "image/") {
|
||||||
|
coverImage = file
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(file.Name(), ".")
|
||||||
|
|
||||||
|
// fallback content
|
||||||
|
if contentMD == nil && len(parts) == 2 && parts[1] == "md" {
|
||||||
|
contentMD = file
|
||||||
|
}
|
||||||
|
// language content
|
||||||
|
if len(parts) == 3 && parts[1] == lang && parts[2] == "md" {
|
||||||
|
contentMD = file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("loaded page infos", "cover", coverImage, "content", contentMD)
|
||||||
|
|
||||||
|
page.md, err = s.fs.Read(contentMD.Path())
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
title := titleRGX.FindStringSubmatch(string(page.md))
|
||||||
|
if len(title) < 2 {
|
||||||
|
err = fmt.Errorf("%w: no matches for title", ErrMalFormedInput)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page.UID = uid
|
||||||
|
page.Title = title[1]
|
||||||
|
if coverImage != nil {
|
||||||
|
page.CoverImageUID, err = images.UIDFromPath(coverImage.Path())
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
fs filesystem.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(fs filesystem.FS) *Service {
|
||||||
|
return &Service{
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package translate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
serviceKey contextKey = "translationService"
|
||||||
|
langKey contextKey = "language"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Language(ctx context.Context) string {
|
||||||
|
lang, ok := ctx.Value(langKey).(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
|
||||||
|
func SupportedLanguages(ctx context.Context) []string {
|
||||||
|
s, ok := ctx.Value(serviceKey).(*Service)
|
||||||
|
if !ok {
|
||||||
|
slog.Error("could not extract translation service from context for getting supported languages")
|
||||||
|
}
|
||||||
|
return s.SupportedLanguages()
|
||||||
|
}
|
||||||
|
|
||||||
|
func T(ctx context.Context, key string) string {
|
||||||
|
s, ok := ctx.Value(serviceKey).(*Service)
|
||||||
|
if !ok {
|
||||||
|
slog.Error("could not extract translation service from context", "ctx", ctx, "key", key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := Language(ctx)
|
||||||
|
|
||||||
|
translation, ok := s.translations[lang][key]
|
||||||
|
if !ok {
|
||||||
|
slog.Warn("could not translate", "key", key, "language", lang)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
return translation
|
||||||
|
}
|
||||||
|
|
||||||
|
func Path(ctx context.Context, pp ...string) string {
|
||||||
|
return PathForLang(ctx, Language(ctx), pp...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PathForLang(ctx context.Context, lang string, pp ...string) string {
|
||||||
|
slog.Info("path for lang", "path", pp)
|
||||||
|
s, ok := ctx.Value(serviceKey).(*Service)
|
||||||
|
if !ok {
|
||||||
|
slog.Error("could not extract translation service from context for translating path", "ctx", ctx, "path", pp)
|
||||||
|
return path.Join(pp...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pp) > 0 {
|
||||||
|
if _, ok := s.translations[pp[0]]; ok {
|
||||||
|
pp = pp[1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.Join(append([]string{lang}, pp...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SupportedLanguages() (languages []string) {
|
||||||
|
for lang := range s.translations {
|
||||||
|
languages = append(languages, lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DefaultLanguage() string {
|
||||||
|
return s.defaultLanguage
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package translate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) Middleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.URL.Path
|
||||||
|
segments := []string{}
|
||||||
|
for segment := range strings.SplitSeq(path, "/") {
|
||||||
|
if segment != "" {
|
||||||
|
segments = append(segments, segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check if the first segment is a supported language.
|
||||||
|
var currentLang string
|
||||||
|
if len(segments) >= 1 {
|
||||||
|
potentialLang := segments[0]
|
||||||
|
if _, ok := s.translations[potentialLang]; ok {
|
||||||
|
currentLang = potentialLang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentLang != "" {
|
||||||
|
// CASE A: Language is present in the URL.
|
||||||
|
// Remove the language from the path so the router sees the clean path.
|
||||||
|
newPath := "/" + strings.Join(segments[1:], "/")
|
||||||
|
if newPath == "" {
|
||||||
|
newPath = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the request URL path.
|
||||||
|
r.URL.Path = newPath
|
||||||
|
|
||||||
|
// Inject translator into context.
|
||||||
|
ctx := context.WithValue(r.Context(), serviceKey, s)
|
||||||
|
ctx = context.WithValue(ctx, langKey, currentLang)
|
||||||
|
|
||||||
|
// Pass control to the next handler with the modified request.
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CASE B: Language is NOT in the URL.
|
||||||
|
// Detect best language from Accept-Language header.
|
||||||
|
detectedLang := s.detectLanguage(r.Header.Get("Accept-Language"))
|
||||||
|
|
||||||
|
// Redirect to the URL with the language prefix.
|
||||||
|
targetURL := "/" + detectedLang + path
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
targetURL += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("redirecting for translated url", "old", path, "new", targetURL)
|
||||||
|
http.Redirect(w, r, targetURL, http.StatusFound)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectLanguage parses the Accept-Language header and returns the best matching supported language.
|
||||||
|
func (s *Service) detectLanguage(header string) string {
|
||||||
|
if header == "" {
|
||||||
|
return s.defaultLanguage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple parser for Accept-Language: en-US,en;q=0.9,de;q=0.8
|
||||||
|
// We only care about the first match for simplicity here, or we could rank them.
|
||||||
|
parts := strings.Split(header, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
tag := strings.Split(strings.TrimSpace(part), ";")[0]
|
||||||
|
// Check for exact match (e.g., "en-US")
|
||||||
|
if _, ok := s.translations[tag]; ok {
|
||||||
|
return tag
|
||||||
|
}
|
||||||
|
// Check for base match (e.g., "en" from "en-US")
|
||||||
|
base := strings.Split(tag, "-")[0]
|
||||||
|
if _, ok := s.translations[base]; ok {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.defaultLanguage
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package translate
|
||||||
|
|
||||||
|
type Translations map[string]string // key -> translation
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
translations map[string]Translations // language -> translations
|
||||||
|
defaultLanguage string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New() *Service {
|
||||||
|
return &Service{
|
||||||
|
defaultLanguage: "de",
|
||||||
|
translations: map[string]Translations{
|
||||||
|
"de": {
|
||||||
|
"home": "Startseite",
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"home": "Home",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyStat = "webdav:stat:"
|
||||||
|
|
||||||
|
// CachedClient wraps a FileSystem implementation with Valkey caching.
|
||||||
|
type CachedClient struct {
|
||||||
|
impl FS
|
||||||
|
cache valkey.Client
|
||||||
|
ttl time.Duration
|
||||||
|
hits atomic.Int64
|
||||||
|
misses atomic.Int64
|
||||||
|
readDirHits atomic.Int64
|
||||||
|
readHits atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCachedClient creates a new CachedClient.
|
||||||
|
func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient {
|
||||||
|
c := &CachedClient{
|
||||||
|
impl: impl,
|
||||||
|
cache: client,
|
||||||
|
ttl: refreshtime * 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(refreshtime)
|
||||||
|
for {
|
||||||
|
<-t.C
|
||||||
|
c.revalidate(context.Background())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) revalidate(ctx context.Context) {
|
||||||
|
go c.revalidateReadDir(ctx)
|
||||||
|
go c.revalidateRead(ctx)
|
||||||
|
go c.revalidateStat(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) scanAndProcess(ctx context.Context, match string, process func(key string) error) {
|
||||||
|
cursor := uint64(0)
|
||||||
|
for {
|
||||||
|
res, err := c.cache.Do(ctx, c.cache.B().Scan().Cursor(cursor).Match(match).Build()).AsScanEntry()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("scan failed", "match", match, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range res.Elements {
|
||||||
|
if err := process(key); err != nil {
|
||||||
|
slog.Debug("process failed", "key", key, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = res.Cursor
|
||||||
|
if cursor == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCachedFile_Serialization(t *testing.T) {
|
||||||
|
now := time.Now().Truncate(time.Second) // JSON marshal of time might lose precision
|
||||||
|
cf := File{
|
||||||
|
name: "test.md",
|
||||||
|
size: 123,
|
||||||
|
mode: 0o644,
|
||||||
|
modTime: now,
|
||||||
|
isDir: false,
|
||||||
|
contentType: "text/markdown",
|
||||||
|
path: "/test.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(cf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var cf2 File
|
||||||
|
if err := json.Unmarshal(data, &cf2); err != nil {
|
||||||
|
t.Fatalf("Unmarshal failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cf.name != cf2.name {
|
||||||
|
t.Errorf("Name mismatch: %s != %s", cf.name, cf2.name)
|
||||||
|
}
|
||||||
|
if cf.size != cf2.size {
|
||||||
|
t.Errorf("Size mismatch: %d != %d", cf.size, cf2.size)
|
||||||
|
}
|
||||||
|
if cf.mode != cf2.mode {
|
||||||
|
t.Errorf("Mode mismatch: %v != %v", cf.mode, cf2.mode)
|
||||||
|
}
|
||||||
|
if !cf.modTime.Equal(cf2.modTime) {
|
||||||
|
t.Errorf("ModTime mismatch: %v != %v", cf.modTime, cf2.modTime)
|
||||||
|
}
|
||||||
|
if cf.isDir != cf2.isDir {
|
||||||
|
t.Errorf("IsDir mismatch: %v != %v", cf.isDir, cf2.isDir)
|
||||||
|
}
|
||||||
|
if cf.contentType != cf2.contentType {
|
||||||
|
t.Errorf("ContentType mismatch: %s != %s", cf.contentType, cf2.contentType)
|
||||||
|
}
|
||||||
|
if cf.path != cf2.path {
|
||||||
|
t.Errorf("Path mismatch: %s != %s", cf.path, cf2.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedFile_Interface(t *testing.T) {
|
||||||
|
// Verify CachedFile implements os.FileInfo
|
||||||
|
var _ os.FileInfo = File{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockFS implements filesystem.FS interface for testing.
|
||||||
|
type MockFS struct {
|
||||||
|
files map[string]*MockFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockFile implements FileInfo and holds content.
|
||||||
|
type MockFile struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
mode os.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
contentType string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *MockFile) Name() string { return f.name }
|
||||||
|
func (f *MockFile) Size() int64 { return int64(len(f.content)) }
|
||||||
|
func (f *MockFile) Mode() os.FileMode { return f.mode }
|
||||||
|
func (f *MockFile) ModTime() time.Time { return f.modTime }
|
||||||
|
func (f *MockFile) IsDir() bool { return f.isDir }
|
||||||
|
func (f *MockFile) Sys() any { return nil }
|
||||||
|
func (f *MockFile) ContentType() string { return f.contentType }
|
||||||
|
func (f *MockFile) Path() string { return f.path }
|
||||||
|
|
||||||
|
func NewMockFS() *MockFS {
|
||||||
|
return &MockFS{
|
||||||
|
files: make(map[string]*MockFile),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockFS) AddFile(path string, content []byte) {
|
||||||
|
m.files[path] = &MockFile{
|
||||||
|
name: path,
|
||||||
|
content: content,
|
||||||
|
mode: 0o644,
|
||||||
|
modTime: time.Now().Truncate(time.Second),
|
||||||
|
isDir: false,
|
||||||
|
contentType: "text/plain",
|
||||||
|
path: path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockFS) AddDir(path string) {
|
||||||
|
m.files[path] = &MockFile{
|
||||||
|
name: path,
|
||||||
|
mode: 0o755,
|
||||||
|
modTime: time.Now().Truncate(time.Second),
|
||||||
|
isDir: true,
|
||||||
|
path: path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockFS) Read(path string) ([]byte, error) {
|
||||||
|
f, ok := m.files[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
if f.isDir {
|
||||||
|
return nil, errors.New("is a directory")
|
||||||
|
}
|
||||||
|
return f.content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockFS) ReadDir(path string) ([]os.FileInfo, error) {
|
||||||
|
// Simple implementation: return files that start with path + "/"
|
||||||
|
// For deeper nesting, simulate direct children only.
|
||||||
|
// Assume flat structure for simplicity or specific test setup.
|
||||||
|
var infos []os.FileInfo
|
||||||
|
for p, f := range m.files {
|
||||||
|
// Just check if it's logically "inside" the directory
|
||||||
|
// e.g. path="/foo", file="/foo/bar" -> yes
|
||||||
|
// file="/foo/bar/baz" -> no (if strict direct children)
|
||||||
|
// file="/other" -> no
|
||||||
|
if p == path {
|
||||||
|
continue // self
|
||||||
|
}
|
||||||
|
// Check prefix
|
||||||
|
// if path is root "/", check if p has no other slashes?
|
||||||
|
// if path is "/foo", check if p starts with "/foo/" and has no further slashes
|
||||||
|
// This logic is simple but sufficient for tests.
|
||||||
|
// Let's assume absolute paths.
|
||||||
|
// If path does not end with /, append it for prefix check
|
||||||
|
prefix := path
|
||||||
|
if len(prefix) > 0 && prefix[len(prefix)-1] != '/' {
|
||||||
|
prefix += "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p) > len(prefix) && p[:len(prefix)] == prefix {
|
||||||
|
// Ensure direct child (no more slashes)
|
||||||
|
rel := p[len(prefix):]
|
||||||
|
// Count slashes in rel. If 0, it's a direct child.
|
||||||
|
slashCount := 0
|
||||||
|
for _, c := range rel {
|
||||||
|
if c == '/' {
|
||||||
|
slashCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slashCount == 0 {
|
||||||
|
infos = append(infos, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(infos) == 0 {
|
||||||
|
// Check if directory exists at all
|
||||||
|
if _, ok := m.files[path]; !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockFS) Stat(path string) (os.FileInfo, error) {
|
||||||
|
f, ok := m.files[path]
|
||||||
|
if !ok {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClient_Stat(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
client, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{mr.Addr()},
|
||||||
|
DisableCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create valkey client: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
mockFS := NewMockFS()
|
||||||
|
mockFS.AddFile("/test.txt", []byte("hello"))
|
||||||
|
|
||||||
|
c := NewCachedClient(mockFS, client, time.Hour)
|
||||||
|
|
||||||
|
// First call: Cache miss
|
||||||
|
info, err := c.Stat("/test.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat failed: %v", err)
|
||||||
|
}
|
||||||
|
if info.Name() != "/test.txt" {
|
||||||
|
t.Errorf("expected name /test.txt, got %s", info.Name())
|
||||||
|
}
|
||||||
|
if c.misses.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 miss, got %d", c.misses.Load())
|
||||||
|
}
|
||||||
|
if c.hits.Load() != 0 {
|
||||||
|
t.Errorf("expected 0 hits, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: Cache hit
|
||||||
|
info2, err := c.Stat("/test.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat failed: %v", err)
|
||||||
|
}
|
||||||
|
if info2.Size() != info.Size() {
|
||||||
|
t.Errorf("size mismatch")
|
||||||
|
}
|
||||||
|
if c.misses.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 miss, got %d", c.misses.Load())
|
||||||
|
}
|
||||||
|
if c.hits.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 hit, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify valkey content
|
||||||
|
keys := mr.Keys()
|
||||||
|
if len(keys) != 1 {
|
||||||
|
t.Errorf("expected 1 key in redis, got %d", len(keys))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClient_Read(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
client, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{mr.Addr()},
|
||||||
|
DisableCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create valkey client: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
mockFS := NewMockFS()
|
||||||
|
content := []byte("hello world")
|
||||||
|
mockFS.AddFile("/read.txt", content)
|
||||||
|
|
||||||
|
c := NewCachedClient(mockFS, client, time.Hour)
|
||||||
|
|
||||||
|
// First call: Miss
|
||||||
|
data, err := c.Read("/read.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != string(content) {
|
||||||
|
t.Errorf("content mismatch")
|
||||||
|
}
|
||||||
|
if c.misses.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 miss, got %d", c.misses.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: Hit
|
||||||
|
data2, err := c.Read("/read.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(data2) != string(content) {
|
||||||
|
t.Errorf("content mismatch")
|
||||||
|
}
|
||||||
|
if c.hits.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 hit, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClient_ReadDir(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
client, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{mr.Addr()},
|
||||||
|
DisableCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create valkey client: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
mockFS := NewMockFS()
|
||||||
|
mockFS.AddDir("/dir")
|
||||||
|
mockFS.AddFile("/dir/f1.txt", []byte("1"))
|
||||||
|
mockFS.AddFile("/dir/f2.txt", []byte("2"))
|
||||||
|
|
||||||
|
c := NewCachedClient(mockFS, client, time.Hour)
|
||||||
|
|
||||||
|
// First call: Miss
|
||||||
|
infos, err := c.ReadDir("/dir")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(infos) != 2 {
|
||||||
|
t.Errorf("expected 2 files, got %d", len(infos))
|
||||||
|
}
|
||||||
|
if c.misses.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 miss, got %d", c.misses.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call: Hit
|
||||||
|
infos2, err := c.ReadDir("/dir")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(infos2) != 2 {
|
||||||
|
t.Errorf("expected 2 files, got %d", len(infos2))
|
||||||
|
}
|
||||||
|
if c.hits.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 hit, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClient_RevalidateStat(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
client, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{mr.Addr()},
|
||||||
|
DisableCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create valkey client: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
mockFS := NewMockFS()
|
||||||
|
mockFS.AddFile("/reval.txt", []byte("initial"))
|
||||||
|
|
||||||
|
c := NewCachedClient(mockFS, client, time.Hour)
|
||||||
|
|
||||||
|
// Populate cache
|
||||||
|
_, _ = c.Stat("/reval.txt")
|
||||||
|
|
||||||
|
// Update mock file (simulate external change)
|
||||||
|
// For simplicity, just update size
|
||||||
|
f := mockFS.files["/reval.txt"]
|
||||||
|
f.content = []byte("updated content")
|
||||||
|
// Important: update modTime to be newer than cached
|
||||||
|
f.modTime = time.Now().Add(time.Minute)
|
||||||
|
|
||||||
|
// Run revalidation manually
|
||||||
|
// Note: revalidateStat is a private method, so we can test it directly here
|
||||||
|
// because we are in package filesystem (if using package filesystem_test we couldn't)
|
||||||
|
// But `revalidateStat` only iterates over keys in redis. So we need to ensure the key is there.
|
||||||
|
// `Stat` put it there.
|
||||||
|
|
||||||
|
// Wait, Stat puts it there. `revalidateStat` iterates keys matching `keyStat*`.
|
||||||
|
// For each key, it checks `Stat` again.
|
||||||
|
// Wait, looking at `revalidateStat` implementation:
|
||||||
|
// It calls `c.impl.Stat(path)`. If successful, it updates cache.
|
||||||
|
// It doesn't check ModTime explicitly for `Stat` revalidation, it just blindly updates?
|
||||||
|
// Let's check `revalidateStat` implementation in `cache.go`.
|
||||||
|
|
||||||
|
// func (c *CachedClient) revalidateStat(ctx context.Context) { ...
|
||||||
|
// info, err := c.impl.Stat(path)
|
||||||
|
// ...
|
||||||
|
// bytes, err := json.Marshal(f)
|
||||||
|
// c.client.Do(...Set...)
|
||||||
|
// }
|
||||||
|
// Yes, it blindly updates. So modification time doesn't matter for `Stat`, it just fetches fresh info.
|
||||||
|
|
||||||
|
c.revalidateStat(context.Background())
|
||||||
|
|
||||||
|
// Verify cache is updated by fetching from cache again
|
||||||
|
// We can inspect redis directly or trust `Stat` returns cached value (which should be updated).
|
||||||
|
// Let's inspect redis to be sure.
|
||||||
|
// Or just call Stat again. It should be a cache HIT, but return NEW values.
|
||||||
|
|
||||||
|
// Reset stats to check hit
|
||||||
|
c.hits.Store(0)
|
||||||
|
c.misses.Store(0)
|
||||||
|
|
||||||
|
info, err := c.Stat("/reval.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Stat failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Size() != int64(len("updated content")) {
|
||||||
|
t.Errorf("expected size %d, got %d", len("updated content"), info.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be a hit because revalidate updated the cache entry, so it exists and is valid.
|
||||||
|
if c.hits.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 hit, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClient_RevalidateRead(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
client, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{mr.Addr()},
|
||||||
|
DisableCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create valkey client: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
mockFS := NewMockFS()
|
||||||
|
mockFS.AddFile("/read_reval.txt", []byte("initial"))
|
||||||
|
|
||||||
|
c := NewCachedClient(mockFS, client, time.Hour)
|
||||||
|
|
||||||
|
// Populate cache
|
||||||
|
_, _ = c.Read("/read_reval.txt")
|
||||||
|
|
||||||
|
// Update mock file (simulate external change)
|
||||||
|
f := mockFS.files["/read_reval.txt"]
|
||||||
|
f.content = []byte("updated content")
|
||||||
|
f.modTime = time.Now().Add(time.Minute) // Newer ModTime triggers update
|
||||||
|
|
||||||
|
// Run revalidation manually
|
||||||
|
c.revalidateRead(context.Background())
|
||||||
|
|
||||||
|
// Verify cache is updated
|
||||||
|
// Reset stats
|
||||||
|
c.hits.Store(0)
|
||||||
|
c.misses.Store(0)
|
||||||
|
|
||||||
|
data, err := c.Read("/read_reval.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Read failed: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != "updated content" {
|
||||||
|
t.Errorf("expected updated content, got %s", string(data))
|
||||||
|
}
|
||||||
|
if c.hits.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 hit, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCachedClient_RevalidateReadDir(t *testing.T) {
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to start miniredis: %v", err)
|
||||||
|
}
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
client, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{mr.Addr()},
|
||||||
|
DisableCache: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create valkey client: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
mockFS := NewMockFS()
|
||||||
|
mockFS.AddDir("/dir_reval")
|
||||||
|
mockFS.AddFile("/dir_reval/f1.txt", []byte("1"))
|
||||||
|
|
||||||
|
c := NewCachedClient(mockFS, client, time.Hour)
|
||||||
|
|
||||||
|
// Populate cache
|
||||||
|
_, err = c.ReadDir("/dir_reval")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update mock file (simulate external change)
|
||||||
|
mockFS.AddFile("/dir_reval/f2.txt", []byte("2"))
|
||||||
|
|
||||||
|
// Run revalidation manually
|
||||||
|
c.revalidateReadDir(context.Background())
|
||||||
|
|
||||||
|
// Verify cache is updated
|
||||||
|
// Reset stats
|
||||||
|
c.hits.Store(0)
|
||||||
|
c.misses.Store(0)
|
||||||
|
|
||||||
|
infos2, err := c.ReadDir("/dir_reval")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadDir failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(infos2) != 2 {
|
||||||
|
t.Errorf("expected 2 files after revalidation, got %d", len(infos2))
|
||||||
|
}
|
||||||
|
if c.hits.Load() != 1 {
|
||||||
|
t.Errorf("expected 1 hit, got %d", c.hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FS interface {
|
||||||
|
ReadDir(path string) ([]os.FileInfo, error)
|
||||||
|
Read(path string) ([]byte, error)
|
||||||
|
Stat(path string) (os.FileInfo, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileInfo interface {
|
||||||
|
os.FileInfo
|
||||||
|
ContentType() string
|
||||||
|
Path() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// File implements FileInfo
|
||||||
|
type File struct {
|
||||||
|
name string
|
||||||
|
size int64
|
||||||
|
mode os.FileMode
|
||||||
|
modTime time.Time
|
||||||
|
isDir bool
|
||||||
|
contentType string
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f File) Name() string { return f.name }
|
||||||
|
func (f File) Size() int64 { return f.size }
|
||||||
|
func (f File) Mode() os.FileMode { return f.mode }
|
||||||
|
func (f File) ModTime() time.Time { return f.modTime }
|
||||||
|
func (f File) IsDir() bool { return f.isDir }
|
||||||
|
func (f File) Sys() any { return nil }
|
||||||
|
func (f File) ContentType() string { return f.contentType }
|
||||||
|
func (f File) Path() string { return f.path }
|
||||||
|
|
||||||
|
type serializableFile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Mode os.FileMode `json:"mode"`
|
||||||
|
ModTime time.Time `json:"modTime"`
|
||||||
|
IsDir bool `json:"isDir"`
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON implements json.Marshaler
|
||||||
|
func (f File) MarshalJSON() ([]byte, error) {
|
||||||
|
// Create a local shadow struct with exported fields for the JSON package
|
||||||
|
return json.Marshal(serializableFile{
|
||||||
|
Name: f.name,
|
||||||
|
Size: f.size,
|
||||||
|
Mode: f.mode,
|
||||||
|
ModTime: f.modTime,
|
||||||
|
IsDir: f.isDir,
|
||||||
|
ContentType: f.contentType,
|
||||||
|
Path: f.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements json.Unmarshaler
|
||||||
|
func (f *File) UnmarshalJSON(data []byte) error {
|
||||||
|
// Define a shadow struct to capture the incoming JSON
|
||||||
|
shadow := serializableFile{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &shadow); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer values to the actual struct
|
||||||
|
f.name = shadow.Name
|
||||||
|
f.size = shadow.Size
|
||||||
|
f.mode = shadow.Mode
|
||||||
|
f.modTime = shadow.ModTime
|
||||||
|
f.isDir = shadow.IsDir
|
||||||
|
f.contentType = shadow.ContentType
|
||||||
|
f.path = shadow.Path
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseToFile(file os.FileInfo) File {
|
||||||
|
if fi, ok := file.(FileInfo); ok {
|
||||||
|
return File{
|
||||||
|
name: fi.Name(),
|
||||||
|
size: fi.Size(),
|
||||||
|
mode: fi.Mode(),
|
||||||
|
modTime: fi.ModTime(),
|
||||||
|
isDir: fi.IsDir(),
|
||||||
|
contentType: fi.ContentType(),
|
||||||
|
path: fi.Path(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback if underlying impl returns generic os.FileInfo
|
||||||
|
return File{
|
||||||
|
name: file.Name(),
|
||||||
|
size: file.Size(),
|
||||||
|
mode: file.Mode(),
|
||||||
|
modTime: file.ModTime(),
|
||||||
|
isDir: file.IsDir(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyRead = "webdav:read:"
|
||||||
|
|
||||||
|
type readFile struct {
|
||||||
|
Age time.Time `json:"age"`
|
||||||
|
Content []byte `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) Read(path string) ([]byte, error) {
|
||||||
|
key := keyRead + path
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try cache
|
||||||
|
cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes()
|
||||||
|
if err == nil && len(cached) > 0 {
|
||||||
|
c.readHits.Add(1)
|
||||||
|
c.hits.Add(1)
|
||||||
|
slog.Debug("cache hit", "key", key)
|
||||||
|
|
||||||
|
var file readFile
|
||||||
|
err := json.Unmarshal(cached, &file)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("could not Unmarshal cache", "key", key, "err", err)
|
||||||
|
} else {
|
||||||
|
return file.Content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss
|
||||||
|
c.misses.Add(1)
|
||||||
|
slog.Debug("cache miss", "key", key)
|
||||||
|
|
||||||
|
data, err := c.impl.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file := readFile{
|
||||||
|
Age: time.Now(),
|
||||||
|
Content: data,
|
||||||
|
}
|
||||||
|
|
||||||
|
cached, err = json.Marshal(file)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("could not marshal file for cache", "key", key, "err", err)
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache
|
||||||
|
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(cached)).Ex(c.ttl).Build())
|
||||||
|
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) revalidateRead(ctx context.Context) {
|
||||||
|
startTime := time.Now()
|
||||||
|
c.scanAndProcess(ctx, keyRead+"*", func(key string) error {
|
||||||
|
path := strings.TrimPrefix(key, keyRead)
|
||||||
|
cached, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).AsBytes()
|
||||||
|
if err != nil {
|
||||||
|
// Key might be gone or error fetching
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cachedFile readFile
|
||||||
|
err = json.Unmarshal(cached, &cachedFile)
|
||||||
|
if err != nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := c.impl.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.ModTime().After(cachedFile.Age) {
|
||||||
|
data, err := c.impl.Read(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cachedFile.Age = time.Now()
|
||||||
|
cachedFile.Content = data
|
||||||
|
|
||||||
|
newCached, err := json.Marshal(cachedFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(valkey.BinaryString(newCached)).Ex(c.ttl).Build())
|
||||||
|
} else {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Expire().Key(key).Seconds(int64(c.ttl/time.Second)).Build())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
slog.Debug("revalidation of read complete", "duration", time.Since(startTime))
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const keyReadDir = "webdav:readdir:"
|
||||||
|
|
||||||
|
func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) {
|
||||||
|
key := keyReadDir + path
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try cache
|
||||||
|
val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
var cached []File
|
||||||
|
if err := json.Unmarshal([]byte(val), &cached); err == nil {
|
||||||
|
c.readDirHits.Add(1)
|
||||||
|
c.hits.Add(1)
|
||||||
|
slog.Debug("cache hit", "key", key)
|
||||||
|
infos := make([]os.FileInfo, len(cached))
|
||||||
|
for i, f := range cached {
|
||||||
|
infos[i] = f
|
||||||
|
}
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss
|
||||||
|
c.misses.Add(1)
|
||||||
|
slog.Debug("cache miss", "key", key)
|
||||||
|
infos, err := c.impl.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize and cache
|
||||||
|
cached := make([]File, 0, len(infos))
|
||||||
|
for _, info := range infos {
|
||||||
|
cached = append(cached, ParseToFile(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(cached)
|
||||||
|
if err == nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
|
||||||
|
}
|
||||||
|
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) revalidateReadDir(ctx context.Context) {
|
||||||
|
startTime := time.Now()
|
||||||
|
c.scanAndProcess(ctx, keyReadDir+"*", func(key string) error {
|
||||||
|
path := strings.TrimPrefix(key, keyReadDir)
|
||||||
|
infos, err := c.impl.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize and cache
|
||||||
|
cached := make([]File, 0, len(infos))
|
||||||
|
for _, info := range infos {
|
||||||
|
cached = append(cached, ParseToFile(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(cached)
|
||||||
|
if err == nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
slog.Debug("revalidation of readDir complete", "duration", time.Since(startTime))
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package filesystem
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) {
|
||||||
|
key := keyStat + path
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Try cache
|
||||||
|
val, err := c.cache.Do(ctx, c.cache.B().Get().Key(key).Build()).ToString()
|
||||||
|
if err == nil && val != "" {
|
||||||
|
var f File
|
||||||
|
if err := json.Unmarshal([]byte(val), &f); err == nil {
|
||||||
|
c.hits.Add(1)
|
||||||
|
slog.Debug("cache hit", "key", key)
|
||||||
|
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache miss
|
||||||
|
c.misses.Add(1)
|
||||||
|
slog.Debug("cache miss", "key", key)
|
||||||
|
fileInfo, err := c.impl.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize and cache
|
||||||
|
// Type assert to ensure we capture ContentType and Path
|
||||||
|
info = ParseToFile(fileInfo)
|
||||||
|
|
||||||
|
bytes, err := json.Marshal(info)
|
||||||
|
if err == nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CachedClient) revalidateStat(ctx context.Context) {
|
||||||
|
startTime := time.Now()
|
||||||
|
c.scanAndProcess(ctx, keyStat+"*", func(key string) error {
|
||||||
|
path := strings.TrimPrefix(key, keyStat)
|
||||||
|
info, err := c.impl.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Del().Key(key).Build())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize and cache
|
||||||
|
f := ParseToFile(info)
|
||||||
|
bytes, err := json.Marshal(f)
|
||||||
|
if err == nil {
|
||||||
|
c.cache.Do(ctx, c.cache.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
slog.Debug("revalidation of Stat complete", "duration", time.Since(startTime))
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package resthome
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/layouts"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/pages"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) home(w http.ResponseWriter, r *http.Request) {
|
||||||
|
pageHeaders, err := h.src.GetPages(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts.Render(r.Context(), w, r, pages.Home(pageHeaders))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) page(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := r.PathValue("uid")
|
||||||
|
|
||||||
|
page, err := h.src.GetPage(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts.Render(r.Context(), w, r, pages.ContentPage(page))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package resthome
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
src pageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(mux *http.ServeMux, srv pageService) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
src: srv,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /{$}", h.home)
|
||||||
|
mux.HandleFunc("GET /{uid}", h.page)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
type pageService interface {
|
||||||
|
GetPages(ctx context.Context) ([]page.PageHeader, error)
|
||||||
|
GetPage(ctx context.Context, uid string) (page.Page, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package restimage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) getImage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid := r.PathValue("uid")
|
||||||
|
|
||||||
|
var err error
|
||||||
|
options := images.Options{}
|
||||||
|
options.Height, err = restutil.IntParam(r, "h")
|
||||||
|
if err != nil {
|
||||||
|
restutil.SendErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.Width, err = restutil.IntParam(r, "w")
|
||||||
|
if err != nil {
|
||||||
|
restutil.SendErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
options.Quality, err = restutil.IntParam(r, "q")
|
||||||
|
if err != nil {
|
||||||
|
restutil.SendErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img, mime, err := h.img.GetImage(r.Context(), uid, options)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("error wile serving image", "err", err, "uid", uid)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Add("Content-Type", mime)
|
||||||
|
io.Copy(w, img)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package restimage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
img imageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(mux *http.ServeMux, srv imageService) *Handler {
|
||||||
|
h := &Handler{
|
||||||
|
img: srv,
|
||||||
|
}
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /images/{uid}", h.getImage)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
type imageService interface {
|
||||||
|
GetImage(ctx context.Context, uid string, options images.Options) (img io.Reader, mimeType string, err error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package restutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ErrorType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ErrorTypeUndefinded = ""
|
||||||
|
ErrorTypeValidation = "VALIDATION"
|
||||||
|
ErrorTypeNotFound = "NOT_FOUND"
|
||||||
|
ErrorTypeServerError = "SERVER_ERROR"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Type ErrorType `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func WrappErr(err error, errorType ErrorType, message string) *Error {
|
||||||
|
return &Error{
|
||||||
|
Type: errorType,
|
||||||
|
Message: message,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Error) Unwrap() error {
|
||||||
|
return e.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Error) Error() string {
|
||||||
|
return fmt.Sprintf("%s: %s", e.Message, e.err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Error) StatusCode() int {
|
||||||
|
switch e.Type {
|
||||||
|
case ErrorTypeValidation:
|
||||||
|
return http.StatusBadRequest
|
||||||
|
case ErrorTypeNotFound:
|
||||||
|
return http.StatusNotFound
|
||||||
|
default:
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendErr(w http.ResponseWriter, err error) {
|
||||||
|
restErr := &Error{}
|
||||||
|
|
||||||
|
if !errors.As(err, &restErr) {
|
||||||
|
slog.Error("internal server error", "err", err)
|
||||||
|
restErr = &Error{
|
||||||
|
Type: ErrorTypeServerError,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(restErr.StatusCode())
|
||||||
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
err = json.NewEncoder(w).Encode(restErr)
|
||||||
|
|
||||||
|
slog.Error("could not send error", "err", err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package restutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IntParam(r *http.Request, key string) (val int, err error) {
|
||||||
|
str := r.URL.Query().Get(key)
|
||||||
|
if str == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val, err = strconv.Atoi(str)
|
||||||
|
if err != nil {
|
||||||
|
err = WrappErr(
|
||||||
|
err,
|
||||||
|
ErrorTypeValidation,
|
||||||
|
fmt.Sprintf("failed to parse query parameter '%s': not an int", key),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/resthome"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/handlers/restimage"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
|
||||||
|
"github.com/studio-b12/gowebdav"
|
||||||
|
"github.com/valkey-io/valkey-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) RegisterRoutes() (h http.Handler) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
registerStatic(mux)
|
||||||
|
mux.Handle("/", registerOther())
|
||||||
|
|
||||||
|
h = mux
|
||||||
|
h = templer.Middleware(h)
|
||||||
|
h = s.loggingMiddleware(h)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerOther() (h http.Handler) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
webdavClient := gowebdav.NewClient("https://cloud.schreifuchs.ch/remote.php/dav/files/test/website", "test", os.Getenv("TEST_PW"))
|
||||||
|
if err := webdavClient.Connect(); err != nil {
|
||||||
|
slog.Error("could not connect webdavClient", "err", err, "pw", os.Getenv("TEST_PW"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var fs filesystem.FS = webdavClient
|
||||||
|
|
||||||
|
valkeyAddr := os.Getenv("VALKEY_ADDR")
|
||||||
|
if valkeyAddr == "" {
|
||||||
|
valkeyAddr = "127.0.0.1:6379"
|
||||||
|
}
|
||||||
|
valkeyClient, err := valkey.NewClient(valkey.ClientOption{
|
||||||
|
InitAddress: []string{valkeyAddr},
|
||||||
|
Password: os.Getenv("VALKEY_PASSWORD"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to create valkey client", "err", err)
|
||||||
|
} else {
|
||||||
|
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resthome.New(mux, page.New(fs))
|
||||||
|
_ = restimage.New(mux, images.New(fs, valkeyClient))
|
||||||
|
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(web.GetStaticFS())))
|
||||||
|
|
||||||
|
translator := translate.New()
|
||||||
|
|
||||||
|
h = mux
|
||||||
|
h = translator.Middleware(h)
|
||||||
|
h = cacheMiddleware(time.Second * 45)(h)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) loggingMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
slog.Info("request",
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"duration", time.Since(start),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer() *http.Server {
|
||||||
|
port, _ := strconv.Atoi(os.Getenv("PORT"))
|
||||||
|
if port == 0 {
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
s := &Server{
|
||||||
|
port: port,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declare Server config
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", s.port),
|
||||||
|
Handler: s.RegisterRoutes(),
|
||||||
|
IdleTimeout: time.Minute,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerStatic registers all the static files.
|
||||||
|
func registerStatic(mux *http.ServeMux) {
|
||||||
|
fileSystem := web.GetStaticFS()
|
||||||
|
|
||||||
|
// we still use go's fileServer to avoid unnecessary implementation
|
||||||
|
fileServer := cacheMiddleware(time.Hour * 24)(http.FileServer(http.FS(fileSystem)))
|
||||||
|
|
||||||
|
err := fs.WalkDir(fileSystem, ".", func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path = "/" + path
|
||||||
|
|
||||||
|
slog.Info("registering file", "path", path)
|
||||||
|
|
||||||
|
mux.Handle(path, fileServer)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("could not register static files", "err", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheMiddleware adds Cache-Control headers to the response.
|
||||||
|
func cacheMiddleware(maxAge time.Duration) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.0f", maxAge.Seconds()))
|
||||||
|
w.Header().Set("Vary", "HX-Request,Accept-Language,Accept-Encoding")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package templer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const (
|
||||||
|
pathKey ctxKey = iota
|
||||||
|
)
|
||||||
|
|
||||||
|
func Middleware(next http.Handler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, pathKey, r.URL.Path)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path returns the current path without a leading slash
|
||||||
|
func Path(ctx context.Context) string {
|
||||||
|
path, ok := ctx.Value(pathKey).(string)
|
||||||
|
if !ok {
|
||||||
|
slog.Error("could not extract request path from context")
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(path, "/")
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/message-format-plugin",
|
|
||||||
"hello_world": "Hallo Welt",
|
|
||||||
"pfadi": "Pfadi",
|
|
||||||
"site_title": "Schreifuchs.ch",
|
|
||||||
"site_description": "Portfolio von Schreifuchs - Fotografie, Informatik und Musik."
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/message-format-plugin",
|
|
||||||
"hello_world": "Hello World",
|
|
||||||
"pfadi": "Scouts",
|
|
||||||
"site_title": "Schreifuchs.ch",
|
|
||||||
"site_description": "Portfolio of Schreifuchs - Photography, Informatics and Music ."
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,37 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "schreifuchs",
|
"name": "schreifuchs.ch",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"build:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --minify",
|
||||||
"build": "vite build",
|
"watch:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --watch",
|
||||||
"preview": "vite preview",
|
"build:js": "cp node_modules/htmx.org/dist/htmx.min.js web/static/js/htmx.min.js"
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
|
||||||
},
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"packageManager": "pnpm@10.28.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@tailwindcss/cli": "^4.1.18",
|
||||||
"@sveltejs/adapter-node": "^5.5.1",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@sveltejs/kit": "^2.5.27",
|
"autoprefixer": "^10.4.24",
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"postcss": "^8.5.6",
|
||||||
"@types/mime-types": "^3.0.1",
|
"tailwindcss": "^4.1.18"
|
||||||
"@types/node": "^25.0.9",
|
|
||||||
"autoprefixer": "^10.4.19",
|
|
||||||
"postcss": "^8.4.38",
|
|
||||||
"svelte": "^5.0.0",
|
|
||||||
"svelte-check": "^4.0.0",
|
|
||||||
"tailwindcss": "^3.4.3",
|
|
||||||
"typescript": "^5.5.0",
|
|
||||||
"vite": "^5.4.4"
|
|
||||||
},
|
},
|
||||||
"type": "module",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/outfit": "^5.0.13",
|
"htmx.org": "^2.0.8"
|
||||||
"@inlang/paraglide-js": "^1.11.0",
|
|
||||||
"@inlang/paraglide-sveltekit": "^0.16.1",
|
|
||||||
"@ts-stack/markdown": "^1.5.0",
|
|
||||||
"mime-types": "^3.0.2",
|
|
||||||
"svelte-preprocess": "^6.0.2",
|
|
||||||
"webdav": "^5.8.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
<<<<<<< HEAD
|
||||||
'@fontsource/outfit':
|
'@fontsource/outfit':
|
||||||
specifier: ^5.0.13
|
specifier: ^5.0.13
|
||||||
version: 5.0.13
|
version: 5.0.13
|
||||||
@@ -20,6 +21,9 @@ importers:
|
|||||||
'@ts-stack/markdown':
|
'@ts-stack/markdown':
|
||||||
specifier: ^1.5.0
|
specifier: ^1.5.0
|
||||||
version: 1.5.0
|
version: 1.5.0
|
||||||
|
lru-cache:
|
||||||
|
specifier: ^11.2.4
|
||||||
|
version: 11.2.4
|
||||||
mime-types:
|
mime-types:
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.2
|
||||||
version: 3.0.2
|
version: 3.0.2
|
||||||
@@ -323,6 +327,32 @@ packages:
|
|||||||
'@jridgewell/gen-mapping@0.3.5':
|
'@jridgewell/gen-mapping@0.3.5':
|
||||||
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
|
resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
=======
|
||||||
|
htmx.org:
|
||||||
|
specifier: ^2.0.8
|
||||||
|
version: 2.0.8
|
||||||
|
devDependencies:
|
||||||
|
'@tailwindcss/cli':
|
||||||
|
specifier: ^4.1.18
|
||||||
|
version: 4.1.18
|
||||||
|
'@tailwindcss/typography':
|
||||||
|
specifier: ^0.5.19
|
||||||
|
version: 0.5.19(tailwindcss@4.1.18)
|
||||||
|
autoprefixer:
|
||||||
|
specifier: ^10.4.24
|
||||||
|
version: 10.4.24(postcss@8.5.6)
|
||||||
|
postcss:
|
||||||
|
specifier: ^8.5.6
|
||||||
|
version: 8.5.6
|
||||||
|
tailwindcss:
|
||||||
|
specifier: ^4.1.18
|
||||||
|
version: 4.1.18
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
|
|
||||||
'@jridgewell/remapping@2.3.5':
|
'@jridgewell/remapping@2.3.5':
|
||||||
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
|
||||||
@@ -331,6 +361,7 @@ packages:
|
|||||||
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
'@jridgewell/set-array@1.2.1':
|
'@jridgewell/set-array@1.2.1':
|
||||||
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -787,11 +818,208 @@ packages:
|
|||||||
|
|
||||||
autoprefixer@10.4.19:
|
autoprefixer@10.4.19:
|
||||||
resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==}
|
resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==}
|
||||||
|
=======
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@parcel/watcher-android-arm64@2.5.6':
|
||||||
|
resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@parcel/watcher-darwin-arm64@2.5.6':
|
||||||
|
resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@parcel/watcher-darwin-x64@2.5.6':
|
||||||
|
resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@parcel/watcher-freebsd-x64@2.5.6':
|
||||||
|
resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm-glibc@2.5.6':
|
||||||
|
resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm-musl@2.5.6':
|
||||||
|
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
||||||
|
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
||||||
|
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
||||||
|
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-x64-musl@2.5.6':
|
||||||
|
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@parcel/watcher-win32-arm64@2.5.6':
|
||||||
|
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@parcel/watcher-win32-ia32@2.5.6':
|
||||||
|
resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@parcel/watcher-win32-x64@2.5.6':
|
||||||
|
resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@parcel/watcher@2.5.6':
|
||||||
|
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||||
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
'@tailwindcss/cli@4.1.18':
|
||||||
|
resolution: {integrity: sha512-sMZ+lZbDyxwjD2E0L7oRUjJ01Ffjtme5OtjvvnC+cV4CEDcbqzbp25TCpxHj6kWLU9+DlqJOiNgSOgctC2aZmg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
'@tailwindcss/node@4.1.18':
|
||||||
|
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-android-arm64@4.1.18':
|
||||||
|
resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-arm64@4.1.18':
|
||||||
|
resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-x64@4.1.18':
|
||||||
|
resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-freebsd-x64@4.1.18':
|
||||||
|
resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
|
||||||
|
resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
|
||||||
|
resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||||
|
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||||
|
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||||
|
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||||
|
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
bundledDependencies:
|
||||||
|
- '@napi-rs/wasm-runtime'
|
||||||
|
- '@emnapi/core'
|
||||||
|
- '@emnapi/runtime'
|
||||||
|
- '@tybys/wasm-util'
|
||||||
|
- '@emnapi/wasi-threads'
|
||||||
|
- tslib
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
|
||||||
|
resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
|
||||||
|
resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tailwindcss/oxide@4.1.18':
|
||||||
|
resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
|
'@tailwindcss/typography@0.5.19':
|
||||||
|
resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==}
|
||||||
|
peerDependencies:
|
||||||
|
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||||
|
|
||||||
|
autoprefixer@10.4.24:
|
||||||
|
resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==}
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
postcss: ^8.1.0
|
postcss: ^8.1.0
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
axios@1.13.2:
|
axios@1.13.2:
|
||||||
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||||
|
|
||||||
@@ -1167,19 +1395,65 @@ packages:
|
|||||||
|
|
||||||
is-core-module@2.13.1:
|
is-core-module@2.13.1:
|
||||||
resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==}
|
resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==}
|
||||||
|
=======
|
||||||
|
baseline-browser-mapping@2.9.19:
|
||||||
|
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
browserslist@4.28.1:
|
||||||
|
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||||
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001769:
|
||||||
|
resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==}
|
||||||
|
|
||||||
|
cssesc@3.0.0:
|
||||||
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
detect-libc@2.1.2:
|
||||||
|
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
electron-to-chromium@1.5.286:
|
||||||
|
resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==}
|
||||||
|
|
||||||
|
enhanced-resolve@5.19.0:
|
||||||
|
resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
|
escalade@3.2.0:
|
||||||
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
fraction.js@5.3.4:
|
||||||
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11:
|
||||||
|
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||||
|
|
||||||
|
htmx.org@2.0.8:
|
||||||
|
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
|
|
||||||
is-extglob@2.1.1:
|
is-extglob@2.1.1:
|
||||||
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
is-fullwidth-code-point@3.0.0:
|
is-fullwidth-code-point@3.0.0:
|
||||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
is-glob@4.0.3:
|
is-glob@4.0.3:
|
||||||
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
is-module@1.0.0:
|
is-module@1.0.0:
|
||||||
resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
|
resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==}
|
||||||
|
|
||||||
@@ -1269,12 +1543,96 @@ packages:
|
|||||||
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
|
resolution: {integrity: sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==}
|
||||||
engines: {node: 14 || >=16.14}
|
engines: {node: 14 || >=16.14}
|
||||||
|
|
||||||
|
lru-cache@11.2.4:
|
||||||
|
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
magic-string@0.30.10:
|
magic-string@0.30.10:
|
||||||
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
|
resolution: {integrity: sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==}
|
||||||
|
=======
|
||||||
|
jiti@2.6.1:
|
||||||
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.30.2:
|
||||||
|
resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.30.2:
|
||||||
|
resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.30.2:
|
||||||
|
resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.30.2:
|
||||||
|
resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.30.2:
|
||||||
|
resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.30.2:
|
||||||
|
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.30.2:
|
||||||
|
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.30.2:
|
||||||
|
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.30.2:
|
||||||
|
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.30.2:
|
||||||
|
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.30.2:
|
||||||
|
resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lightningcss@1.30.2:
|
||||||
|
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||||
|
engines: {node: '>= 12.0.0'}
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1318,10 +1676,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==}
|
resolution: {integrity: sha512-UZ7eQ+h8ywIRAW1hIEl2AqdwzJucU/Kp59+8kkZeSvafXhZjul247BvIJjEVFVeON6d7lM46XX1HXCduKAS8VA==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
mri@1.2.0:
|
mri@1.2.0:
|
||||||
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
mrmime@2.0.0:
|
mrmime@2.0.0:
|
||||||
resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==}
|
resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1336,11 +1697,14 @@ packages:
|
|||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
nanoid@3.3.11:
|
nanoid@3.3.11:
|
||||||
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
nanoid@3.3.7:
|
nanoid@3.3.7:
|
||||||
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
|
resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -1403,18 +1767,30 @@ packages:
|
|||||||
|
|
||||||
picocolors@1.0.0:
|
picocolors@1.0.0:
|
||||||
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
|
||||||
|
=======
|
||||||
|
node-addon-api@7.1.1:
|
||||||
|
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||||
|
|
||||||
|
node-releases@2.0.27:
|
||||||
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
picomatch@2.3.1:
|
picomatch@2.3.1:
|
||||||
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
engines: {node: '>=8.6'}
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
picomatch@4.0.3:
|
picomatch@4.0.3:
|
||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<<<<<<< HEAD
|
||||||
pify@2.3.0:
|
pify@2.3.0:
|
||||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -1459,6 +1835,10 @@ packages:
|
|||||||
|
|
||||||
postcss-selector-parser@6.0.16:
|
postcss-selector-parser@6.0.16:
|
||||||
resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==}
|
resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==}
|
||||||
|
=======
|
||||||
|
postcss-selector-parser@6.0.10:
|
||||||
|
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
|
||||||
|
>>>>>>> 6c4b219 (feat: dummy pages)
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
postcss-value-parser@4.2.0:
|
postcss-value-parser@4.2.0:
|
||||||
@@ -1468,10 +1848,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
|
resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
=======
|
||||||
|
postcss-value-parser@4.2.0:
|
||||||
|
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||||
|
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
posthog-node@3.1.3:
|
posthog-node@3.1.3:
|
||||||
resolution: {integrity: sha512-UaOOoWEUYTcaaDe1w0fgHW/sXvFr3RO0l7yI7RUDzkZNZCfwXNO9r3pc14d1EtNppF/SHBrV5hNiZZATpf/vUw==}
|
resolution: {integrity: sha512-UaOOoWEUYTcaaDe1w0fgHW/sXvFr3RO0l7yI7RUDzkZNZCfwXNO9r3pc14d1EtNppF/SHBrV5hNiZZATpf/vUw==}
|
||||||
engines: {node: '>=15.0.0'}
|
engines: {node: '>=15.0.0'}
|
||||||
@@ -1564,10 +1950,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -1700,10 +2089,23 @@ packages:
|
|||||||
|
|
||||||
update-browserslist-db@1.0.15:
|
update-browserslist-db@1.0.15:
|
||||||
resolution: {integrity: sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA==}
|
resolution: {integrity: sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA==}
|
||||||
|
=======
|
||||||
|
tailwindcss@4.1.18:
|
||||||
|
resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
|
||||||
|
|
||||||
|
tapable@2.3.0:
|
||||||
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
update-browserslist-db@1.2.3:
|
||||||
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
browserslist: '>= 4.21.0'
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
<<<<<<< HEAD
|
||||||
url-join@5.0.0:
|
url-join@5.0.0:
|
||||||
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
|
resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -2904,10 +3306,217 @@ snapshots:
|
|||||||
|
|
||||||
is-fullwidth-code-point@3.0.0: {}
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
|
=======
|
||||||
|
=======
|
||||||
|
util-deprecate@1.0.2:
|
||||||
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
|
>>>>>>> 6c4b219 (feat: dummy pages)
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
'@jridgewell/gen-mapping@0.3.13':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
|
'@jridgewell/remapping@2.3.5':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
|
|
||||||
|
'@jridgewell/resolve-uri@3.1.2': {}
|
||||||
|
|
||||||
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@parcel/watcher-android-arm64@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-darwin-arm64@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-darwin-x64@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-freebsd-x64@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm-glibc@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm-musl@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm64-glibc@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-arm64-musl@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-x64-glibc@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-linux-x64-musl@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-win32-arm64@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-win32-ia32@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher-win32-x64@2.5.6':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@parcel/watcher@2.5.6':
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
is-glob: 4.0.3
|
||||||
|
node-addon-api: 7.1.1
|
||||||
|
picomatch: 4.0.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@parcel/watcher-android-arm64': 2.5.6
|
||||||
|
'@parcel/watcher-darwin-arm64': 2.5.6
|
||||||
|
'@parcel/watcher-darwin-x64': 2.5.6
|
||||||
|
'@parcel/watcher-freebsd-x64': 2.5.6
|
||||||
|
'@parcel/watcher-linux-arm-glibc': 2.5.6
|
||||||
|
'@parcel/watcher-linux-arm-musl': 2.5.6
|
||||||
|
'@parcel/watcher-linux-arm64-glibc': 2.5.6
|
||||||
|
'@parcel/watcher-linux-arm64-musl': 2.5.6
|
||||||
|
'@parcel/watcher-linux-x64-glibc': 2.5.6
|
||||||
|
'@parcel/watcher-linux-x64-musl': 2.5.6
|
||||||
|
'@parcel/watcher-win32-arm64': 2.5.6
|
||||||
|
'@parcel/watcher-win32-ia32': 2.5.6
|
||||||
|
'@parcel/watcher-win32-x64': 2.5.6
|
||||||
|
|
||||||
|
'@tailwindcss/cli@4.1.18':
|
||||||
|
dependencies:
|
||||||
|
'@parcel/watcher': 2.5.6
|
||||||
|
'@tailwindcss/node': 4.1.18
|
||||||
|
'@tailwindcss/oxide': 4.1.18
|
||||||
|
enhanced-resolve: 5.19.0
|
||||||
|
mri: 1.2.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
tailwindcss: 4.1.18
|
||||||
|
|
||||||
|
'@tailwindcss/node@4.1.18':
|
||||||
|
dependencies:
|
||||||
|
'@jridgewell/remapping': 2.3.5
|
||||||
|
enhanced-resolve: 5.19.0
|
||||||
|
jiti: 2.6.1
|
||||||
|
lightningcss: 1.30.2
|
||||||
|
magic-string: 0.30.21
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
tailwindcss: 4.1.18
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-android-arm64@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-arm64@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-darwin-x64@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-freebsd-x64@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tailwindcss/oxide@4.1.18':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tailwindcss/oxide-android-arm64': 4.1.18
|
||||||
|
'@tailwindcss/oxide-darwin-arm64': 4.1.18
|
||||||
|
'@tailwindcss/oxide-darwin-x64': 4.1.18
|
||||||
|
'@tailwindcss/oxide-freebsd-x64': 4.1.18
|
||||||
|
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
|
||||||
|
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
|
||||||
|
'@tailwindcss/oxide-linux-arm64-musl': 4.1.18
|
||||||
|
'@tailwindcss/oxide-linux-x64-gnu': 4.1.18
|
||||||
|
'@tailwindcss/oxide-linux-x64-musl': 4.1.18
|
||||||
|
'@tailwindcss/oxide-wasm32-wasi': 4.1.18
|
||||||
|
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
|
||||||
|
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
|
||||||
|
|
||||||
|
'@tailwindcss/typography@0.5.19(tailwindcss@4.1.18)':
|
||||||
|
dependencies:
|
||||||
|
postcss-selector-parser: 6.0.10
|
||||||
|
tailwindcss: 4.1.18
|
||||||
|
|
||||||
|
autoprefixer@10.4.24(postcss@8.5.6):
|
||||||
|
dependencies:
|
||||||
|
browserslist: 4.28.1
|
||||||
|
caniuse-lite: 1.0.30001769
|
||||||
|
fraction.js: 5.3.4
|
||||||
|
picocolors: 1.1.1
|
||||||
|
postcss: 8.5.6
|
||||||
|
postcss-value-parser: 4.2.0
|
||||||
|
|
||||||
|
baseline-browser-mapping@2.9.19: {}
|
||||||
|
|
||||||
|
browserslist@4.28.1:
|
||||||
|
dependencies:
|
||||||
|
baseline-browser-mapping: 2.9.19
|
||||||
|
caniuse-lite: 1.0.30001769
|
||||||
|
electron-to-chromium: 1.5.286
|
||||||
|
node-releases: 2.0.27
|
||||||
|
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||||
|
|
||||||
|
caniuse-lite@1.0.30001769: {}
|
||||||
|
|
||||||
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
|
detect-libc@2.1.2: {}
|
||||||
|
|
||||||
|
electron-to-chromium@1.5.286: {}
|
||||||
|
|
||||||
|
enhanced-resolve@5.19.0:
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: 4.2.11
|
||||||
|
tapable: 2.3.0
|
||||||
|
|
||||||
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
graceful-fs@4.2.11: {}
|
||||||
|
|
||||||
|
htmx.org@2.0.8: {}
|
||||||
|
|
||||||
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
is-glob@4.0.3:
|
is-glob@4.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-extglob: 2.1.1
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
is-module@1.0.0: {}
|
is-module@1.0.0: {}
|
||||||
|
|
||||||
is-number@7.0.0: {}
|
is-number@7.0.0: {}
|
||||||
@@ -2988,14 +3597,69 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@10.2.2: {}
|
lru-cache@10.2.2: {}
|
||||||
|
|
||||||
|
lru-cache@11.2.4: {}
|
||||||
|
|
||||||
magic-string@0.30.10:
|
magic-string@0.30.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.4.15
|
'@jridgewell/sourcemap-codec': 1.4.15
|
||||||
|
=======
|
||||||
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
|
lightningcss-android-arm64@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-arm64@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-darwin-x64@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-freebsd-x64@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm-gnueabihf@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-gnu@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-arm64-musl@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-gnu@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-linux-x64-musl@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-arm64-msvc@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss-win32-x64-msvc@1.30.2:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lightningcss@1.30.2:
|
||||||
|
dependencies:
|
||||||
|
detect-libc: 2.1.2
|
||||||
|
optionalDependencies:
|
||||||
|
lightningcss-android-arm64: 1.30.2
|
||||||
|
lightningcss-darwin-arm64: 1.30.2
|
||||||
|
lightningcss-darwin-x64: 1.30.2
|
||||||
|
lightningcss-freebsd-x64: 1.30.2
|
||||||
|
lightningcss-linux-arm-gnueabihf: 1.30.2
|
||||||
|
lightningcss-linux-arm64-gnu: 1.30.2
|
||||||
|
lightningcss-linux-arm64-musl: 1.30.2
|
||||||
|
lightningcss-linux-x64-gnu: 1.30.2
|
||||||
|
lightningcss-linux-x64-musl: 1.30.2
|
||||||
|
lightningcss-win32-arm64-msvc: 1.30.2
|
||||||
|
lightningcss-win32-x64-msvc: 1.30.2
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
|
|
||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
md5@2.3.0:
|
md5@2.3.0:
|
||||||
@@ -3152,12 +3816,34 @@ snapshots:
|
|||||||
picocolors: 1.0.0
|
picocolors: 1.0.0
|
||||||
source-map-js: 1.2.0
|
source-map-js: 1.2.0
|
||||||
|
|
||||||
|
=======
|
||||||
|
mri@1.2.0: {}
|
||||||
|
|
||||||
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
|
node-addon-api@7.1.1: {}
|
||||||
|
|
||||||
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
postcss-selector-parser@6.0.10:
|
||||||
|
dependencies:
|
||||||
|
cssesc: 3.0.0
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
postcss-value-parser@4.2.0: {}
|
||||||
|
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
postcss@8.5.6:
|
postcss@8.5.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
nanoid: 3.3.11
|
nanoid: 3.3.11
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
posthog-node@3.1.3:
|
posthog-node@3.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
axios: 1.13.2
|
axios: 1.13.2
|
||||||
@@ -3478,3 +4164,21 @@ snapshots:
|
|||||||
yaml@2.4.2: {}
|
yaml@2.4.2: {}
|
||||||
|
|
||||||
zimmerframe@1.1.4: {}
|
zimmerframe@1.1.4: {}
|
||||||
|
=======
|
||||||
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
|
tailwindcss@4.1.18: {}
|
||||||
|
|
||||||
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||||
|
dependencies:
|
||||||
|
browserslist: 4.28.1
|
||||||
|
escalade: 3.2.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
<<<<<<< HEAD
|
||||||
|
>>>>>>> cbc2a1a (feat: translation middleware)
|
||||||
|
=======
|
||||||
|
|
||||||
|
util-deprecate@1.0.2: {}
|
||||||
|
>>>>>>> 6c4b219 (feat: dummy pages)
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- esbuild
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
cache
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
aa922b989658d017b7a6296093cef3dac23a39ddc1afa6cf7ea24c049a284b56
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://inlang.com/schema/project-settings",
|
|
||||||
"telemetry": "off",
|
|
||||||
"sourceLanguageTag": "de",
|
|
||||||
"languageTags": ["de", "en"],
|
|
||||||
"modules": [
|
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@latest/dist/index.js",
|
|
||||||
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js"
|
|
||||||
],
|
|
||||||
"plugin.inlang.messageFormat": {
|
|
||||||
"pathPattern": "./messages/{languageTag}.json"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@import "@fontsource/outfit";
|
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
// See https://kit.svelte.dev/docs/types#app
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
// interface Error {}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>schreifuchs.ch</title>
|
|
||||||
<meta name="description" content="Portfolio von Niklas - Fotografie, Informatik, Musik und Video." />
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { i18n } from "$lib/i18n"
|
|
||||||
|
|
||||||
export const handle = i18n.handle()
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ImageTile from "./ImageTile.svelte";
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
href: string;
|
|
||||||
target?: "_self" | "_blank" | "_parent" | "_top";
|
|
||||||
children?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
src,
|
|
||||||
href,
|
|
||||||
target = "_self",
|
|
||||||
children
|
|
||||||
}: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<ImageTile {src}>
|
|
||||||
<a {href} {target} class="flex items-center justify-center h-full">
|
|
||||||
{@render children?.()}
|
|
||||||
</a>
|
|
||||||
</ImageTile>
|
|
||||||
</div>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
src: string;
|
|
||||||
alt?: string;
|
|
||||||
children?: Snippet;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { src, alt = "", children }: Props = $props();
|
|
||||||
|
|
||||||
const placeholder = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxyZWN0IGZpbGw9IiMzMzMiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
|
|
||||||
|
|
||||||
let imageSrc = $derived(src || placeholder);
|
|
||||||
|
|
||||||
// Generate srcset for optimized images from our proxy
|
|
||||||
let srcset = $derived.by(() => {
|
|
||||||
if (!src || !src.startsWith('/assets/')) return undefined;
|
|
||||||
|
|
||||||
const widths = [400, 800, 1200, 1600];
|
|
||||||
const sets = widths.map(w => `${src}${src.includes('?') ? '&' : '?'}w=${w} ${w}w`);
|
|
||||||
|
|
||||||
// Add original as fallback
|
|
||||||
sets.push(`${src} 2000w`);
|
|
||||||
|
|
||||||
return sets.join(', ');
|
|
||||||
});
|
|
||||||
|
|
||||||
const sizes = "(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 grid-rows-1">
|
|
||||||
<img
|
|
||||||
src={imageSrc}
|
|
||||||
{srcset}
|
|
||||||
{sizes}
|
|
||||||
{alt}
|
|
||||||
loading="lazy"
|
|
||||||
class="row-start-1 col-start-1 pointer-events-none w-full h-96 object-cover"
|
|
||||||
onerror={(e) => {
|
|
||||||
const img = e.currentTarget as HTMLImageElement;
|
|
||||||
img.src = placeholder;
|
|
||||||
// Clear srcset on error to avoid loading broken thumbnails
|
|
||||||
img.srcset = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div class="row-start-1 col-start-1">
|
|
||||||
{@render children?.()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Marked, Renderer } from "@ts-stack/markdown";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
content?: string;
|
|
||||||
basePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { content = "", basePath = "" }: Props = $props();
|
|
||||||
|
|
||||||
const renderer = new Renderer();
|
|
||||||
|
|
||||||
// Custom image renderer to proxy through /assets/
|
|
||||||
renderer.image = (href, title, text) => {
|
|
||||||
let src = href;
|
|
||||||
if (href && !href.startsWith('http')) {
|
|
||||||
// Ensure basePath starts with / if provided
|
|
||||||
const normalizedBasePath = basePath.startsWith('/') ? basePath : `/${basePath}`;
|
|
||||||
// If href starts with /, it's relative to root. Otherwise, relative to basePath.
|
|
||||||
if (!href.startsWith('/')) {
|
|
||||||
src = `${normalizedBasePath}/${href}`.replace(/\/+/g, '/');
|
|
||||||
}
|
|
||||||
src = `/assets${src}`;
|
|
||||||
|
|
||||||
// Generate srcset for optimized images
|
|
||||||
const widths = [400, 800, 1200, 1600, 2000];
|
|
||||||
const srcset = widths
|
|
||||||
.map(w => `${src}?w=${w} ${w}w`)
|
|
||||||
.join(', ');
|
|
||||||
const sizes = "(max-width: 1024px) 100vw, 1024px";
|
|
||||||
|
|
||||||
return `<img src="${src}" srcset="${srcset}" sizes="${sizes}" alt="${text}" title="${title || ''}" loading="lazy" />`;
|
|
||||||
}
|
|
||||||
return `<img src="${src}" alt="${text}" title="${title || ''}" loading="lazy" />`;
|
|
||||||
};
|
|
||||||
|
|
||||||
let html = $derived(
|
|
||||||
Marked.parse(content || "__nothing to see here__", {
|
|
||||||
renderer,
|
|
||||||
gfm: true,
|
|
||||||
tables: true,
|
|
||||||
breaks: false,
|
|
||||||
pedantic: false,
|
|
||||||
sanitize: false,
|
|
||||||
smartLists: true,
|
|
||||||
smartypants: false,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="markdown">
|
|
||||||
{@html html}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style global>
|
|
||||||
.markdown {
|
|
||||||
color: white;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.markdown h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
.markdown h2 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
.markdown h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
.markdown h4 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown * {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown img {
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown table,
|
|
||||||
.markdown tr,
|
|
||||||
.markdown th,
|
|
||||||
.markdown td {
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: 1px solid;
|
|
||||||
|
|
||||||
padding: 0.3rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.markdown th {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { createI18n } from "@inlang/paraglide-sveltekit"
|
|
||||||
import * as runtime from "$lib/paraglide/runtime"
|
|
||||||
|
|
||||||
export const i18n = createI18n(runtime, {
|
|
||||||
pathnames: {
|
|
||||||
"/about": {
|
|
||||||
en: "/about",
|
|
||||||
de: "/ueber-uns"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
Before Width: | Height: | Size: 661 KiB |
|
Before Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 423 KiB |
|
Before Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 476 KiB |
|
Before Width: | Height: | Size: 766 KiB |
@@ -1 +0,0 @@
|
|||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
const cache = new Map<string, { timestamp: number; data: any }>();
|
|
||||||
const DEFAULT_TTL = 1000 * 60 * 15; // 15 minutes default TTL
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Caches the result of a promise-returning function.
|
|
||||||
* @param key Unique key for the cache entry.
|
|
||||||
* @param fetcher Function that returns a promise with the data to cache.
|
|
||||||
* @param ttl Time to live in milliseconds. Defaults to 15 minutes.
|
|
||||||
*/
|
|
||||||
export async function withCache<T>(key: string, fetcher: () => Promise<T>, ttl = DEFAULT_TTL): Promise<T> {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = cache.get(key);
|
|
||||||
|
|
||||||
if (entry && (now - entry.timestamp < ttl)) {
|
|
||||||
return entry.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await fetcher();
|
|
||||||
cache.set(key, { timestamp: now, data });
|
|
||||||
return data;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error fetching data for key ${key}:`, error);
|
|
||||||
// If fetch fails but we have stale data, return it?
|
|
||||||
// For now, let's bubble the error up so the page handles it (e.g. 404),
|
|
||||||
// or maybe just re-throw.
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually clears a specific cache key.
|
|
||||||
*/
|
|
||||||
export function clearCache(key: string) {
|
|
||||||
cache.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clears the entire cache.
|
|
||||||
*/
|
|
||||||
export function clearAllCache() {
|
|
||||||
cache.clear();
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import { createClient } from "webdav";
|
|
||||||
import { env } from "$env/dynamic/private";
|
|
||||||
import { withCache } from "./cache";
|
|
||||||
|
|
||||||
if (!env.NEXTCLOUD_URL || !env.NEXTCLOUD_USER || !env.NEXTCLOUD_PASSWORD) {
|
|
||||||
console.warn(
|
|
||||||
"Nextcloud environment variables are not fully set. WebDAV client might not work correctly.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct the standard Nextcloud WebDAV URL: https://example.com/remote.php/dav/files/USERNAME/[BASE_DIR/]
|
|
||||||
const getWebdavUrl = () => {
|
|
||||||
if (!env.NEXTCLOUD_URL) return "";
|
|
||||||
|
|
||||||
let url = env.NEXTCLOUD_URL.endsWith("/")
|
|
||||||
? env.NEXTCLOUD_URL
|
|
||||||
: `${env.NEXTCLOUD_URL}/`;
|
|
||||||
url += `remote.php/dav/files/${env.NEXTCLOUD_USER}/`;
|
|
||||||
|
|
||||||
if (env.NEXTCLOUD_BASE_DIR) {
|
|
||||||
// Remove leading/trailing slashes from base dir to ensure clean concatenation
|
|
||||||
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
|
|
||||||
if (baseDir) {
|
|
||||||
url += `${baseDir}/`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return url;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const client = createClient(getWebdavUrl(), {
|
|
||||||
username: env.NEXTCLOUD_USER || "",
|
|
||||||
password: env.NEXTCLOUD_PASSWORD || "",
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getCoverImage(slug: string): Promise<string | null> {
|
|
||||||
return withCache(`cover:${slug}`, async () => {
|
|
||||||
try {
|
|
||||||
const contents = await client.getDirectoryContents("/" + slug);
|
|
||||||
if (!Array.isArray(contents)) return null;
|
|
||||||
|
|
||||||
const cover = contents.find(
|
|
||||||
(item: any) =>
|
|
||||||
item.type === "file" &&
|
|
||||||
item.basename.match(/^cover\.(webp|jpg|jpeg|png)$/i),
|
|
||||||
);
|
|
||||||
|
|
||||||
return cover ? `/assets/${slug}/${cover.basename}` : null;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Failed to check cover image for ${slug}:`, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPages(lang: string = 'de') {
|
|
||||||
return withCache(`pages:${lang}`, async () => {
|
|
||||||
try {
|
|
||||||
const items = await client.getDirectoryContents("/");
|
|
||||||
|
|
||||||
if (!Array.isArray(items)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const directories = items.filter(
|
|
||||||
(item: any) =>
|
|
||||||
item.type === "directory" &&
|
|
||||||
!item.basename.startsWith(".") &&
|
|
||||||
!item.basename.startsWith("_"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pages = await Promise.all(
|
|
||||||
directories.map(async (item: any) => {
|
|
||||||
const slug = item.basename;
|
|
||||||
const baseName = slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
||||||
const filename = lang === 'de' ? `${baseName}.md` : `${baseName}.${lang}.md`;
|
|
||||||
const path = `/${slug}/${filename}`;
|
|
||||||
|
|
||||||
let title = baseName;
|
|
||||||
try {
|
|
||||||
const content = await getPageContent(path);
|
|
||||||
const headerMatch = content.match(/^#{1,6}\s+(.+)$/m);
|
|
||||||
if (headerMatch) {
|
|
||||||
title = headerMatch[1];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Fallback to German if English is missing
|
|
||||||
if (lang !== 'de') {
|
|
||||||
try {
|
|
||||||
const fallbackContent = await getPageContent(`/${slug}/${baseName}.md`);
|
|
||||||
const headerMatch = fallbackContent.match(/^#{1,6}\s+(.+)$/m);
|
|
||||||
if (headerMatch) {
|
|
||||||
title = headerMatch[1];
|
|
||||||
}
|
|
||||||
} catch (innerE) {
|
|
||||||
console.warn(`Could not fetch fallback title for ${slug}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const coverImage = await getCoverImage(slug);
|
|
||||||
|
|
||||||
return {
|
|
||||||
slug,
|
|
||||||
title,
|
|
||||||
coverImage,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch pages from Nextcloud:", e);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPageContent(path: string): Promise<string> {
|
|
||||||
return withCache(`content:${path}`, async () => {
|
|
||||||
const content = await client.getFileContents(path, { format: "text" });
|
|
||||||
return content as string;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import type { ParamMatcher } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const match: ParamMatcher = (param) => {
|
|
||||||
return ['de', 'en'].includes(param);
|
|
||||||
};
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
|
||||||
import { getPages } from '$lib/server/nextcloud';
|
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ params }) => {
|
|
||||||
const pages = await getPages(params.lang);
|
|
||||||
return {
|
|
||||||
pages
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import "../app.css";
|
|
||||||
import type { Snippet } from 'svelte';
|
|
||||||
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
|
|
||||||
import { i18n } from '$lib/i18n';
|
|
||||||
import { languageTag } from '$lib/paraglide/runtime';
|
|
||||||
import * as m from '$lib/paraglide/messages';
|
|
||||||
|
|
||||||
let { children }: { children?: Snippet } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{m.site_title()}</title>
|
|
||||||
<meta name="description" content={m.site_description()} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<ParaglideJS {i18n}>
|
|
||||||
<header class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start">
|
|
||||||
<a href="/" class="text-white text-2xl">schreifuchs.ch</a>
|
|
||||||
<nav class="flex gap-2 p-2">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
hreflang="de"
|
|
||||||
data-no-translate
|
|
||||||
class="text-white hover:underline {languageTag() === 'de' ? 'font-bold' : ''}"
|
|
||||||
>
|
|
||||||
DE
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/en"
|
|
||||||
hreflang="en"
|
|
||||||
data-no-translate
|
|
||||||
class="text-white hover:underline {languageTag() === 'en' ? 'font-bold' : ''}"
|
|
||||||
>
|
|
||||||
EN
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
{@render children?.()}
|
|
||||||
</ParaglideJS>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// This can be false if you're using a fallback (i.e. SPA mode)
|
|
||||||
// export const prerender = true;
|
|
||||||
// export const trailingSlash = 'always';
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ImageLinkTile from "$lib/components/ImageLinkTile.svelte";
|
|
||||||
import type { PageData } from "./$types";
|
|
||||||
import { i18n } from "$lib/i18n";
|
|
||||||
import * as m from "$lib/paraglide/messages";
|
|
||||||
|
|
||||||
// Import local images to preserve existing design
|
|
||||||
import mouse from "$lib/images/home/mouse.webp";
|
|
||||||
import drummachine from "$lib/images/home/drummachine.webp";
|
|
||||||
import scouts from "$lib/images/home/scouts.webp";
|
|
||||||
import console_image from "$lib/images/home/console.webp";
|
|
||||||
import lauch from "$lib/images/home/lauch.webp";
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
// Map slugs to local images for backward compatibility/styling
|
|
||||||
const staticImages: Record<string, string> = {
|
|
||||||
informatik: console_image,
|
|
||||||
fotos: mouse,
|
|
||||||
video: lauch,
|
|
||||||
musig: drummachine,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to resolve image source: Local override -> Nextcloud cover -> Placeholder (handled by ImageTile)
|
|
||||||
const getTileImage = (page: { slug: string; coverImage: string | null }) => {
|
|
||||||
return staticImages[page.slug.toLowerCase()] || page.coverImage || "";
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<main class="">
|
|
||||||
{#if data.pages}
|
|
||||||
{#each data.pages as page}
|
|
||||||
<ImageLinkTile src={getTileImage(page)} href={`/${page.slug}`}>
|
|
||||||
<h2
|
|
||||||
class="text-3xl font-bold text-white hover:underline text-shadow-lg"
|
|
||||||
>
|
|
||||||
{page.title}
|
|
||||||
</h2>
|
|
||||||
</ImageLinkTile>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Static/External Links -->
|
|
||||||
<ImageLinkTile src={scouts} href="https://pfadifrisco.ch/" target="_blank">
|
|
||||||
<h2 class="text-3xl font-bold text-white hover:underline text-shadow-lg">
|
|
||||||
{m.pfadi()}
|
|
||||||
</h2>
|
|
||||||
</ImageLinkTile>
|
|
||||||
</main>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { getCoverImage, getPageContent } from "$lib/server/nextcloud";
|
|
||||||
import type { PageServerLoad } from "./$types";
|
|
||||||
import { error } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
const { slug, lang } = params;
|
|
||||||
const language = lang || 'de';
|
|
||||||
|
|
||||||
// Try both the regular slug and the hidden (_slug) folder
|
|
||||||
const slugsToTry = [slug, `_${slug}`];
|
|
||||||
let rawContent = "";
|
|
||||||
let finalSlug = slug;
|
|
||||||
let baseName = "";
|
|
||||||
let filename = "";
|
|
||||||
|
|
||||||
for (const s of slugsToTry) {
|
|
||||||
const cleanSlug = s.startsWith('_') ? s.slice(1) : s;
|
|
||||||
baseName = cleanSlug.charAt(0).toUpperCase() + cleanSlug.slice(1);
|
|
||||||
|
|
||||||
filename = language === 'de' ? `${baseName}.md` : `${baseName}.${language}.md`;
|
|
||||||
const path = `/${s}/${filename}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
rawContent = await getPageContent(path);
|
|
||||||
finalSlug = s;
|
|
||||||
break; // Found it!
|
|
||||||
} catch (e) {
|
|
||||||
if (language !== 'de') {
|
|
||||||
console.warn(`Localized content ${filename} not found in ${s}, falling back to German.`);
|
|
||||||
rawContent = await getPageContent(`/${s}/${baseName}.md`);
|
|
||||||
finalSlug = s;
|
|
||||||
break; // Found fallback
|
|
||||||
} else {
|
|
||||||
throw e; // Try next slug variant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (s === slugsToTry[slugsToTry.length - 1]) {
|
|
||||||
console.error(`Error fetching page from Nextcloud for slug ${slug}`, e);
|
|
||||||
throw error(404, "Page not found");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Extract the first header (e.g., # Title or ## Title)
|
|
||||||
const headerMatch = rawContent.match(/^#{1,6}\s+(.+)$/m);
|
|
||||||
const title = headerMatch ? headerMatch[1] : (finalSlug.startsWith('_') ? finalSlug.slice(1) : finalSlug);
|
|
||||||
|
|
||||||
// Remove the first header from content so it's not rendered twice
|
|
||||||
const content = headerMatch
|
|
||||||
? rawContent.replace(headerMatch[0], "").trim()
|
|
||||||
: rawContent;
|
|
||||||
|
|
||||||
const coverImage = await getCoverImage(finalSlug);
|
|
||||||
|
|
||||||
return {
|
|
||||||
content,
|
|
||||||
title,
|
|
||||||
slug: finalSlug,
|
|
||||||
coverImage,
|
|
||||||
lang: language
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error processing content for ${finalSlug}`, e);
|
|
||||||
throw error(404, "Page not found");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import ImageTile from "$lib/components/ImageTile.svelte";
|
|
||||||
import Markdown from "$lib/components/Markdown.svelte";
|
|
||||||
import type { PageData } from "./$types";
|
|
||||||
|
|
||||||
// Legacy images
|
|
||||||
import sunne_untergang from "$lib/images/fotos/sunne_untergang.webp";
|
|
||||||
import monitor from "$lib/images/informatik/monitor.webp";
|
|
||||||
import plattespiler from "$lib/images/musig/plattespiler.webp";
|
|
||||||
import jochen from "$lib/images/video/jochen.webp";
|
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
|
||||||
|
|
||||||
const legacyHeaders: Record<string, string> = {
|
|
||||||
fotos: sunne_untergang,
|
|
||||||
informatik: monitor,
|
|
||||||
musig: plattespiler,
|
|
||||||
video: jochen
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fallback image data URI
|
|
||||||
const placeholder = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiPjxyZWN0IGZpbGw9IiMzMzMiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiLz48L3N2Zz4=';
|
|
||||||
|
|
||||||
// Use the cover image found by the server, or legacy local image, or placeholder
|
|
||||||
let headerImage = $derived(
|
|
||||||
data.coverImage ||
|
|
||||||
legacyHeaders[data.slug.toLowerCase()] ||
|
|
||||||
"" // ImageTile handles empty/null src with placeholder or its own logic
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{data.title} - schreifuchs.ch</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<ImageTile src={headerImage} alt={data.title}>
|
|
||||||
<h2 class="flex items-center justify-center h-full text-3xl font-bold text-white text-shadow-lg">
|
|
||||||
{data.title}
|
|
||||||
</h2>
|
|
||||||
</ImageTile>
|
|
||||||
|
|
||||||
<main class="flex items-center justify-center pt-8 bg-black min-h-screen px-12">
|
|
||||||
<section class="max-w-screen-md w-full">
|
|
||||||
<Markdown content={data.content} basePath={`/${data.slug}`} />
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { client } from "$lib/server/nextcloud";
|
|
||||||
import { error } from "@sveltejs/kit";
|
|
||||||
import type { RequestHandler } from "./$types";
|
|
||||||
import mime from "mime-types";
|
|
||||||
import { env } from "$env/dynamic/private";
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url }) => {
|
|
||||||
const { path } = params;
|
|
||||||
const width = url.searchParams.get("w");
|
|
||||||
const height = url.searchParams.get("h");
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
throw error(400, "Path is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let buffer: ArrayBuffer;
|
|
||||||
let contentType: string;
|
|
||||||
|
|
||||||
// Try both original path and hidden variant if applicable
|
|
||||||
const pathsToTry = [path];
|
|
||||||
if (!path.startsWith('_')) {
|
|
||||||
pathsToTry.push(`_${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: Response | null = null;
|
|
||||||
let lastError: any = null;
|
|
||||||
|
|
||||||
for (const currentPath of pathsToTry) {
|
|
||||||
try {
|
|
||||||
if (width || height) {
|
|
||||||
// Use Nextcloud Preview API
|
|
||||||
const baseUrl = env.NEXTCLOUD_URL?.endsWith("/") ? env.NEXTCLOUD_URL : `${env.NEXTCLOUD_URL}/`;
|
|
||||||
|
|
||||||
let fullPath = currentPath;
|
|
||||||
if (env.NEXTCLOUD_BASE_DIR) {
|
|
||||||
const baseDir = env.NEXTCLOUD_BASE_DIR.replace(/^\/+|\/+$/g, "");
|
|
||||||
fullPath = baseDir ? `${baseDir}/${currentPath}` : currentPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isJpg = fullPath.match(/\.(jpg|jpeg)$/i);
|
|
||||||
const previewExtension = isJpg ? 'jpg' : 'png';
|
|
||||||
const previewUrl = new URL(`${baseUrl}index.php/core/preview.${previewExtension}`);
|
|
||||||
// Ensure leading slash for the file parameter
|
|
||||||
const fileParam = fullPath.startsWith('/') ? fullPath : `/${fullPath}`;
|
|
||||||
previewUrl.searchParams.set("file", fileParam);
|
|
||||||
if (width) previewUrl.searchParams.set("x", width);
|
|
||||||
if (height) previewUrl.searchParams.set("y", height);
|
|
||||||
previewUrl.searchParams.set("forceIcon", "0");
|
|
||||||
|
|
||||||
const auth = Buffer.from(`${env.NEXTCLOUD_USER}:${env.NEXTCLOUD_PASSWORD}`).toString("base64");
|
|
||||||
|
|
||||||
console.log(`Fetching preview: ${previewUrl.toString()} for file: ${fullPath}`);
|
|
||||||
|
|
||||||
const ncResponse = await fetch(previewUrl.toString(), {
|
|
||||||
headers: {
|
|
||||||
"Authorization": `Basic ${auth}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ncResponse.ok) {
|
|
||||||
buffer = await ncResponse.arrayBuffer();
|
|
||||||
contentType = ncResponse.headers.get("Content-Type") || "image/png";
|
|
||||||
|
|
||||||
return new Response(buffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": contentType,
|
|
||||||
"Cache-Control": "public, max-age=86400",
|
|
||||||
"Vary": "Accept-Encoding"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.warn(`Nextcloud preview failed (${ncResponse.status}) for ${fullPath}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch original via WebDAV (Fallback or no resize requested)
|
|
||||||
console.log(`Fetching original file via WebDAV: /${currentPath}`);
|
|
||||||
const data = await client.getFileContents("/" + currentPath);
|
|
||||||
buffer = data as ArrayBuffer;
|
|
||||||
contentType = mime.lookup(currentPath) || "application/octet-stream";
|
|
||||||
|
|
||||||
return new Response(buffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": contentType,
|
|
||||||
"Cache-Control": "public, max-age=86400",
|
|
||||||
"Vary": "Accept-Encoding"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
lastError = e;
|
|
||||||
continue; // Try next path variant
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError || new Error("Asset not found");
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error fetching asset from Nextcloud: ${path}`, e);
|
|
||||||
throw error(404, "Asset not found");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import adapter from '@sveltejs/adapter-node';
|
|
||||||
import { sveltePreprocess } from 'svelte-preprocess';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
|
||||||
const config = {
|
|
||||||
kit: {
|
|
||||||
adapter: adapter()
|
|
||||||
},
|
|
||||||
preprocess: [vitePreprocess(), sveltePreprocess({})]
|
|
||||||
};
|
|
||||||
export default config;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import plugin from "tailwindcss/plugin"
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Outfit', 'outfit', 'sans-serif']
|
|
||||||
},
|
|
||||||
textShadow: {
|
|
||||||
sm: '0 1px 2px #000',
|
|
||||||
DEFAULT: '0 2px 4px #000',
|
|
||||||
lg: '0px 0px 20px #000',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
plugin(function({ matchUtilities, theme }) {
|
|
||||||
matchUtilities(
|
|
||||||
{
|
|
||||||
'text-shadow': (value) => ({
|
|
||||||
textShadow: value,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ values: theme('textShadow') }
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
}
|
|
||||||
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
|
|
||||||
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
|
|
||||||
//
|
|
||||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
|
||||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { paraglide } from '@inlang/paraglide-sveltekit/vite';
|
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import { defineConfig } from 'vite';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [
|
|
||||||
paraglide({
|
|
||||||
project: './project.inlang',
|
|
||||||
outdir: './src/lib/paraglide'
|
|
||||||
}),
|
|
||||||
sveltekit()
|
|
||||||
]
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
templ Hello() {
|
||||||
|
<h1>Hello World</h1>
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package components
|
||||||
|
|
||||||
|
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
|
||||||
|
|
||||||
|
templ ImageTile(src string) {
|
||||||
|
<div class="grid grid-cols-1 grid-rows-1">
|
||||||
|
<img
|
||||||
|
src={ src }
|
||||||
|
srcset={ images.SourceSet(src) }
|
||||||
|
fetchpriority="high"
|
||||||
|
class="row-start-1 col-start-1 pointer-events-none w-full h-96 object-cover"
|
||||||
|
/>
|
||||||
|
<div class="row-start-1 col-start-1">
|
||||||
|
{ children... }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package layouts
|
||||||
|
|
||||||
|
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
||||||
|
import "strings"
|
||||||
|
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
|
||||||
|
|
||||||
|
templ Base(title string) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang={ translate.Language(ctx) }>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>{ title }</title>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png"/>
|
||||||
|
<link fetchpriority="high" href="/css/output.css" rel="stylesheet"/>
|
||||||
|
<script fetchpriority="low" src="/js/htmx.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-preload@2.1.2" integrity="sha384-PRIcY6hH1Y5784C76/Y8SqLyTanY9rnI3B8F3+hKZFNED55hsEqMJyqWhp95lgfk" crossorigin="anonymous"></script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-black dark" hx-boost="true" hx-target="main" hx-ext="preload">
|
||||||
|
<header
|
||||||
|
class="fixed z-50 mix-blend-difference p-1 w-full flex justify-between items-start"
|
||||||
|
>
|
||||||
|
<a class="text-white text-2xl" href={ "/" + translate.Path(ctx, "") }>schreifuchs.ch</a>
|
||||||
|
<nav class="flex gap-2 p-2">
|
||||||
|
for _, lang := range translate.SupportedLanguages(ctx) {
|
||||||
|
<a
|
||||||
|
href={ "/" + translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...) }
|
||||||
|
hreflang={ lang }
|
||||||
|
data-no-translate
|
||||||
|
class="text-white hover:underline uppercase"
|
||||||
|
>
|
||||||
|
{ lang }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
<main class="text-white">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package layouts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render renders a page. If the query paramenter "c" is set
|
||||||
|
// the component gets rendered without the layout
|
||||||
|
func Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) {
|
||||||
|
withLayout := !r.URL.Query().Has("c")
|
||||||
|
fmt.Println("layout: ", withLayout)
|
||||||
|
|
||||||
|
slog.Debug("HX-Request", "value", r.Header.Get("HX-Request"))
|
||||||
|
if r.Header.Get("HX-Request") != "true" {
|
||||||
|
ctx = templ.WithChildren(ctx, component)
|
||||||
|
component = Base("hello")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := component.Render(ctx, w); err != nil {
|
||||||
|
slog.Error("error while rendering component", "err", err, "ctx", ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
|
||||||
|
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/components"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ Home(pages []page.PageHeader) {
|
||||||
|
for _, page := range pages {
|
||||||
|
@components.ImageTile("/images/" + page.CoverImageUID) {
|
||||||
|
<a class="flex items-center justify-center h-full " href={ translate.Path(ctx, page.UID) } preload="mouseover">
|
||||||
|
<h2
|
||||||
|
class="text-3xl font-bold text-white hover:underline text-shadow-lg"
|
||||||
|
>
|
||||||
|
{ page.Title }
|
||||||
|
</h2>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
|
||||||
|
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/components"
|
||||||
|
|
||||||
|
templ ContentPage(content page.Page) {
|
||||||
|
@components.ImageTile("/images/" + content.PageHeader.CoverImageUID) {
|
||||||
|
<h2 class="flex items-center justify-center h-full text-3xl font-bold text-shadow-lg">
|
||||||
|
{ content.Title }
|
||||||
|
</h2>
|
||||||
|
}
|
||||||
|
<div class="flex items-start justify-center pt-8 bg-black min-h-screen px-12">
|
||||||
|
<section class="max-w-6xl w-full prose prose-invert text-justify">
|
||||||
|
@templ.Raw(content.Content)
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//go:generate go tool templ generate
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate pnpm run build:css
|
||||||
|
//go:generate pnpm run build:js
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var Static embed.FS
|
||||||
|
|
||||||
|
func GetStaticFS() fs.FS {
|
||||||
|
// fs.Sub returns an fs.FS corresponding to the subtree rooted at "static"
|
||||||
|
f, err := fs.Sub(Static, "static")
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
@source "../../**/*.templ";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Outfit";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/Outfit-LatinExt-Variable.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
|
||||||
|
U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
|
||||||
|
U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "Outfit";
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-display: swap;
|
||||||
|
src: url("../fonts/Outfit-Latin-Variable.woff2") format("woff2");
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||||
|
U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Font Family Namespace */
|
||||||
|
--font-sans: "Outfit", "outfit", sans-serif;
|
||||||
|
|
||||||
|
/* Text Shadow Namespace */
|
||||||
|
/* These will automatically generate .text-shadow-sm, .text-shadow, and .text-shadow-lg */
|
||||||
|
--text-shadow-sm: 0 1px 2px #000;
|
||||||
|
--text-shadow: 0 2px 4px #000;
|
||||||
|
--text-shadow-lg: 0px 0px 20px #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Functional Utility for arbitrary values (e.g., text-shadow-[...])
|
||||||
|
This replaces the 'matchUtilities' logic from your JS plugin.
|
||||||
|
*/
|
||||||
|
@utility text-shadow-* {
|
||||||
|
text-shadow: --value(--text-shadow-*, [color], [*]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.prose img {
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
max-height: 90vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |