Compare commits

...
9 Commits
Author SHA1 Message Date
schreifuchs 7b60024935 feat: dockerization 2026-03-02 22:33:22 +01:00
schreifuchs c3af7a1b35 feat: page content 2026-03-02 15:40:42 +01:00
schreifuchs 19b9532f21 chore: custom error 2026-03-02 13:39:34 +01:00
schreifuchs abfddca518 feat: cache revalidation 2026-02-27 23:10:02 +01:00
schreifuchs 6f98d730bb feat: images 2026-02-23 09:50:42 +01:00
schreifuchs 3f321f0836 feat: caching headers 2026-02-21 16:16:55 +01:00
schreifuchs 6c4b2190b3 feat: dummy pages 2026-02-19 22:04:29 +01:00
schreifuchs cbc2a1a803 feat: translation middleware 2026-02-19 21:13:01 +01:00
schreifuchs bbae5b1c93 feat: initialize template 2026-02-11 20:27:14 +01:00
101 changed files with 3439 additions and 4203 deletions
+53
View File
@@ -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
-12
View File
@@ -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"
]
}
}
}
+2 -4
View File
@@ -1,10 +1,8 @@
.DS_Store
node_modules
/tmp
/tmp/*
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
-1
View File
@@ -1 +0,0 @@
engine-strict=true
+22
View File
@@ -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"
}
]
}
+41 -33
View File
@@ -1,46 +1,54 @@
# Stage 1: Build the application
FROM node:24-slim AS builder
# Enable corepack to use pnpm
RUN corepack enable
# Stage 1: Build frontend assets
FROM node:24-alpine AS frontend-builder
WORKDIR /app
# Copy configuration files
COPY package.json pnpm-lock.yaml .npmrc ./
# Enable pnpm via corepack
RUN corepack enable pnpm
# Install all dependencies (including devDependencies)
# Install dependencies
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy the rest of the application code
# Copy source and build assets
COPY . .
RUN pnpm run build:css && pnpm run build:js
# Build the SvelteKit application
RUN pnpm run build
# Stage 2: Run the application
FROM node:24-slim AS runner
# Enable corepack to use pnpm
RUN corepack enable
# Stage 2: Build Go binary
FROM golang:1.26-alpine AS go-builder
WORKDIR /app
# Copy the build output and necessary files for production
COPY --from=builder /app/build ./build
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 git for potential private modules (though not strictly needed here)
RUN apk add --no-cache git
# Install only production dependencies
RUN pnpm install --prod --frozen-lockfile
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
# Expose the port the app runs on (SvelteKit defaults to 3000)
EXPOSE 3000
# Copy source code
COPY . .
# Set environment variables
ENV NODE_ENV=production
ENV PORT=3000
# Copy built frontend assets from the previous stage
COPY --from=frontend-builder /app/web/static/css/output.css ./web/static/css/
COPY --from=frontend-builder /app/web/static/js/htmx.min.js ./web/static/js/
# Run the application
CMD ["node", "build"]
# Generate templ files using the tool defined in go.mod
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"]
-65
View File
@@ -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.
+22
View File
@@ -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)
}
}
+12
View File
@@ -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
+32
View File
@@ -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
)
+61
View File
@@ -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=
+118
View File
@@ -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
}
+68
View File
@@ -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()
}
+30
View File
@@ -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)
}
+55
View File
@@ -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)),
)
}
+59
View File
@@ -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
}
+69
View File
@@ -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
}
}
+80
View File
@@ -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
}
+15
View File
@@ -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,
}
}
+81
View File
@@ -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
}
+22
View File
@@ -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",
},
},
}
}
+70
View File
@@ -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
}
}
}
+58
View File
@@ -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{}
}
+464
View 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())
}
}
+106
View File
@@ -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(),
}
}
}
+109
View File
@@ -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))
}
+79
View File
@@ -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))
}
+67
View File
@@ -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))
}
+30
View File
@@ -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))
}
+27
View File
@@ -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)
}
+43
View File
@@ -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)
}
+26
View File
@@ -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)
}
+68
View File
@@ -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)
}
+24
View File
@@ -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
}
+80
View File
@@ -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),
)
})
}
+34
View File
@@ -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
}
+51
View File
@@ -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)
})
}
}
+34
View File
@@ -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, "/")
}
-8
View File
@@ -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."
}
-8
View File
@@ -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 ."
}
+17 -29
View File
@@ -1,37 +1,25 @@
{
"name": "schreifuchs",
"version": "0.0.1",
"private": true,
"name": "schreifuchs.ch",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
"build:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --minify",
"watch:css": "tailwindcss -i ./web/static/css/input.css -o ./web/static/css/output.css --watch",
"build:js": "cp node_modules/htmx.org/dist/htmx.min.js web/static/js/htmx.min.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.28.2",
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.5.1",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/mime-types": "^3.0.1",
"@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"
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18"
},
"type": "module",
"dependencies": {
"@fontsource/outfit": "^5.0.13",
"@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"
"htmx.org": "^2.0.8"
}
}
+470 -3230
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
onlyBuiltDependencies:
- esbuild
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-1
View File
@@ -1 +0,0 @@
cache
-1
View File
@@ -1 +0,0 @@
aa922b989658d017b7a6296093cef3dac23a39ddc1afa6cf7ea24c049a284b56
-14
View File
@@ -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"
}
}
-5
View File
@@ -1,5 +0,0 @@
@import "@fontsource/outfit";
@tailwind base;
@tailwind components;
@tailwind utilities;
-13
View File
@@ -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 {};
-14
View File
@@ -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>
-3
View File
@@ -1,3 +0,0 @@
import { i18n } from "$lib/i18n"
export const handle = i18n.handle()
-27
View File
@@ -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>
-50
View File
@@ -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>
-98
View File
@@ -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>
-11
View File
@@ -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"
}
}
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 KiB

-1
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
-43
View File
@@ -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();
}
-124
View File
@@ -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;
});
}
-5
View File
@@ -1,5 +0,0 @@
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => {
return ['de', 'en'].includes(param);
};
-9
View File
@@ -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
};
};
-40
View File
@@ -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>
-3
View File
@@ -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';
-49
View File
@@ -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>
-101
View File
@@ -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");
}
};
-11
View File
@@ -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;
-29
View File
@@ -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') }
)
}),
],
}
-19
View File
@@ -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
}
-13
View File
@@ -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()
]
});
+5
View File
@@ -0,0 +1,5 @@
package components
templ Hello() {
<h1>Hello World</h1>
}
+40
View File
@@ -0,0 +1,40 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
func Hello() templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1>Hello World</h1>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+17
View File
@@ -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>
}
+76
View File
@@ -0,0 +1,76 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package components
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
func ImageTile(src string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"grid grid-cols-1 grid-rows-1\"><img src=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(src)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/imagetile.templ`, Line: 8, Col: 12}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" srcset=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(images.SourceSet(src))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `components/imagetile.templ`, Line: 9, Col: 33}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+42
View File
@@ -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>
}
+140
View File
@@ -0,0 +1,140 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package layouts
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/translate"
import "strings"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/templer"
func Base(title string) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(translate.Language(ctx))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 9, Col: 37}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 13, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 templ.SafeURL
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs("/" + translate.Path(ctx, ""))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 23, Col: 71}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">schreifuchs.ch</a><nav class=\"flex gap-2 p-2\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
for _, lang := range translate.SupportedLanguages(ctx) {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<a href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var5 templ.SafeURL
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/" + translate.PathForLang(ctx, lang, strings.Split(templer.Path(ctx), "/")...))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 27, Col: 94}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\" hreflang=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var6 string
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(lang)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 28, Col: 22}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\" data-no-translate class=\"text-white hover:underline uppercase\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var7 string
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lang)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `layouts/base.templ`, Line: 32, Col: 13}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</nav></header><main class=\"text-white\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</main></body></html>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+28
View File
@@ -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)
}
}
+21
View File
@@ -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>
}
}
}
+92
View File
@@ -0,0 +1,92 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package pages
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
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"
)
func Home(pages []page.PageHeader) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
for _, page := range pages {
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<a class=\"flex items-center justify-center h-full \" href=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 templ.SafeURL
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(translate.Path(ctx, page.UID))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/index.templ`, Line: 12, Col: 91}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\" preload=\"mouseover\"><h2 class=\"text-3xl font-bold text-white hover:underline text-shadow-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var4 string
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(page.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/index.templ`, Line: 16, Col: 17}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</h2></a>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.ImageTile("/images/"+page.CoverImageUID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+17
View File
@@ -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>
}
+86
View File
@@ -0,0 +1,86 @@
// Code generated by templ - DO NOT EDIT.
// templ: version: v0.3.977
package pages
//lint:file-ignore SA4006 This context is only used if a nested component is present.
import "github.com/a-h/templ"
import templruntime "github.com/a-h/templ/runtime"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/page"
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/web/components"
func ContentPage(content page.Page) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
if templ_7745c5c3_Var1 == nil {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h2 class=\"flex items-center justify-center h-full text-3xl font-bold text-shadow-lg\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var3 string
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(content.Title)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/page.templ`, Line: 9, Col: 18}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</h2>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
templ_7745c5c3_Err = components.ImageTile("/images/"+content.PageHeader.CoverImageUID).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templ.Raw(content.Content).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</section></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
var _ = templruntime.GeneratedTemplate
+22
View File
@@ -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
}
+58
View File
@@ -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;
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.
Binary file not shown.
+1
View File
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More