diff --git a/go.mod b/go.mod index f9aa702..5c72624 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( require ( github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/alicebob/miniredis/v2 v2.36.1 // 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 @@ -20,6 +21,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 0ef4549..4381f14 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ6 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= @@ -33,6 +35,8 @@ github.com/studio-b12/gowebdav v0.12.0 h1:kFRtQECt8jmVAvA6RHBz3geXUGJHUZA6/IKpOV 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/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= diff --git a/internal/components/page/resource.go b/internal/components/page/resource.go index 6d04b0e..9c9780e 100644 --- a/internal/components/page/resource.go +++ b/internal/components/page/resource.go @@ -1,8 +1,6 @@ package page import ( - "log/slog" - "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/filesystem" ) @@ -11,12 +9,6 @@ type Service struct { } func New(fs filesystem.FS) *Service { - info, err := fs.ReadDir("/") - if err != nil { - slog.Error("could not read dir", "err", err) - } - - slog.Info("file info", "info", info) return &Service{ fs: fs, } diff --git a/internal/filesystem/cache.go b/internal/filesystem/cache.go index 8f5bc96..0d24a7a 100644 --- a/internal/filesystem/cache.go +++ b/internal/filesystem/cache.go @@ -2,21 +2,20 @@ package filesystem import ( "context" - "encoding/json" - "fmt" "log/slog" - "os" "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 client valkey.Client - refreshTime time.Duration + ttl time.Duration hits atomic.Int64 misses atomic.Int64 readDirHits atomic.Int64 @@ -24,139 +23,48 @@ type CachedClient struct { } // NewCachedClient creates a new CachedClient. -func NewCachedClient(impl FS, client valkey.Client, ttl time.Duration) *CachedClient { - return &CachedClient{ - impl: impl, - client: client, - refreshTime: ttl, +func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient { + c := &CachedClient{ + impl: impl, + client: client, + ttl: refreshtime * 5, } + + go func() { + t := time.NewTicker(refreshtime) + for { + <-t.C + c.revalidate(context.Background()) + } + }() + + return c } -func (c *CachedClient) ReadDir(path string) ([]os.FileInfo, error) { - key := fmt.Sprintf("webdav:readdir:%s", path) - ctx := context.Background() +func (c *CachedClient) revalidate(ctx context.Context) { + go c.revalidateReadDir(ctx) + go c.revalidateRead(ctx) + go c.revalidateStat(ctx) +} - // Try cache - val, err := c.client.Do(ctx, c.client.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 +func (c *CachedClient) scanAndProcess(ctx context.Context, match string, process func(key string) error) { + cursor := uint64(0) + for { + res, err := c.client.Do(ctx, c.client.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) } - return infos, nil + } + + cursor = res.Cursor + if cursor == 0 { + break } } - - // 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 { - // Type assert to ensure we capture ContentType and Path - if fi, ok := info.(FileInfo); ok { - cached = append(cached, 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 - cached = append(cached, File{ - name: info.Name(), - size: info.Size(), - mode: info.Mode(), - modTime: info.ModTime(), - isDir: info.IsDir(), - }) - } - } - - bytes, err := json.Marshal(cached) - if err == nil { - c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.refreshTime).Build()) - } - - return infos, nil -} - -func (c *CachedClient) Read(path string) ([]byte, error) { - key := fmt.Sprintf("webdav:read:%s", path) - ctx := context.Background() - - // Try cache - val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).AsBytes() - if err == nil && len(val) > 0 { - c.readHits.Add(1) - c.hits.Add(1) - slog.Debug("cache hit", "key", key) - return val, 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 - } - - // Cache - c.client.Do(ctx, c.client.B().Set().Key(key).Value(valkey.BinaryString(data)).Ex(c.refreshTime).Build()) - - return data, nil -} - -func (c *CachedClient) Stat(path string) (info os.FileInfo, err error) { - key := fmt.Sprintf("webdav:stat:%s", path) - ctx := context.Background() - - // Try cache - val, err := c.client.Do(ctx, c.client.B().Get().Key(key).Build()).ToString() - if err == nil && val != "" { - if err := json.Unmarshal([]byte(val), &info); err == nil { - c.hits.Add(1) - slog.Debug("cache hit", "key", key) - - return info, 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.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.refreshTime).Build()) - } - - return -} - -func (c *CachedClient) Invalidate(path string) { - ctx := context.Background() - c.client.Do(ctx, c.client.B().Del().Key(fmt.Sprintf("webdav:readdir:%s", path)).Key(fmt.Sprintf("webdav:read:%s", path)).Build()) } diff --git a/internal/filesystem/cached_client_test.go b/internal/filesystem/cached_client_test.go new file mode 100644 index 0000000..2a22a57 --- /dev/null +++ b/internal/filesystem/cached_client_test.go @@ -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()) + } +} + diff --git a/internal/filesystem/read.go b/internal/filesystem/read.go new file mode 100644 index 0000000..bd8480d --- /dev/null +++ b/internal/filesystem/read.go @@ -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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.B().Del().Key(key).Build()) + return err + } + + info, err := c.impl.Stat(path) + if err != nil { + c.client.Do(ctx, c.client.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.client.Do(ctx, c.client.B().Set().Key(key).Value(valkey.BinaryString(newCached)).Ex(c.ttl).Build()) + } else { + c.client.Do(ctx, c.client.B().Expire().Key(key).Seconds(int64(c.ttl/time.Second)).Build()) + } + return nil + }) + slog.Debug("revalidation of read complete", "duration", time.Since(startTime)) +} diff --git a/internal/filesystem/readdir.go b/internal/filesystem/readdir.go new file mode 100644 index 0000000..6e7c3d9 --- /dev/null +++ b/internal/filesystem/readdir.go @@ -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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) + } + return nil + }) + slog.Debug("revalidation of readDir complete", "duration", time.Since(startTime)) +} diff --git a/internal/filesystem/stat.go b/internal/filesystem/stat.go new file mode 100644 index 0000000..fd07db1 --- /dev/null +++ b/internal/filesystem/stat.go @@ -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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.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.client.Do(ctx, c.client.B().Del().Key(key).Build()) + return err + } + + // Serialize and cache + f := ParseToFile(info) + bytes, err := json.Marshal(f) + if err == nil { + c.client.Do(ctx, c.client.B().Set().Key(key).Value(string(bytes)).Ex(c.ttl).Build()) + } + return nil + }) + slog.Debug("revalidation of Stat complete", "duration", time.Since(startTime)) +} diff --git a/internal/server/routes.go b/internal/server/routes.go index 20228c8..197f221 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -51,7 +51,7 @@ func registerOther() (h http.Handler) { if err != nil { slog.Error("failed to create valkey client", "err", err) } else { - fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Minute) + fs = filesystem.NewCachedClient(webdavClient, valkeyClient, 5*time.Hour) } _ = handlehome.New(mux, page.New(fs)) diff --git a/tmp/build-errors.log b/tmp/build-errors.log index a85e130..1fa1b86 100644 --- a/tmp/build-errors.log +++ b/tmp/build-errors.log @@ -1 +1 @@ -exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file +exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1exit status 1 \ No newline at end of file diff --git a/tmp/main b/tmp/main index 09166af..1b46103 100755 Binary files a/tmp/main and b/tmp/main differ diff --git a/web/static/css/output.css b/web/static/css/output.css index d830c3b..f273bb9 100644 --- a/web/static/css/output.css +++ b/web/static/css/output.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.fixed{position:fixed}.static{position:static}.z-50{z-index:50}.col-start-1{grid-column-start:1}.row-start-1{grid-row-start:1}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);margin-top:1.2em;margin-bottom:1.2em;font-size:1.25em;line-height:1.6}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:decimal}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:disc}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.25em;font-weight:600}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em;font-style:italic;font-weight:500}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:0;margin-bottom:.888889em;font-size:2.25em;font-weight:800;line-height:1.11111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:2em;margin-bottom:1em;font-size:1.5em;font-weight:700;line-height:1.33333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.6em;margin-bottom:.6em;font-size:1.25em;font-weight:600;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.5em;margin-bottom:.5em;font-weight:600;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em;display:block}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;border-radius:.3125rem;padding-inline-start:.375em;font-family:inherit;font-size:.875em;font-weight:500}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);padding-top:.857143em;padding-inline-end:1.14286em;padding-bottom:.857143em;border-radius:.375rem;margin-top:1.71429em;margin-bottom:1.71429em;padding-inline-start:1.14286em;font-size:.875em;font-weight:400;line-height:1.71429;overflow-x:auto}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit;background-color:#0000;border-width:0;border-radius:0;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){table-layout:auto;width:100%;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.71429}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);vertical-align:bottom;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em;font-weight:600}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);margin-top:.857143em;font-size:.875em;line-height:1.42857}.prose{--tw-prose-body:oklch(37.3% .034 259.733);--tw-prose-headings:oklch(21% .034 264.665);--tw-prose-lead:oklch(44.6% .03 256.802);--tw-prose-links:oklch(21% .034 264.665);--tw-prose-bold:oklch(21% .034 264.665);--tw-prose-counters:oklch(55.1% .027 264.364);--tw-prose-bullets:oklch(87.2% .01 258.338);--tw-prose-hr:oklch(92.8% .006 264.531);--tw-prose-quotes:oklch(21% .034 264.665);--tw-prose-quote-borders:oklch(92.8% .006 264.531);--tw-prose-captions:oklch(55.1% .027 264.364);--tw-prose-kbd:oklch(21% .034 264.665);--tw-prose-kbd-shadows:oklab(21% -.00316127 -.0338527/.1);--tw-prose-code:oklch(21% .034 264.665);--tw-prose-pre-code:oklch(92.8% .006 264.531);--tw-prose-pre-bg:oklch(27.8% .033 256.848);--tw-prose-th-borders:oklch(87.2% .01 258.338);--tw-prose-td-borders:oklch(92.8% .006 264.531);--tw-prose-invert-body:oklch(87.2% .01 258.338);--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:oklch(70.7% .022 261.325);--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:oklch(70.7% .022 261.325);--tw-prose-invert-bullets:oklch(44.6% .03 256.802);--tw-prose-invert-hr:oklch(37.3% .034 259.733);--tw-prose-invert-quotes:oklch(96.7% .003 264.542);--tw-prose-invert-quote-borders:oklch(37.3% .034 259.733);--tw-prose-invert-captions:oklch(70.7% .022 261.325);--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:#ffffff1a;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:oklch(87.2% .01 258.338);--tw-prose-invert-pre-bg:#00000080;--tw-prose-invert-th-borders:oklch(44.6% .03 256.802);--tw-prose-invert-td-borders:oklch(37.3% .034 259.733);font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.571429em;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-96{height:calc(var(--spacing)*96)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-1{grid-template-rows:repeat(1,minmax(0,1fr))}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing)*2)}.bg-black{background-color:var(--color-black)}.object-cover{object-fit:cover}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.px-12{padding-inline:calc(var(--spacing)*12)}.pt-8{padding-top:calc(var(--spacing)*8)}.text-justify{text-align:justify}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.mix-blend-difference{mix-blend-mode:difference}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--tw-prose-invert-pre-bg);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.text-shadow-lg{text-shadow:var(--text-shadow-lg)}@media (hover:hover){.hover\:underline:hover{text-decoration-line:underline}}}@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+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,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+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-text-shadow-color{syntax:"*";inherits:false}@property --tw-text-shadow-alpha{syntax:"";inherits:false;initial-value:100%} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{position:fixed}.static{position:static}.z-50{z-index:50}.col-start-1{grid-column-start:1}.row-start-1{grid-row-start:1}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);margin-top:1.2em;margin-bottom:1.2em;font-size:1.25em;line-height:1.6}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:decimal}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:disc}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.25em;font-weight:600}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em;font-style:italic;font-weight:500}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:0;margin-bottom:.888889em;font-size:2.25em;font-weight:800;line-height:1.11111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:2em;margin-bottom:1em;font-size:1.5em;font-weight:700;line-height:1.33333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.6em;margin-bottom:.6em;font-size:1.25em;font-weight:600;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.5em;margin-bottom:.5em;font-weight:600;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em;display:block}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows),0 3px 0 var(--tw-prose-kbd-shadows);padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;border-radius:.3125rem;padding-inline-start:.375em;font-family:inherit;font-size:.875em;font-weight:500}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);padding-top:.857143em;padding-inline-end:1.14286em;padding-bottom:.857143em;border-radius:.375rem;margin-top:1.71429em;margin-bottom:1.71429em;padding-inline-start:1.14286em;font-size:.875em;font-weight:400;line-height:1.71429;overflow-x:auto}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit;background-color:#0000;border-width:0;border-radius:0;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){table-layout:auto;width:100%;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.71429}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);vertical-align:bottom;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em;font-weight:600}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);margin-top:.857143em;font-size:.875em;line-height:1.42857}.prose{--tw-prose-body:oklch(37.3% .034 259.733);--tw-prose-headings:oklch(21% .034 264.665);--tw-prose-lead:oklch(44.6% .03 256.802);--tw-prose-links:oklch(21% .034 264.665);--tw-prose-bold:oklch(21% .034 264.665);--tw-prose-counters:oklch(55.1% .027 264.364);--tw-prose-bullets:oklch(87.2% .01 258.338);--tw-prose-hr:oklch(92.8% .006 264.531);--tw-prose-quotes:oklch(21% .034 264.665);--tw-prose-quote-borders:oklch(92.8% .006 264.531);--tw-prose-captions:oklch(55.1% .027 264.364);--tw-prose-kbd:oklch(21% .034 264.665);--tw-prose-kbd-shadows:oklab(21% -.00316127 -.0338527/.1);--tw-prose-code:oklch(21% .034 264.665);--tw-prose-pre-code:oklch(92.8% .006 264.531);--tw-prose-pre-bg:oklch(27.8% .033 256.848);--tw-prose-th-borders:oklch(87.2% .01 258.338);--tw-prose-td-borders:oklch(92.8% .006 264.531);--tw-prose-invert-body:oklch(87.2% .01 258.338);--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:oklch(70.7% .022 261.325);--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:oklch(70.7% .022 261.325);--tw-prose-invert-bullets:oklch(44.6% .03 256.802);--tw-prose-invert-hr:oklch(37.3% .034 259.733);--tw-prose-invert-quotes:oklch(96.7% .003 264.542);--tw-prose-invert-quote-borders:oklch(37.3% .034 259.733);--tw-prose-invert-captions:oklch(70.7% .022 261.325);--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:#ffffff1a;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:oklch(87.2% .01 258.338);--tw-prose-invert-pre-bg:#00000080;--tw-prose-invert-th-borders:oklch(44.6% .03 256.802);--tw-prose-invert-td-borders:oklch(37.3% .034 259.733);font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.571429em;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-96{height:calc(var(--spacing)*96)}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-full{width:100%}.max-w-6xl{max-width:var(--container-6xl)}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-rows-1{grid-template-rows:repeat(1,minmax(0,1fr))}.items-center{align-items:center}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing)*2)}.bg-black{background-color:var(--color-black)}.object-cover{object-fit:cover}.p-1{padding:calc(var(--spacing)*1)}.p-2{padding:calc(var(--spacing)*2)}.px-12{padding-inline:calc(var(--spacing)*12)}.pt-8{padding-top:calc(var(--spacing)*8)}.text-justify{text-align:justify}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.text-white{color:var(--color-white)}.uppercase{text-transform:uppercase}.mix-blend-difference{mix-blend-mode:difference}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.prose-invert{--tw-prose-body:var(--tw-prose-invert-body);--tw-prose-headings:var(--tw-prose-invert-headings);--tw-prose-lead:var(--tw-prose-invert-lead);--tw-prose-links:var(--tw-prose-invert-links);--tw-prose-bold:var(--tw-prose-invert-bold);--tw-prose-counters:var(--tw-prose-invert-counters);--tw-prose-bullets:var(--tw-prose-invert-bullets);--tw-prose-hr:var(--tw-prose-invert-hr);--tw-prose-quotes:var(--tw-prose-invert-quotes);--tw-prose-quote-borders:var(--tw-prose-invert-quote-borders);--tw-prose-captions:var(--tw-prose-invert-captions);--tw-prose-kbd:var(--tw-prose-invert-kbd);--tw-prose-kbd-shadows:var(--tw-prose-invert-kbd-shadows);--tw-prose-code:var(--tw-prose-invert-code);--tw-prose-pre-code:var(--tw-prose-invert-pre-code);--tw-prose-pre-bg:var(--tw-prose-invert-pre-bg);--tw-prose-th-borders:var(--tw-prose-invert-th-borders);--tw-prose-td-borders:var(--tw-prose-invert-td-borders)}.text-shadow-lg{text-shadow:var(--text-shadow-lg)}@media (hover:hover){.hover\:underline:hover{text-decoration-line:underline}}}@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+100-2BA,U+2BD-2C5,U+2C7-2CC,U+2CE-2D7,U+2DD-2FF,U+304,U+308,U+329,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+??,U+131,U+152-153,U+2BB-2BC,U+2C6,U+2DA,U+2DC,U+304,U+308,U+329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-text-shadow-color{syntax:"*";inherits:false}@property --tw-text-shadow-alpha{syntax:"";inherits:false;initial-value:100%} \ No newline at end of file