Browse Source

initial commit of new homepage

master
Silberengel 4 weeks ago
parent
commit
86229a86a4
  1. 84
      .gitignore
  2. 5
      .go-path/pkg/sumdb/sum.golang.org/latest
  3. 102
      README.md
  4. 102
      cmd/server/main.go
  5. 20
      config.yaml.example
  6. 32
      go.mod
  7. 155
      go.sum
  8. 187
      internal/asciidoc/processor.go
  9. 103
      internal/config/config.go
  10. 331
      internal/generator/html.go
  11. 70
      internal/generator/seo.go
  12. 34
      internal/generator/sitemap.go
  13. 152
      internal/nostr/client.go
  14. 210
      internal/nostr/events.go
  15. 55
      internal/nostr/feed.go
  16. 195
      internal/nostr/issues.go
  17. 71
      internal/nostr/naddr.go
  18. 107
      internal/nostr/wiki.go
  19. 270
      internal/server/handlers.go
  20. 92
      internal/server/server.go
  21. 133
      package-lock.json
  22. 5
      package.json
  23. 0
      static/GitCitadel_Graphic_Landscape.png
  24. 0
      static/GitCitadel_Graphic_Portrait.png
  25. 0
      static/GitCitadel_Icon_Gradient.svg
  26. 0
      static/GitCitadel_PFP.png
  27. 611
      static/css/main.css
  28. 58
      static/css/print.css
  29. 119
      static/css/responsive.css
  30. 29
      templates/404.html
  31. 29
      templates/500.html
  32. 92
      templates/base.html
  33. 50
      templates/blog.html
  34. 52
      templates/contact.html
  35. 19
      templates/feed_sidebar.html
  36. 47
      templates/landing.html
  37. 45
      templates/page.html

84
.gitignore vendored

@ -0,0 +1,84 @@
# Binaries
gitcitadel-online
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go build artifacts
/bin/
/dist/
/build/
# Go workspace file
go.work
go.work.sum
# User-specific config files
config.yaml
*.local.yaml
*.local.yml
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.html
coverage.txt
# Dependency directories
vendor/
# Go module cache (if using local cache)
.go-mod-cache/
.go-cache/
# Node.js
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm
# Note: package-lock.json should be committed for reproducible builds
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
*.sublime-project
*.sublime-workspace
# Logs
*.log
logs/
# Environment files
.env
.env.local
.env.*.local
# Temporary files
tmp/
temp/
*.tmp
# OS-specific
Thumbs.db
.DS_Store
.AppleDouble
.LSOverride
# Cache directories
.cache/
cache/
# Build artifacts
*.a
*.o

5
.go-path/pkg/sumdb/sum.golang.org/latest

@ -0,0 +1,5 @@
go.sum database tree
50144980
nD9Fj466sgN+5qITNI4UsGgIubMahhAoSzy1anczwi8=
— sum.golang.org Az3grqlGKF2Yc0/NkMjN7LHBaVRPaKlJ5uUCHoglziDyBPnI1eFnBBfKg04+o8e++xMZ7DrVMkgtXLG+FQz9RlZ82wE=

102
README.md

@ -1,3 +1,103 @@
# GitCitadel Online # GitCitadel Online
Repo containing the basic webpages for our [GitCitadel company](https://gitcitadel.com). A server-generated website that fetches kind 30818 wiki events from Nostr relays, processes AsciiDoc content, and serves professional HTML pages with caching.
## Features
- Fetches wiki content from Nostr relays (kind 30818 events)
- Processes AsciiDoc content to HTML
- Caches all pages for fast serving
- Background cache rewarming to keep content fresh
- Kind 1 feed integration in sidebar
- SEO optimized with structured data
- Responsive design with medium-dark theme
- WCAG AA/AAA compliant accessibility
- YAML configuration for easy index management
## Requirements
- Go 1.22+
- Node.js (for asciidoctor.js)
- @asciidoctor/core npm package
- Network access to Nostr relays
## Installation
1. Clone the repository
2. Install Go dependencies:
```bash
go mod tidy
```
3. Install Node.js dependencies:
```bash
npm install @asciidoctor/core
```
Or globally:
```bash
npm install -g @asciidoctor/core
```
4. Copy the example config:
```bash
cp config.yaml.example config.yaml
```
5. Edit `config.yaml` with your indices and settings
## Configuration
Edit `config.yaml` to set:
- `wiki_index`: naddr for your wiki index (kind 30040)
- `blog_index`: naddr for your blog index (kind 30040)
- Relay URLs
- Cache refresh intervals
- Server port
- SEO settings
## Running
```bash
go run cmd/server/main.go
```
Or build and run:
```bash
go build -o gitcitadel-online cmd/server/main.go
./gitcitadel-online
```
Development mode with verbose logging:
```bash
go run cmd/server/main.go --dev
```
## Project Structure
```
gitcitadel-online/
├── cmd/server/ # Main server application
├── internal/
│ ├── nostr/ # Nostr client and event parsing
│ ├── asciidoc/ # AsciiDoc processing
│ ├── generator/ # HTML generation
│ ├── cache/ # Caching layer
│ ├── server/ # HTTP server
│ └── config/ # Configuration management
├── templates/ # HTML templates
├── static/ # Static assets (CSS, images)
└── config.yaml # Configuration file
```
## API
The server provides:
- `/` - Landing page
- `/wiki/<d-tag>` - Wiki article pages
- `/blog` - Blog index page
- `/static/` - Static assets
- `/health` - Health check endpoint
- `/metrics` - Metrics endpoint
- `/sitemap.xml` - Sitemap
- `/robots.txt` - Robots.txt
## License
MIT License - see LICENSE.md

102
cmd/server/main.go

@ -0,0 +1,102 @@
package main
import (
"context"
"flag"
"log"
"time"
"gitcitadel-online/internal/cache"
"gitcitadel-online/internal/config"
"gitcitadel-online/internal/generator"
"gitcitadel-online/internal/nostr"
"gitcitadel-online/internal/server"
)
func main() {
configPath := flag.String("config", "config.yaml", "Path to configuration file")
devMode := flag.Bool("dev", false, "Enable development mode")
flag.Parse()
// Load configuration
cfg, err := config.LoadConfig(*configPath)
if err != nil {
log.Fatalf("Failed to load config: %v", err)
}
if err := cfg.Validate(); err != nil {
log.Fatalf("Invalid config: %v", err)
}
if *devMode {
log.Println("Development mode enabled")
}
// Initialize caches
pageCache := cache.NewCache()
feedCache := cache.NewFeedCache()
// Initialize Nostr client
nostrClient := nostr.NewClient(cfg.Relays.Primary, cfg.Relays.Fallback)
ctx := context.Background()
if err := nostrClient.Connect(ctx); err != nil {
log.Printf("Warning: Failed to connect to relays: %v", err)
}
// Initialize services
wikiService := nostr.NewWikiService(nostrClient)
feedService := nostr.NewFeedService(nostrClient)
issueService := nostr.NewIssueService(nostrClient)
// Initialize HTML generator
htmlGenerator, err := generator.NewHTMLGenerator(
"templates",
cfg.LinkBaseURL,
cfg.SEO.SiteName,
cfg.SEO.SiteURL,
cfg.SEO.DefaultImage,
)
if err != nil {
log.Fatalf("Failed to initialize HTML generator: %v", err)
}
// Initialize cache rewarming
rewarmer := cache.NewRewarmer(
pageCache,
feedCache,
wikiService,
feedService,
htmlGenerator,
cfg.WikiIndex,
cfg.BlogIndex,
cfg.Feed.Relay,
cfg.Feed.MaxEvents,
time.Duration(cfg.Cache.RefreshIntervalMinutes)*time.Minute,
time.Duration(cfg.Feed.PollIntervalMinutes)*time.Minute,
)
// Start cache rewarming
rewarmer.Start(ctx)
// Initialize HTTP server
httpServer := server.NewServer(cfg.Server.Port, pageCache, feedCache, issueService, cfg.RepoAnnouncement, htmlGenerator)
// Start server in goroutine
go func() {
if err := httpServer.Start(); err != nil {
log.Fatalf("Server failed: %v", err)
}
}()
log.Printf("Server started on port %d", cfg.Server.Port)
log.Println("Waiting for initial cache population...")
// Wait a bit for initial cache
time.Sleep(5 * time.Second)
// Wait for shutdown signal
httpServer.WaitForShutdown()
// Close Nostr client
nostrClient.Close()
}

20
config.yaml.example

@ -0,0 +1,20 @@
wiki_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyd8wumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6qgmwaehxw309a6xsetrd96xzer9dshxummnw3erztnrdakszyrhwden5te0dehhxarj9ekxzmnyqyg8wumn8ghj7mn0wd68ytnhd9hx2qghwaehxw309ahx7um5wgh8xmmkvf5hgtngdaehgqg3waehxw309ahx7um5wgerztnrdaksz9thwden5te0v9nkwu3wdehhxarj9ekxzmnyqyv8wumn8ghj7un9d3shjtnwdaehw6r9wfjjucm0d5q3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7qgewaehxw309an8yet9d3shjtnndamxy6t59e5x7um5qqhxw6t5vd5hgctyv4kz6urjda4x2cm594jx7cm4d4jkuarpw35k7m3dvfuj6um5v4kxccfdwcknzhekhth"
blog_index: "naddr1qvzqqqr4tqpzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqyvhwumn8ghj7enjv4jkccte9eek7anzd96zu6r0wd6qzxmhwden5te0w35x2cmfw3skgetv9ehx7um5wgcjucm0d5q35amnwvaz7tm5dpjkvmmjv4ehgtnwdaehgu339e3k7mgpzpmhxue69uhkummnw3ezumrpdejqzyrhwden5te0dehhxarj9emkjmn9qythwumn8ghj7mn0wd68ytnndamxy6t59e5x7um5qyghwumn8ghj7mn0wd68yv339e3k7mgqy96xsefdva5hgcmfw3skgetv943xcmm89438jttnw3jkcmrp94mz6vggpn2pq"
repo_announcement: "naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqq9xw6t5vd5hgctyv4kqde47kt"
relays:
primary: "wss://theforest.nostr1.com"
fallback: "wss://nostr.land"
link_base_url: "https://alexandria.gitcitadel.eu"
cache:
refresh_interval_minutes: 30
feed:
relay: "wss://theforest.nostr1.com"
poll_interval_minutes: 5
max_events: 30
server:
port: 8080
enable_compression: true
seo:
site_name: "GitCitadel"
site_url: "https://gitcitadel.com"
default_image: "/static/GitCitadel_Graphic_Landscape.png"

32
go.mod

@ -0,0 +1,32 @@
module gitcitadel-online
go 1.22.2
require (
github.com/btcsuite/btcd/btcutil v1.1.5
github.com/nbd-wtf/go-nostr v0.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/dgraph-io/ristretto v0.1.1 // indirect
github.com/dustin/go-humanize v1.0.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.0 // indirect
github.com/golang/glog v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect
golang.org/x/sys v0.8.0 // indirect
)

155
go.sum

@ -0,0 +1,155 @@
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA=
github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE=
github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U=
github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04=
github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A=
github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE=
github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8=
github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ=
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc=
github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA=
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg=
github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg=
github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY=
github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I=
github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc=
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc=
github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y=
github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218=
github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8=
github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I=
github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ=
github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ=
github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/nbd-wtf/go-nostr v0.27.0 h1:h6JmMMmfNcAORTL2kk/K3+U6Mju6rk/IjcHA/PMeOc8=
github.com/nbd-wtf/go-nostr v0.27.0/go.mod h1:bkffJI+x914sPQWum9ZRUn66D7NpDnAoWo1yICvj3/0=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
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/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=
github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o=
golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w=
golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

187
internal/asciidoc/processor.go

@ -0,0 +1,187 @@
package asciidoc
import (
"bytes"
"fmt"
"os/exec"
"regexp"
"strings"
)
// Processor handles AsciiDoc to HTML conversion
type Processor struct {
linkBaseURL string
}
// NewProcessor creates a new AsciiDoc processor
func NewProcessor(linkBaseURL string) *Processor {
return &Processor{
linkBaseURL: linkBaseURL,
}
}
// Process converts AsciiDoc content to HTML with link rewriting
func (p *Processor) Process(asciidocContent string) (string, error) {
// First, rewrite links in the AsciiDoc content
processedContent := p.rewriteLinks(asciidocContent)
// Convert AsciiDoc to HTML using asciidoctor CLI
html, err := p.convertToHTML(processedContent)
if err != nil {
return "", fmt.Errorf("failed to convert AsciiDoc to HTML: %w", err)
}
// Sanitize HTML to prevent XSS
sanitized := p.sanitizeHTML(html)
return sanitized, nil
}
// rewriteLinks rewrites wikilinks and nostr: links in AsciiDoc content
func (p *Processor) rewriteLinks(content string) string {
// Rewrite wikilinks: [[target]] or [[target|display text]]
// Format: [[target]] -> https://alexandria.gitcitadel.eu/events?d=<normalized-d-tag>
wikilinkRegex := regexp.MustCompile(`\[\[([^\]]+)\]\]`)
content = wikilinkRegex.ReplaceAllStringFunc(content, func(match string) string {
// Extract the content inside [[ ]]
inner := match[2 : len(match)-2]
var target, display string
if strings.Contains(inner, "|") {
parts := strings.SplitN(inner, "|", 2)
target = strings.TrimSpace(parts[0])
display = strings.TrimSpace(parts[1])
} else {
target = strings.TrimSpace(inner)
display = target
}
// Normalize the d tag (convert to lowercase, replace spaces with hyphens, etc.)
normalized := normalizeDTag(target)
// Create the link
url := fmt.Sprintf("%s/events?d=%s", p.linkBaseURL, normalized)
return fmt.Sprintf("link:%s[%s]", url, display)
})
// Rewrite nostr: links: nostr:naddr1... or nostr:nevent1...
// Format: nostr:naddr1... -> https://alexandria.gitcitadel.eu/events?id=naddr1...
nostrLinkRegex := regexp.MustCompile(`nostr:(naddr1[^\s\]]+|nevent1[^\s\]]+)`)
content = nostrLinkRegex.ReplaceAllStringFunc(content, func(match string) string {
nostrID := strings.TrimPrefix(match, "nostr:")
url := fmt.Sprintf("%s/events?id=%s", p.linkBaseURL, nostrID)
return url
})
return content
}
// normalizeDTag normalizes a d tag according to NIP-54 rules
func normalizeDTag(dTag string) string {
// Convert to lowercase
dTag = strings.ToLower(dTag)
// Convert whitespace to hyphens
dTag = strings.ReplaceAll(dTag, " ", "-")
dTag = strings.ReplaceAll(dTag, "\t", "-")
dTag = strings.ReplaceAll(dTag, "\n", "-")
// Remove punctuation and symbols (keep alphanumeric, hyphens, and non-ASCII)
var result strings.Builder
for _, r := range dTag {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r > 127 {
result.WriteRune(r)
}
}
dTag = result.String()
// Collapse multiple consecutive hyphens
for strings.Contains(dTag, "--") {
dTag = strings.ReplaceAll(dTag, "--", "-")
}
// Remove leading and trailing hyphens
dTag = strings.Trim(dTag, "-")
return dTag
}
// convertToHTML converts AsciiDoc to HTML using asciidoctor.js via Node.js
func (p *Processor) convertToHTML(asciidocContent string) (string, error) {
// Check if node is available
cmd := exec.Command("node", "--version")
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("node.js not found: %w", err)
}
// JavaScript code to run asciidoctor.js
// Read content from stdin to handle special characters properly
jsCode := `
const asciidoctor = require('@asciidoctor/core')();
let content = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
content += chunk;
});
process.stdin.on('end', () => {
try {
const html = asciidoctor.convert(content, {
safe: 'safe',
backend: 'html5',
doctype: 'article',
attributes: {
'showtitle': true,
'icons': 'font',
'sectanchors': true,
'sectlinks': true,
'toc': 'left',
'toclevels': 3
}
});
process.stdout.write(html);
} catch (error) {
console.error('Error converting AsciiDoc:', error.message);
process.exit(1);
}
});
`
// Run node with the JavaScript code, passing content via stdin
cmd = exec.Command("node", "-e", jsCode)
cmd.Stdin = strings.NewReader(asciidocContent)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("asciidoctor.js conversion failed: %w, stderr: %s", err, stderr.String())
}
return stdout.String(), nil
}
// sanitizeHTML performs basic HTML sanitization to prevent XSS
// Note: This is a basic implementation. For production, consider using a proper HTML sanitizer library
func (p *Processor) sanitizeHTML(html string) string {
// Remove script tags and their content
scriptRegex := regexp.MustCompile(`(?i)<script[^>]*>.*?</script>`)
html = scriptRegex.ReplaceAllString(html, "")
// Remove event handlers (onclick, onerror, etc.)
eventHandlerRegex := regexp.MustCompile(`(?i)\s*on\w+\s*=\s*["'][^"']*["']`)
html = eventHandlerRegex.ReplaceAllString(html, "")
// Remove javascript: protocol in links
javascriptRegex := regexp.MustCompile(`(?i)javascript:`)
html = javascriptRegex.ReplaceAllString(html, "")
// Remove data: URLs that could be dangerous
dataURLRegex := regexp.MustCompile(`(?i)data:\s*text/html`)
html = dataURLRegex.ReplaceAllString(html, "")
return html
}

103
internal/config/config.go

@ -0,0 +1,103 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// Config represents the application configuration
type Config struct {
WikiIndex string `yaml:"wiki_index"`
BlogIndex string `yaml:"blog_index"`
RepoAnnouncement string `yaml:"repo_announcement"` // naddr for kind 30617 repo announcement
Relays struct {
Primary string `yaml:"primary"`
Fallback string `yaml:"fallback"`
} `yaml:"relays"`
LinkBaseURL string `yaml:"link_base_url"`
Cache struct {
RefreshIntervalMinutes int `yaml:"refresh_interval_minutes"`
} `yaml:"cache"`
Feed struct {
Relay string `yaml:"relay"`
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
MaxEvents int `yaml:"max_events"`
} `yaml:"feed"`
Server struct {
Port int `yaml:"port"`
EnableCompression bool `yaml:"enable_compression"`
} `yaml:"server"`
SEO struct {
SiteName string `yaml:"site_name"`
SiteURL string `yaml:"site_url"`
DefaultImage string `yaml:"default_image"`
} `yaml:"seo"`
}
// LoadConfig loads configuration from a YAML file
func LoadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var config Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Set defaults
if config.Relays.Primary == "" {
config.Relays.Primary = "wss://theforest.nostr1.com"
}
if config.Relays.Fallback == "" {
config.Relays.Fallback = "wss://nostr.land"
}
if config.LinkBaseURL == "" {
config.LinkBaseURL = "https://alexandria.gitcitadel.eu"
}
if config.Cache.RefreshIntervalMinutes == 0 {
config.Cache.RefreshIntervalMinutes = 30
}
if config.Feed.Relay == "" {
config.Feed.Relay = "wss://theforest.nostr1.com"
}
if config.Feed.PollIntervalMinutes == 0 {
config.Feed.PollIntervalMinutes = 5
}
if config.Feed.MaxEvents == 0 {
config.Feed.MaxEvents = 30
}
if config.Server.Port == 0 {
config.Server.Port = 8080
}
if config.SEO.SiteName == "" {
config.SEO.SiteName = "GitCitadel"
}
if config.SEO.SiteURL == "" {
config.SEO.SiteURL = "https://gitcitadel.com"
}
if config.SEO.DefaultImage == "" {
config.SEO.DefaultImage = "/static/GitCitadel_Graphic_Landscape.png"
}
// Validate required fields
if config.WikiIndex == "" {
return nil, fmt.Errorf("wiki_index is required")
}
return &config, nil
}
// Validate validates the configuration
func (c *Config) Validate() error {
if c.WikiIndex == "" {
return fmt.Errorf("wiki_index is required")
}
if c.Relays.Primary == "" {
return fmt.Errorf("relays.primary is required")
}
return nil
}

331
internal/generator/html.go

@ -0,0 +1,331 @@
package generator
import (
"bytes"
"fmt"
"html/template"
"path/filepath"
"time"
"gitcitadel-online/internal/asciidoc"
"gitcitadel-online/internal/nostr"
)
// HTMLGenerator generates HTML pages from wiki events
type HTMLGenerator struct {
templates *template.Template
asciidocProc *asciidoc.Processor
linkBaseURL string
siteName string
siteURL string
defaultImage string
}
// PageData represents data for a wiki page
type PageData struct {
Title string
Description string
Keywords string
CanonicalURL string
OGImage string
OGType string
StructuredData template.JS
SiteName string
CurrentYear int
WikiPages []WikiPageInfo
BlogItems []BlogItemInfo
FeedItems []FeedItemInfo
Content template.HTML
Summary string
TableOfContents template.HTML
}
// WikiPageInfo represents info about a wiki page for navigations
type WikiPageInfo struct {
DTag string
Title string
}
// BlogItemInfo represents info about a blog item
type BlogItemInfo struct {
DTag string
Title string
Summary string
Content template.HTML
}
// FeedItemInfo represents info about a feed item
type FeedItemInfo struct {
Author string
Content string
Time string
TimeISO string
Link string
}
// NewHTMLGenerator creates a new HTML generator
func NewHTMLGenerator(templateDir string, linkBaseURL, siteName, siteURL, defaultImage string) (*HTMLGenerator, error) {
tmpl := template.New("base").Funcs(template.FuncMap{
"year": func() int { return time.Now().Year() },
})
// Load all templates
templateFiles := []string{
"base.html",
"landing.html",
"page.html",
"blog.html",
"feed_sidebar.html",
"404.html",
"500.html",
}
for _, file := range templateFiles {
path := filepath.Join(templateDir, file)
_, err := tmpl.ParseFiles(path)
if err != nil {
return nil, err
}
}
return &HTMLGenerator{
templates: tmpl,
asciidocProc: asciidoc.NewProcessor(linkBaseURL),
linkBaseURL: linkBaseURL,
siteName: siteName,
siteURL: siteURL,
defaultImage: defaultImage,
}, nil
}
// ProcessAsciiDoc processes AsciiDoc content to HTML
func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) {
return g.asciidocProc.Process(content)
}
// GenerateLandingPage generates the static landing page
func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) {
data := PageData{
Title: "Home",
Description: "Welcome to " + g.siteName,
CanonicalURL: g.siteURL + "/",
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
CurrentYear: time.Now().Year(),
WikiPages: wikiPages,
FeedItems: feedItems,
}
return g.renderTemplate("landing.html", data)
}
// GenerateWikiPage generates a wiki article page
func (g *HTMLGenerator) GenerateWikiPage(wiki *nostr.WikiEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) {
// Process AsciiDoc content
htmlContent, err := g.asciidocProc.Process(wiki.Content)
if err != nil {
return "", err
}
description := wiki.Summary
if description == "" {
description = wiki.Title
}
canonicalURL := g.siteURL + "/wiki/" + wiki.DTag
data := PageData{
Title: wiki.Title,
Description: description,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
OGType: "article",
SiteName: g.siteName,
CurrentYear: time.Now().Year(),
WikiPages: wikiPages,
FeedItems: feedItems,
Content: template.HTML(htmlContent),
Summary: wiki.Summary,
}
// Add structured data for article
data.StructuredData = template.JS(g.generateArticleStructuredData(wiki, canonicalURL))
return g.renderTemplate("page.html", data)
}
// GenerateBlogPage generates the blog index page
func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems []BlogItemInfo, feedItems []FeedItemInfo) (string, error) {
description := blogIndex.Summary
if description == "" {
description = "Blog articles from " + g.siteName
}
canonicalURL := g.siteURL + "/blog"
data := PageData{
Title: "Blog",
Description: description,
CanonicalURL: canonicalURL,
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
CurrentYear: time.Now().Year(),
BlogItems: blogItems,
FeedItems: feedItems,
}
return g.renderTemplate("blog.html", data)
}
// GenerateContactPage generates the contact form page
func (g *HTMLGenerator) GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string) (string, error) {
// Prepare form data with defaults
subject := ""
content := ""
labels := ""
if formData != nil {
subject = formData["subject"]
content = formData["content"]
labels = formData["labels"]
}
// Create form data struct for template
type ContactFormData struct {
Subject string
Content string
Labels string
}
type ContactPageData struct {
PageData
Success bool
Error string
EventID string
FormData ContactFormData
}
data := ContactPageData{
PageData: PageData{
Title: "Contact",
Description: "Contact " + g.siteName,
CanonicalURL: g.siteURL + "/contact",
OGImage: g.siteURL + g.defaultImage,
OGType: "website",
SiteName: g.siteName,
CurrentYear: time.Now().Year(),
WikiPages: []WikiPageInfo{}, // Will be populated if needed
FeedItems: []FeedItemInfo{}, // Will be populated if needed
},
Success: success,
Error: errorMsg,
EventID: eventID,
FormData: ContactFormData{
Subject: subject,
Content: content,
Labels: labels,
},
}
// Render contact template with base template
// The base template uses {{block "content" .}} which will be filled by contact.html
// We need to pass the full ContactPageData so the template can access all fields
baseTmpl := g.templates.Lookup("base.html")
if baseTmpl == nil {
return "", fmt.Errorf("template base.html not found")
}
// Create a map that includes both PageData fields and contact-specific fields
templateData := map[string]interface{}{
"Title": data.Title,
"Description": data.Description,
"CanonicalURL": data.CanonicalURL,
"OGImage": data.OGImage,
"OGType": data.OGType,
"SiteName": data.SiteName,
"CurrentYear": data.CurrentYear,
"WikiPages": data.WikiPages,
"BlogItems": data.BlogItems,
"FeedItems": data.FeedItems,
"Success": data.Success,
"Error": data.Error,
"EventID": data.EventID,
"FormData": data.FormData,
}
// Execute the base template, which will use the contact.html "content" block
var buf bytes.Buffer
if err := baseTmpl.ExecuteTemplate(&buf, "base.html", templateData); err != nil {
return "", fmt.Errorf("failed to execute base template: %w", err)
}
return buf.String(), nil
}
// GenerateErrorPage generates an error page (404 or 500)
func (g *HTMLGenerator) GenerateErrorPage(statusCode int, siteName string) (string, error) {
var title, message string
switch statusCode {
case 404:
title = "404 - Page Not Found"
message = "The page you're looking for doesn't exist."
case 500:
title = "500 - Server Error"
message = "Something went wrong on our end. Please try again later."
default:
title = "Error"
message = "An error occurred."
}
data := map[string]interface{}{
"SiteName": siteName,
"Title": title,
"Message": message,
}
tmpl := g.templates.Lookup(fmt.Sprintf("%d.html", statusCode))
if tmpl == nil {
return "", fmt.Errorf("template for status %d not found", statusCode)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// renderTemplate renders a template with the base template
func (g *HTMLGenerator) renderTemplate(templateName string, data PageData) (string, error) {
// The templateName (e.g., "landing.html") defines blocks that are used by base.html
// We need to execute base.html, which will use the blocks from templateName
baseTmpl := g.templates.Lookup("base.html")
if baseTmpl == nil {
return "", fmt.Errorf("template base.html not found")
}
var buf bytes.Buffer
if err := baseTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil {
return "", fmt.Errorf("failed to execute base template: %w", err)
}
return buf.String(), nil
}
// generateArticleStructuredData generates JSON-LD structured data for an article
func (g *HTMLGenerator) generateArticleStructuredData(wiki *nostr.WikiEvent, url string) string {
// This is a simplified version - in production, use proper JSON encoding
return `{
"@context": "https://schema.org",
"@type": "Article",
"headline": "` + wiki.Title + `",
"description": "` + wiki.Summary + `",
"url": "` + url + `",
"publisher": {
"@type": "Organization",
"name": "` + g.siteName + `"
}
}`
}

70
internal/generator/seo.go

@ -0,0 +1,70 @@
package generator
import (
"encoding/json"
)
// GenerateStructuredData generates JSON-LD structured data
func GenerateStructuredData(siteName, siteURL, pageType, title, description, url string) string {
var data map[string]interface{}
switch pageType {
case "article":
data = map[string]interface{}{
"@context": "https://schema.org",
"@type": "Article",
"headline": title,
"description": description,
"url": url,
"publisher": map[string]interface{}{
"@type": "Organization",
"name": siteName,
},
}
case "website":
data = map[string]interface{}{
"@context": "https://schema.org",
"@type": "WebSite",
"name": siteName,
"url": siteURL,
}
default:
data = map[string]interface{}{
"@context": "https://schema.org",
"@type": "WebPage",
"name": title,
"url": url,
}
}
jsonData, _ := json.Marshal(data)
return string(jsonData)
}
// GenerateBreadcrumbStructuredData generates breadcrumb structured data
func GenerateBreadcrumbStructuredData(items []BreadcrumbItem, siteURL string) string {
breadcrumbList := make([]map[string]interface{}, len(items))
for i, item := range items {
breadcrumbList[i] = map[string]interface{}{
"@type": "ListItem",
"position": i + 1,
"name": item.Name,
"item": siteURL + item.URL,
}
}
data := map[string]interface{}{
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": breadcrumbList,
}
jsonData, _ := json.Marshal(data)
return string(jsonData)
}
// BreadcrumbItem represents a breadcrumb item
type BreadcrumbItem struct {
Name string
URL string
}

34
internal/generator/sitemap.go

@ -0,0 +1,34 @@
package generator
import (
"fmt"
"time"
)
// GenerateSitemap generates a sitemap.xml
func GenerateSitemap(urls []SitemapURL, siteURL string) string {
sitemap := `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
`
for _, url := range urls {
sitemap += fmt.Sprintf(` <url>
<loc>%s%s</loc>
<lastmod>%s</lastmod>
<changefreq>%s</changefreq>
<priority>%.1f</priority>
</url>
`, siteURL, url.Path, url.LastMod.Format(time.RFC3339), url.ChangeFreq, url.Priority)
}
sitemap += `</urlset>`
return sitemap
}
// SitemapURL represents a URL in the sitemap
type SitemapURL struct {
Path string
LastMod time.Time
ChangeFreq string
Priority float64
}

152
internal/nostr/client.go

@ -0,0 +1,152 @@
package nostr
import (
"context"
"fmt"
"sync"
"time"
"github.com/nbd-wtf/go-nostr"
)
// Client handles connections to Nostr relays with failover support
type Client struct {
primaryRelay string
fallbackRelay string
mu sync.RWMutex
lastError error
}
// NewClient creates a new Nostr client with primary and fallback relays
func NewClient(primaryRelay, fallbackRelay string) *Client {
return &Client{
primaryRelay: primaryRelay,
fallbackRelay: fallbackRelay,
}
}
// Connect connects to the relays (no-op for now, connections happen on query)
func (c *Client) Connect(ctx context.Context) error {
// Connections are established lazily when querying
return nil
}
// ConnectToRelay connects to a single relay (exported for use by services)
func (c *Client) ConnectToRelay(ctx context.Context, url string) (*nostr.Relay, error) {
relay, err := nostr.RelayConnect(ctx, url)
if err != nil {
return nil, err
}
return relay, nil
}
// connectToRelay connects to a single relay (deprecated, use ConnectToRelay)
func (c *Client) connectToRelay(ctx context.Context, url string) (*nostr.Relay, error) {
return c.ConnectToRelay(ctx, url)
}
// FetchEvent fetches a single event by filter, trying primary relay first, then fallback
func (c *Client) FetchEvent(ctx context.Context, filter nostr.Filter) (*nostr.Event, error) {
// Try primary relay first
relay, err := c.connectToRelay(ctx, c.primaryRelay)
if err == nil {
events, err := relay.QuerySync(ctx, filter)
relay.Close()
if err == nil && len(events) > 0 {
return events[0], nil
}
}
// Try fallback relay
relay, err = c.connectToRelay(ctx, c.fallbackRelay)
if err != nil {
return nil, fmt.Errorf("failed to connect to both relays: %w", err)
}
defer relay.Close()
events, err := relay.QuerySync(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch event: %w", err)
}
if len(events) == 0 {
return nil, fmt.Errorf("event not found")
}
return events[0], nil
}
// FetchEvents fetches multiple events by filter, trying primary relay first, then fallback
func (c *Client) FetchEvents(ctx context.Context, filter nostr.Filter) ([]*nostr.Event, error) {
// Try primary relay first
relay, err := c.connectToRelay(ctx, c.primaryRelay)
if err == nil {
events, err := relay.QuerySync(ctx, filter)
relay.Close()
if err == nil {
return events, nil
}
}
// Try fallback relay
relay, err = c.connectToRelay(ctx, c.fallbackRelay)
if err != nil {
return nil, fmt.Errorf("failed to connect to both relays: %w", err)
}
defer relay.Close()
events, err := relay.QuerySync(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch events: %w", err)
}
return events, nil
}
// FetchEventByID fetches an event by its ID
func (c *Client) FetchEventByID(ctx context.Context, eventID string) (*nostr.Event, error) {
filter := nostr.Filter{
IDs: []string{eventID},
}
return c.FetchEvent(ctx, filter)
}
// FetchEventsByKind fetches events of a specific kind
func (c *Client) FetchEventsByKind(ctx context.Context, kind int, limit int) ([]*nostr.Event, error) {
filter := nostr.Filter{
Kinds: []int{kind},
Limit: limit,
}
return c.FetchEvents(ctx, filter)
}
// Close closes all relay connections (no-op for lazy connections)
func (c *Client) Close() {
// Connections are closed after each query, so nothing to do here
}
// GetLastError returns the last error encountered
func (c *Client) GetLastError() error {
c.mu.RLock()
defer c.mu.RUnlock()
return c.lastError
}
// IsConnected checks if at least one relay is connected (always true for lazy connections)
func (c *Client) IsConnected() bool {
return true // We connect on-demand
}
// HealthCheck performs a health check on the relays
func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
// Try to fetch a recent event to test connectivity
filter := nostr.Filter{
Kinds: []int{1}, // kind 1 (notes) for testing
Limit: 1,
}
_, err := c.FetchEvents(ctx, filter)
return err
}

210
internal/nostr/events.go

@ -0,0 +1,210 @@
package nostr
import (
"fmt"
"strings"
"github.com/nbd-wtf/go-nostr"
)
// IndexEvent represents a kind 30040 publication index event (NKBIP-01)
type IndexEvent struct {
Event *nostr.Event
Title string
DTag string
Author string
Items []IndexItem
AutoUpdate string
Type string
Version string
Summary string
Image string
}
// IndexItem represents an item in a publication index (from 'a' tags)
type IndexItem struct {
Kind int
Pubkey string
DTag string
RelayHint string
EventID string // Optional event ID for version tracking
}
// ParseIndexEvent parses a kind 30040 index event according to NKBIP-01
func ParseIndexEvent(event *nostr.Event) (*IndexEvent, error) {
if event.Kind != 30040 {
return nil, fmt.Errorf("expected kind 30040, got %d", event.Kind)
}
index := &IndexEvent{
Event: event,
Author: event.PubKey,
}
// Extract d tag
var dTag string
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
dTag = tag[1]
break
}
}
index.DTag = dTag
// Extract title
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
index.Title = tag[1]
break
}
}
// Extract 'a' tags (index items)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "a" && len(tag) > 1 {
// Format: ["a", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
ref := tag[1]
parts := strings.Split(ref, ":")
if len(parts) >= 3 {
var kind int
fmt.Sscanf(parts[0], "%d", &kind)
item := IndexItem{
Kind: kind,
Pubkey: parts[1],
DTag: parts[2],
RelayHint: "",
EventID: "",
}
if len(tag) > 2 {
item.RelayHint = tag[2]
}
if len(tag) > 3 {
item.EventID = tag[3]
}
index.Items = append(index.Items, item)
}
}
}
// Extract auto-update tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "auto-update" && len(tag) > 1 {
index.AutoUpdate = tag[1]
break
}
}
// Extract type tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "type" && len(tag) > 1 {
index.Type = tag[1]
break
}
}
// Extract version tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "version" && len(tag) > 1 {
index.Version = tag[1]
break
}
}
// Extract summary tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
index.Summary = tag[1]
break
}
}
// Extract image tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "image" && len(tag) > 1 {
index.Image = tag[1]
break
}
}
return index, nil
}
// WikiEvent represents a kind 30818 wiki event (NIP-54)
type WikiEvent struct {
Event *nostr.Event
DTag string
Title string
Summary string
Content string
}
// ParseWikiEvent parses a kind 30818 wiki event according to NIP-54
func ParseWikiEvent(event *nostr.Event) (*WikiEvent, error) {
if event.Kind != 30818 {
return nil, fmt.Errorf("expected kind 30818, got %d", event.Kind)
}
wiki := &WikiEvent{
Event: event,
Content: event.Content,
}
// Extract d tag (normalized identifier)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
wiki.DTag = tag[1]
break
}
}
// Extract title tag (optional, falls back to d tag)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "title" && len(tag) > 1 {
wiki.Title = tag[1]
break
}
}
if wiki.Title == "" {
wiki.Title = wiki.DTag
}
// Extract summary tag (optional)
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 {
wiki.Summary = tag[1]
break
}
}
return wiki, nil
}
// NormalizeDTag normalizes a d tag according to NIP-54 rules
func NormalizeDTag(dTag string) string {
// Convert to lowercase
dTag = strings.ToLower(dTag)
// Convert whitespace to hyphens
dTag = strings.ReplaceAll(dTag, " ", "-")
dTag = strings.ReplaceAll(dTag, "\t", "-")
dTag = strings.ReplaceAll(dTag, "\n", "-")
// Remove punctuation and symbols (keep alphanumeric, hyphens, and non-ASCII)
var result strings.Builder
for _, r := range dTag {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r > 127 {
result.WriteRune(r)
}
}
dTag = result.String()
// Collapse multiple consecutive hyphens
for strings.Contains(dTag, "--") {
dTag = strings.ReplaceAll(dTag, "--", "-")
}
// Remove leading and trailing hyphens
dTag = strings.Trim(dTag, "-")
return dTag
}

55
internal/nostr/feed.go

@ -0,0 +1,55 @@
package nostr
import (
"context"
"fmt"
"time"
"github.com/nbd-wtf/go-nostr"
)
// FeedService handles kind 1 feed operations
type FeedService struct {
client *Client
}
// NewFeedService creates a new feed service
func NewFeedService(client *Client) *FeedService {
return &FeedService{
client: client,
}
}
// FetchFeedItems fetches recent kind 1 events
func (fs *FeedService) FetchFeedItems(ctx context.Context, relay string, maxEvents int) ([]FeedItem, error) {
filter := nostr.Filter{
Kinds: []int{1},
Limit: maxEvents,
}
events, err := fs.client.FetchEvents(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch feed events: %w", err)
}
items := make([]FeedItem, 0, len(events))
for _, event := range events {
item := FeedItem{
Author: event.PubKey,
Content: event.Content,
Time: time.Unix(int64(event.CreatedAt), 0),
Link: fmt.Sprintf("https://alexandria.gitcitadel.eu/events?id=nevent1%s", event.ID),
}
items = append(items, item)
}
return items, nil
}
// FeedItem represents a feed item
type FeedItem struct {
Author string
Content string
Time time.Time
Link string
}

195
internal/nostr/issues.go

@ -0,0 +1,195 @@
package nostr
import (
"context"
"encoding/hex"
"fmt"
"time"
"github.com/nbd-wtf/go-nostr"
)
// IssueService handles publishing kind 1621 issue events
type IssueService struct {
client *Client
}
// NewIssueService creates a new issue service
func NewIssueService(client *Client) *IssueService {
return &IssueService{
client: client,
}
}
// RepoAnnouncement represents a parsed kind 30617 repository announcement
type RepoAnnouncement struct {
Event *nostr.Event
Pubkey string
DTag string
Relays []string
Maintainers []string
}
// ParseRepoAnnouncement parses a kind 30617 repository announcement event
func ParseRepoAnnouncement(event *nostr.Event) (*RepoAnnouncement, error) {
if event.Kind != 30617 {
return nil, fmt.Errorf("expected kind 30617, got %d", event.Kind)
}
repo := &RepoAnnouncement{
Event: event,
Pubkey: event.PubKey,
}
// Extract d tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "d" && len(tag) > 1 {
repo.DTag = tag[1]
break
}
}
// Extract relays tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "relays" && len(tag) > 1 {
repo.Relays = append(repo.Relays, tag[1:]...)
}
}
// Extract maintainers tag
for _, tag := range event.Tags {
if len(tag) > 0 && tag[0] == "maintainers" && len(tag) > 1 {
repo.Maintainers = append(repo.Maintainers, tag[1:]...)
}
}
return repo, nil
}
// FetchRepoAnnouncement fetches a repository announcement by naddr
func (s *IssueService) FetchRepoAnnouncement(ctx context.Context, repoNaddr string) (*RepoAnnouncement, error) {
// Parse the naddr
naddr, err := ParseNaddr(repoNaddr)
if err != nil {
return nil, fmt.Errorf("failed to parse repo naddr: %w", err)
}
if naddr.Kind != 30617 {
return nil, fmt.Errorf("expected kind 30617, got %d", naddr.Kind)
}
// Fetch the event
filter := naddr.ToFilter()
event, err := s.client.FetchEvent(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch repo announcement: %w", err)
}
return ParseRepoAnnouncement(event)
}
// IssueRequest represents a request to create an issue
type IssueRequest struct {
Subject string
Content string
Labels []string
}
// PublishIssue publishes a kind 1621 issue event to the repository
// If privateKey is empty, a random key will be generated (anonymous submission)
func (s *IssueService) PublishIssue(ctx context.Context, repoAnnouncement *RepoAnnouncement, req *IssueRequest, privateKey string) (string, error) {
// Generate or use provided private key
var privKeyHex string
if privateKey == "" {
// Generate a random key for anonymous submission
privKeyHex = nostr.GeneratePrivateKey()
} else {
// Validate the provided key
keyBytes, err := hex.DecodeString(privateKey)
if err != nil {
return "", fmt.Errorf("invalid private key: %w", err)
}
if len(keyBytes) != 32 {
return "", fmt.Errorf("private key must be 32 bytes (64 hex characters)")
}
privKeyHex = privateKey
}
// Get public key from private key
pubkey, err := nostr.GetPublicKey(privKeyHex)
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
// Create the issue event
event := &nostr.Event{
PubKey: pubkey,
Kind: 1621,
Content: req.Content,
CreatedAt: nostr.Timestamp(time.Now().Unix()),
Tags: nostr.Tags{},
}
// Add 'a' tag pointing to the repository announcement
// Format: ["a", "30617:<pubkey>:<d-tag>"]
event.Tags = append(event.Tags, nostr.Tag{"a", fmt.Sprintf("30617:%s:%s", repoAnnouncement.Pubkey, repoAnnouncement.DTag)})
// Add 'p' tag for repository owner
event.Tags = append(event.Tags, nostr.Tag{"p", repoAnnouncement.Pubkey})
// Add maintainers as 'p' tags
for _, maintainer := range repoAnnouncement.Maintainers {
event.Tags = append(event.Tags, nostr.Tag{"p", maintainer})
}
// Add subject tag if provided
if req.Subject != "" {
event.Tags = append(event.Tags, nostr.Tag{"subject", req.Subject})
}
// Add label tags
for _, label := range req.Labels {
if label != "" {
event.Tags = append(event.Tags, nostr.Tag{"t", label})
}
}
// Sign the event
if err := event.Sign(privKeyHex); err != nil {
return "", fmt.Errorf("failed to sign event: %w", err)
}
// Determine which relays to publish to
relays := repoAnnouncement.Relays
if len(relays) == 0 {
// Fallback to default relays if none specified
relays = []string{s.client.primaryRelay, s.client.fallbackRelay}
}
// Publish to relays
var lastErr error
successCount := 0
for _, relayURL := range relays {
relay, err := s.client.ConnectToRelay(ctx, relayURL)
if err != nil {
lastErr = err
continue
}
err = relay.Publish(ctx, *event)
relay.Close()
if err != nil {
lastErr = err
continue
}
// Publish succeeded
successCount++
}
if successCount == 0 && lastErr != nil {
return "", fmt.Errorf("failed to publish to any relay: %w", lastErr)
}
return event.ID, nil
}

71
internal/nostr/naddr.go

@ -0,0 +1,71 @@
package nostr
import (
"fmt"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip19"
)
// Naddr represents a parsed naddr (Nostr addressable event reference)
type Naddr struct {
Kind int
Pubkey string
DTag string
Relays []string
RawNaddr string
}
// ParseNaddr decodes a bech32-encoded naddr string and extracts its components
// Uses the go-nostr library's nip19.Decode which supports longer naddr strings
func ParseNaddr(naddr string) (*Naddr, error) {
// Use go-nostr's nip19.Decode which handles longer strings via DecodeNoLimit
prefix, value, err := nip19.Decode(naddr)
if err != nil {
return nil, fmt.Errorf("failed to decode naddr: %w", err)
}
if prefix != "naddr" {
return nil, fmt.Errorf("invalid naddr prefix: expected 'naddr', got '%s'", prefix)
}
// Cast to EntityPointer
ep, ok := value.(nostr.EntityPointer)
if !ok {
return nil, fmt.Errorf("failed to parse naddr: unexpected type")
}
if ep.Kind == 0 {
return nil, fmt.Errorf("naddr missing kind")
}
if ep.PublicKey == "" {
return nil, fmt.Errorf("naddr missing pubkey")
}
if ep.Identifier == "" {
return nil, fmt.Errorf("naddr missing d tag")
}
return &Naddr{
Kind: ep.Kind,
Pubkey: ep.PublicKey,
DTag: ep.Identifier,
Relays: ep.Relays,
RawNaddr: naddr,
}, nil
}
// ToEventReference converts the naddr to an event reference string (kind:pubkey:d)
func (n *Naddr) ToEventReference() string {
return fmt.Sprintf("%d:%s:%s", n.Kind, n.Pubkey, n.DTag)
}
// ToFilter creates a nostr filter for this naddr
func (n *Naddr) ToFilter() nostr.Filter {
return nostr.Filter{
Kinds: []int{n.Kind},
Authors: []string{n.Pubkey},
Tags: map[string][]string{
"d": {n.DTag},
},
}
}

107
internal/nostr/wiki.go

@ -0,0 +1,107 @@
package nostr
import (
"context"
"fmt"
"github.com/nbd-wtf/go-nostr"
)
// WikiService handles wiki-specific operations
type WikiService struct {
client *Client
}
// NewWikiService creates a new wiki service
func NewWikiService(client *Client) *WikiService {
return &WikiService{
client: client,
}
}
// FetchWikiIndex fetches a wiki index (kind 30040) by naddr
func (ws *WikiService) FetchWikiIndex(ctx context.Context, naddrStr string) (*IndexEvent, error) {
// Parse naddr
naddr, err := ParseNaddr(naddrStr)
if err != nil {
return nil, fmt.Errorf("failed to parse naddr: %w", err)
}
// Create filter for the index event
filter := naddr.ToFilter()
// Fetch the event
event, err := ws.client.FetchEvent(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch index event: %w", err)
}
// Parse the index event
index, err := ParseIndexEvent(event)
if err != nil {
return nil, fmt.Errorf("failed to parse index event: %w", err)
}
return index, nil
}
// FetchWikiEvents fetches all wiki events (kind 30818) referenced in an index
func (ws *WikiService) FetchWikiEvents(ctx context.Context, index *IndexEvent) ([]*WikiEvent, error) {
var wikiEvents []*WikiEvent
for _, item := range index.Items {
if item.Kind != 30818 {
continue // Skip non-wiki items
}
// Create filter for this wiki event
filter := nostr.Filter{
Kinds: []int{30818},
Authors: []string{item.Pubkey},
Tags: map[string][]string{
"d": {item.DTag},
},
}
// If event ID is specified, use it
if item.EventID != "" {
filter.IDs = []string{item.EventID}
}
// Fetch the event
event, err := ws.client.FetchEvent(ctx, filter)
if err != nil {
// Log error but continue with other events
continue
}
// Parse the wiki event
wiki, err := ParseWikiEvent(event)
if err != nil {
continue
}
wikiEvents = append(wikiEvents, wiki)
}
return wikiEvents, nil
}
// FetchWikiEventByDTag fetches a single wiki event by d tag
func (ws *WikiService) FetchWikiEventByDTag(ctx context.Context, pubkey, dTag string) (*WikiEvent, error) {
filter := nostr.Filter{
Kinds: []int{30818},
Authors: []string{pubkey},
Tags: map[string][]string{
"d": {dTag},
},
Limit: 1,
}
event, err := ws.client.FetchEvent(ctx, filter)
if err != nil {
return nil, fmt.Errorf("failed to fetch wiki event: %w", err)
}
return ParseWikiEvent(event)
}

270
internal/server/handlers.go

@ -0,0 +1,270 @@
package server
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
"gitcitadel-online/internal/cache"
"gitcitadel-online/internal/nostr"
)
// setupRoutes sets up all HTTP routes
func (s *Server) setupRoutes(mux *http.ServeMux) {
// Static files
mux.HandleFunc("/static/", s.handleStatic)
// Main routes
mux.HandleFunc("/", s.handleLanding)
mux.HandleFunc("/wiki/", s.handleWiki)
mux.HandleFunc("/blog", s.handleBlog)
mux.HandleFunc("/contact", s.handleContact)
// Health and metrics
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/metrics", s.handleMetrics)
// SEO
mux.HandleFunc("/sitemap.xml", s.handleSitemap)
mux.HandleFunc("/robots.txt", s.handleRobots)
}
// handleLanding handles the landing page
func (s *Server) handleLanding(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
s.handle404(w, r)
return
}
page, exists := s.cache.Get("/")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
}
// handleWiki handles wiki article pages
func (s *Server) handleWiki(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
page, exists := s.cache.Get(path)
if !exists {
s.handle404(w, r)
return
}
s.servePage(w, r, page)
}
// handleBlog handles the blog page
func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) {
page, exists := s.cache.Get("/blog")
if !exists {
http.Error(w, "Page not ready", http.StatusServiceUnavailable)
return
}
s.servePage(w, r, page)
}
// handleContact handles the contact form (GET and POST)
func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Render the contact form
html, err := s.htmlGenerator.GenerateContactPage(false, "", "", nil)
if err != nil {
http.Error(w, "Failed to generate contact page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to parse form data", "", nil)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
subject := strings.TrimSpace(r.FormValue("subject"))
content := strings.TrimSpace(r.FormValue("content"))
labelsStr := strings.TrimSpace(r.FormValue("labels"))
// Validate required fields
if subject == "" || content == "" {
formData := map[string]string{
"subject": subject,
"content": content,
"labels": labelsStr,
}
html, _ := s.htmlGenerator.GenerateContactPage(false, "Subject and message are required", "", formData)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
// Parse labels
var labels []string
if labelsStr != "" {
labelParts := strings.Split(labelsStr, ",")
for _, label := range labelParts {
label = strings.TrimSpace(label)
if label != "" {
labels = append(labels, label)
}
}
}
// Fetch repo announcement
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
repoAnnouncement, err := s.issueService.FetchRepoAnnouncement(ctx, s.repoAnnouncement)
if err != nil {
log.Printf("Failed to fetch repo announcement: %v", err)
formData := map[string]string{
"subject": subject,
"content": content,
"labels": labelsStr,
}
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to connect to repository. Please try again later.", "", formData)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
// Create issue request
issueReq := &nostr.IssueRequest{
Subject: subject,
Content: content,
Labels: labels,
}
// Publish issue (using anonymous key - server generates random key)
eventID, err := s.issueService.PublishIssue(ctx, repoAnnouncement, issueReq, "")
if err != nil {
log.Printf("Failed to publish issue: %v", err)
formData := map[string]string{
"subject": subject,
"content": content,
"labels": labelsStr,
}
html, _ := s.htmlGenerator.GenerateContactPage(false, "Failed to submit your message. Please try again later.", "", formData)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
return
}
// Success - render success page
html, err := s.htmlGenerator.GenerateContactPage(true, "", eventID, nil)
if err != nil {
http.Error(w, "Failed to generate success page", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
// handleStatic serves static files
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
// Serve static files from the static directory
http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))).ServeHTTP(w, r)
}
// handleHealth handles health check requests
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if s.cache.Size() == 0 {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("Not ready"))
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
}
// handleMetrics handles metrics requests
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
fmt.Fprintf(w, "cache_size %d\n", s.cache.Size())
fmt.Fprintf(w, "feed_items %d\n", len(s.feedCache.Get()))
}
// handleSitemap handles sitemap requests
func (s *Server) handleSitemap(w http.ResponseWriter, r *http.Request) {
// TODO: Generate sitemap from cache
w.Header().Set("Content-Type", "application/xml")
w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>`))
}
// handleRobots handles robots.txt requests
func (s *Server) handleRobots(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Write([]byte("User-agent: *\nAllow: /\nSitemap: /sitemap.xml\n"))
}
// handle404 handles 404 errors
func (s *Server) handle404(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
// TODO: Serve custom 404 page from cache
w.Write([]byte("404 - Page Not Found"))
}
// servePage serves a cached page with proper headers
func (s *Server) servePage(w http.ResponseWriter, r *http.Request, page *cache.CachedPage) {
// Set headers
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("ETag", page.ETag)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("Last-Modified", page.LastUpdated.Format(http.TimeFormat))
// Check If-None-Match for conditional requests
if match := r.Header.Get("If-None-Match"); match == page.ETag {
w.WriteHeader(http.StatusNotModified)
return
}
// Check Accept-Encoding for compression
acceptEncoding := r.Header.Get("Accept-Encoding")
if strings.Contains(acceptEncoding, "gzip") && len(page.Compressed) > 0 {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
w.Write(page.Compressed)
return
}
// Serve uncompressed
w.Write([]byte(page.Content))
}
// middleware adds security headers and logging
func (s *Server) middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Security headers
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// CSP header
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:;")
// Log request
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}

92
internal/server/server.go

@ -0,0 +1,92 @@
package server
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"gitcitadel-online/internal/cache"
"gitcitadel-online/internal/nostr"
)
// Server represents the HTTP server
type Server struct {
httpServer *http.Server
cache *cache.Cache
feedCache *cache.FeedCache
port int
issueService IssueServiceInterface
repoAnnouncement string
htmlGenerator HTMLGeneratorInterface
}
// IssueServiceInterface defines the interface for issue service
type IssueServiceInterface interface {
FetchRepoAnnouncement(ctx context.Context, repoNaddr string) (*nostr.RepoAnnouncement, error)
PublishIssue(ctx context.Context, repoAnnouncement *nostr.RepoAnnouncement, req *nostr.IssueRequest, privateKey string) (string, error)
}
// HTMLGeneratorInterface defines the interface for HTML generator
type HTMLGeneratorInterface interface {
GenerateContactPage(success bool, errorMsg string, eventID string, formData map[string]string) (string, error)
}
// NewServer creates a new HTTP server
func NewServer(port int, pageCache *cache.Cache, feedCache *cache.FeedCache, issueService IssueServiceInterface, repoAnnouncement string, htmlGenerator HTMLGeneratorInterface) *Server {
s := &Server{
cache: pageCache,
feedCache: feedCache,
port: port,
issueService: issueService,
repoAnnouncement: repoAnnouncement,
htmlGenerator: htmlGenerator,
}
mux := http.NewServeMux()
// Setup routes
s.setupRoutes(mux)
s.httpServer = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: s.middleware(mux),
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
return s
}
// Start starts the HTTP server
func (s *Server) Start() error {
log.Printf("Starting server on port %d", s.port)
return s.httpServer.ListenAndServe()
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpServer.Shutdown(ctx)
}
// WaitForShutdown waits for shutdown signals
func (s *Server) WaitForShutdown() {
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exited")
}

133
package-lock.json generated

@ -0,0 +1,133 @@
{
"name": "gitcitadel-online",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"@asciidoctor/core": "^3.0.4"
}
},
"node_modules/@asciidoctor/core": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@asciidoctor/core/-/core-3.0.4.tgz",
"integrity": "sha512-41SDMi7iRRBViPe0L6VWFTe55bv6HEOJeRqMj5+E5wB1YPdUPuTucL4UAESPZM6OWmn4t/5qM5LusXomFUVwVQ==",
"license": "MIT",
"dependencies": {
"@asciidoctor/opal-runtime": "3.0.1",
"unxhr": "1.2.0"
},
"engines": {
"node": ">=16",
"npm": ">=8"
}
},
"node_modules/@asciidoctor/opal-runtime": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@asciidoctor/opal-runtime/-/opal-runtime-3.0.1.tgz",
"integrity": "sha512-iW7ACahOG0zZft4A/4CqDcc7JX+fWRNjV5tFAVkNCzwZD+EnFolPaUOPYt8jzadc0+Bgd80cQTtRMQnaaV1kkg==",
"license": "MIT",
"dependencies": {
"glob": "8.1.0",
"unxhr": "1.2.0"
},
"engines": {
"node": ">=16"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/unxhr": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/unxhr/-/unxhr-1.2.0.tgz",
"integrity": "sha512-6cGpm8NFXPD9QbSNx0cD2giy7teZ6xOkCUH3U89WKVkL9N9rBrWjlCwhR94Re18ZlAop4MOc3WU1M3Hv/bgpIw==",
"license": "MIT",
"engines": {
"node": ">=8.11"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}

5
package.json

@ -0,0 +1,5 @@
{
"dependencies": {
"@asciidoctor/core": "^3.0.4"
}
}

0
static/icons/GitCitadel_Logo/GitCitadel_Graphic_Landscape.png → static/GitCitadel_Graphic_Landscape.png

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 166 KiB

0
static/icons/GitCitadel_Logo/GitCitadel_Graphic_Portrait.png → static/GitCitadel_Graphic_Portrait.png

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

0
static/icons/GitCitadel_Logo/GitCitadel_Icon_Gradient.svg → static/GitCitadel_Icon_Gradient.svg

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

0
static/icons/GitCitadel_Logo/GitCitadel_PFP.png → static/GitCitadel_PFP.png

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

611
static/css/main.css

@ -0,0 +1,611 @@
/* Main Stylesheet - Medium-dark background with WCAG AA/AAA compliant colors */
:root {
--bg-primary: #2d2d2d;
--bg-secondary: #1e1e1e;
--text-primary: #e8e8e8;
--text-secondary: #b8b8b8;
--link-color: #7c9eff;
--link-hover: #9bb3ff;
--link-visited: #a58fff;
--accent-color: #7c9eff;
--border-color: #404040;
--focus-color: #9bb3ff;
--error-color: #ff6b6b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
font-size: 16px;
line-height: 1.6;
color: var(--text-primary);
background-color: var(--bg-primary);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Skip link for accessibility */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: var(--accent-color);
color: var(--bg-primary);
padding: 8px;
text-decoration: none;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
/* Header and Navigation */
header {
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
position: sticky;
top: 0;
z-index: 50;
}
.navbar {
padding: 1rem 0;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--text-primary);
font-weight: 600;
font-size: 1.25rem;
}
.logo-icon {
width: 32px;
height: 32px;
}
.logo-text {
color: var(--text-primary);
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
align-items: center;
}
.nav-menu a {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s;
}
.nav-menu a:hover,
.nav-menu a:focus {
color: var(--link-hover);
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
.mobile-menu-toggle {
display: none;
flex-direction: column;
gap: 4px;
background: transparent;
border: none;
cursor: pointer;
padding: 8px;
}
.mobile-menu-toggle span {
width: 24px;
height: 2px;
background: var(--text-primary);
transition: all 0.3s;
}
/* Dropdown menu */
.dropdown {
position: relative;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
list-style: none;
min-width: 200px;
padding: 0.5rem 0;
opacity: 0;
visibility: hidden;
transition: opacity 0.2s, visibility 0.2s;
}
.dropdown:hover .dropdown-menu,
.dropdown:focus-within .dropdown-menu {
opacity: 1;
visibility: visible;
}
.dropdown-menu li {
padding: 0;
}
.dropdown-menu a {
display: block;
padding: 0.5rem 1rem;
}
/* Layout */
.layout-container {
display: flex;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
gap: 2rem;
flex: 1;
}
.main-content {
flex: 1;
min-width: 0;
}
.feed-sidebar {
width: 300px;
flex-shrink: 0;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary);
line-height: 1.2;
margin-bottom: 1rem;
}
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
p {
margin-bottom: 1rem;
}
/* Links */
a {
color: var(--link-color);
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: var(--link-hover);
}
a:visited {
color: var(--link-visited);
}
a:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Buttons */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--accent-color);
color: var(--bg-primary);
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-weight: 500;
transition: background 0.2s;
}
.btn:hover {
background: var(--link-hover);
}
.btn:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Articles and Pages */
article {
background: var(--bg-secondary);
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.page-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.page-summary {
color: var(--text-secondary);
font-size: 1.1rem;
}
.page-content {
line-height: 1.8;
}
.page-content img {
max-width: 100%;
height: auto;
}
/* Breadcrumbs */
.breadcrumbs {
margin-bottom: 1rem;
}
.breadcrumbs ol {
list-style: none;
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.breadcrumbs li::after {
content: '/';
margin-left: 0.5rem;
color: var(--text-secondary);
}
.breadcrumbs li:last-child::after {
content: '';
}
.breadcrumbs a {
color: var(--link-color);
}
/* Feed Sidebar */
.feed-container {
background: var(--bg-secondary);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.feed-container h3 {
margin-bottom: 1rem;
font-size: 1.25rem;
}
.feed-item {
padding: 1rem 0;
border-bottom: 1px solid var(--border-color);
}
.feed-item:last-child {
border-bottom: none;
}
.feed-author {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.feed-content {
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.feed-time {
color: var(--text-secondary);
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.feed-link {
font-size: 0.85rem;
}
.feed-empty {
color: var(--text-secondary);
font-style: italic;
}
/* Footer */
footer {
background: var(--bg-secondary);
border-top: 1px solid var(--border-color);
padding: 2rem 1rem;
text-align: center;
color: var(--text-secondary);
margin-top: auto;
}
/* Error pages */
.error-page {
text-align: center;
padding: 4rem 2rem;
max-width: 600px;
margin: 0 auto;
}
.error-page h1 {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-page p {
font-size: 1.25rem;
margin-bottom: 2rem;
color: var(--text-secondary);
}
/* Blog */
.blog-nav {
margin-bottom: 2rem;
}
.article-menu {
list-style: none;
}
.article-menu li {
margin-bottom: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.article-menu a {
color: var(--text-primary);
text-decoration: none;
}
.article-menu h3 {
margin-bottom: 0.5rem;
}
.article-menu p {
color: var(--text-secondary);
margin: 0;
}
.blog-article {
margin-bottom: 3rem;
padding-bottom: 2rem;
border-bottom: 1px solid var(--border-color);
}
.blog-article:last-child {
border-bottom: none;
}
.article-summary {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 1rem;
}
/* Code blocks */
pre {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
margin: 1rem 0;
}
code {
background: var(--bg-secondary);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
pre code {
background: transparent;
padding: 0;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
}
th, td {
padding: 0.75rem;
border: 1px solid var(--border-color);
text-align: left;
}
th {
background: var(--bg-secondary);
font-weight: 600;
}
/* Lists */
ul, ol {
margin-left: 2rem;
margin-bottom: 1rem;
}
li {
margin-bottom: 0.5rem;
}
/* Focus styles for accessibility */
*:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
button:focus,
a:focus,
input:focus,
select:focus,
textarea:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Contact Form Styles */
.contact-page {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.contact-form {
margin-top: 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: var(--text-primary);
}
.form-group .required {
color: var(--error-color);
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 0.75rem;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-primary);
font-family: inherit;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
border-color: var(--focus-color);
outline: none;
}
.form-group textarea {
resize: vertical;
min-height: 150px;
}
.form-help {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s, transform 0.1s;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--accent-color);
color: var(--bg-primary);
}
.btn-primary:hover {
background: var(--link-hover);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: #2a2a2a;
}
/* Alert Styles */
.alert {
padding: 1rem;
border-radius: 4px;
margin-bottom: 1.5rem;
}
.alert-success {
background: rgba(76, 175, 80, 0.2);
border: 1px solid rgba(76, 175, 80, 0.5);
color: #a5d6a7;
}
.alert-error {
background: rgba(244, 67, 54, 0.2);
border: 1px solid rgba(244, 67, 54, 0.5);
color: #ef9a9a;
}
.alert strong {
display: block;
margin-bottom: 0.5rem;
}
.alert small {
display: block;
margin-top: 0.5rem;
font-size: 0.875rem;
opacity: 0.8;
}

58
static/css/print.css

@ -0,0 +1,58 @@
/* Print Stylesheet */
@media print {
.feed-sidebar,
.navbar,
.mobile-menu-toggle,
.skip-link,
footer {
display: none !important;
}
.layout-container {
flex-direction: column;
max-width: 100%;
padding: 0;
}
.main-content {
width: 100%;
}
body {
background: white;
color: black;
}
article {
background: white;
border: none;
padding: 0;
page-break-inside: avoid;
}
a {
color: black;
text-decoration: underline;
}
a[href^="http"]:after {
content: " (" attr(href) ")";
font-size: 0.8em;
color: #666;
}
h1, h2, h3 {
page-break-after: avoid;
}
img {
max-width: 100% !important;
page-break-inside: avoid;
}
pre, code {
background: #f5f5f5;
border: 1px solid #ddd;
}
}

119
static/css/responsive.css

@ -0,0 +1,119 @@
/* Responsive Design - Mobile-first approach */
/* Mobile styles (default, < 768px) */
@media (max-width: 767px) {
.feed-sidebar {
display: none;
}
.layout-container {
flex-direction: column;
padding: 1rem;
}
.main-content {
width: 100%;
}
.mobile-menu-toggle {
display: flex;
}
.nav-menu {
position: fixed;
top: 60px;
left: -100%;
width: 100%;
height: calc(100vh - 60px);
background: var(--bg-secondary);
flex-direction: column;
align-items: flex-start;
padding: 2rem;
transition: left 0.3s;
border-top: 1px solid var(--border-color);
}
.nav-menu.active {
left: 0;
}
.nav-menu li {
width: 100%;
padding: 0.5rem 0;
}
.dropdown-menu {
position: static;
opacity: 1;
visibility: visible;
border: none;
box-shadow: none;
margin-left: 1rem;
margin-top: 0.5rem;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
article {
padding: 1.5rem;
}
.nav-container {
padding: 0.5rem 1rem;
}
}
/* Tablet styles (768px - 1024px) */
@media (min-width: 768px) and (max-width: 1024px) {
.feed-sidebar {
width: 250px;
}
.layout-container {
padding: 1.5rem;
}
h1 {
font-size: 2.25rem;
}
}
/* Desktop styles (> 1024px) */
@media (min-width: 1025px) {
.feed-sidebar {
display: block;
}
.layout-container {
max-width: 1400px;
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
:root {
--bg-primary: #1a1a1a;
--bg-secondary: #0f0f0f;
--text-primary: #ffffff;
--text-secondary: #e0e0e0;
--link-color: #9bb3ff;
--border-color: #606060;
}
}

29
templates/404.html

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found - {{.SiteName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/GitCitadel_Icon_Gradient.svg">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/responsive.css">
</head>
<body>
<header>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="logo">
<img src="/static/GitCitadel_Icon_Gradient.svg" alt="GitCitadel Logo" class="logo-icon">
<span class="logo-text">{{.SiteName}}</span>
</a>
</div>
</nav>
</header>
<main class="error-page">
<h1>404</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/" class="btn">Go Home</a>
</main>
</body>
</html>

29
templates/500.html

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>500 - Server Error - {{.SiteName}}</title>
<link rel="icon" type="image/svg+xml" href="/static/GitCitadel_Icon_Gradient.svg">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/responsive.css">
</head>
<body>
<header>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="logo">
<img src="/static/GitCitadel_Icon_Gradient.svg" alt="GitCitadel Logo" class="logo-icon">
<span class="logo-text">{{.SiteName}}</span>
</a>
</div>
</nav>
</header>
<main class="error-page">
<h1>500</h1>
<p>Something went wrong on our end. Please try again later.</p>
<a href="/" class="btn">Go Home</a>
</main>
</body>
</html>

92
templates/base.html

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} - {{.SiteName}}</title>
{{if .Description}}<meta name="description" content="{{.Description}}">{{end}}
{{if .Keywords}}<meta name="keywords" content="{{.Keywords}}">{{end}}
<!-- OpenGraph / Facebook -->
<meta property="og:type" content="{{if .OGType}}{{.OGType}}{{else}}website{{end}}">
<meta property="og:url" content="{{.CanonicalURL}}">
<meta property="og:title" content="{{.Title}} - {{.SiteName}}">
{{if .Description}}<meta property="og:description" content="{{.Description}}">{{end}}
<meta property="og:image" content="{{.OGImage}}">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="{{.CanonicalURL}}">
<meta name="twitter:title" content="{{.Title}} - {{.SiteName}}">
{{if .Description}}<meta name="twitter:description" content="{{.Description}}">{{end}}
<meta name="twitter:image" content="{{.OGImage}}">
<link rel="canonical" href="{{.CanonicalURL}}">
<link rel="icon" type="image/svg+xml" href="/static/GitCitadel_Icon_Gradient.svg">
<link rel="stylesheet" href="/static/css/main.css">
<link rel="stylesheet" href="/static/css/responsive.css">
<link rel="stylesheet" href="/static/css/print.css" media="print">
{{if .StructuredData}}<script type="application/ld+json">{{.StructuredData}}</script>{{end}}
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>
<nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="nav-container">
<a href="/" class="logo">
<img src="/static/GitCitadel_Icon_Gradient.svg" alt="GitCitadel Logo" class="logo-icon">
<span class="logo-text">{{.SiteName}}</span>
</a>
<button class="mobile-menu-toggle" aria-label="Toggle menu" aria-expanded="false">
<span></span>
<span></span>
<span></span>
</button>
<ul class="nav-menu">
<li><a href="/">Home</a></li>
{{range .WikiPages}}
<li><a href="/wiki/{{.DTag}}">{{.Title}}</a></li>
{{end}}
<li class="dropdown">
<a href="/blog" class="dropdown-toggle">Blog</a>
<ul class="dropdown-menu">
{{range .BlogItems}}
<li><a href="/blog#{{.DTag}}">{{.Title}}</a></li>
{{end}}
</ul>
</li>
<li><a href="/contact">Contact</a></li>
</ul>
</div>
</nav>
</header>
<div class="layout-container">
<main id="main-content" class="main-content">
{{block "content" .}}{{end}}
</main>
<aside class="feed-sidebar" aria-label="Recent notes">
{{block "feed" .}}{{end}}
</aside>
</div>
<footer>
<p>&copy; {{.CurrentYear}} {{.SiteName}}. All rights reserved.</p>
</footer>
<script>
// Mobile menu toggle
document.querySelector('.mobile-menu-toggle')?.addEventListener('click', function() {
const menu = document.querySelector('.nav-menu');
const isExpanded = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', !isExpanded);
menu.classList.toggle('active');
});
</script>
</body>
</html>

50
templates/blog.html

@ -0,0 +1,50 @@
{{define "content"}}
<article class="blog-page">
<header class="page-header">
<h1>Blog</h1>
{{if .BlogSummary}}<p class="page-summary">{{.BlogSummary}}</p>{{end}}
</header>
<nav class="blog-nav" aria-label="Blog articles">
<h2>Articles</h2>
<ul class="article-menu">
{{range .BlogItems}}
<li>
<a href="/blog#{{.DTag}}" id="{{.DTag}}">
<h3>{{.Title}}</h3>
{{if .Summary}}<p>{{.Summary}}</p>{{end}}
</a>
</li>
{{end}}
</ul>
</nav>
{{range .BlogItems}}
<section class="blog-article" id="article-{{.DTag}}">
<h2>{{.Title}}</h2>
{{if .Summary}}<p class="article-summary">{{.Summary}}</p>{{end}}
<div class="article-content">
{{.Content}}
</div>
</section>
{{end}}
</article>
{{end}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<div class="feed-item">
<div class="feed-author">{{.Author}}</div>
<div class="feed-content">{{.Content}}</div>
<div class="feed-time">{{.Time}}</div>
<a href="{{.Link}}" class="feed-link">View on Alexandria</a>
</div>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
</div>
</div>
{{end}}

52
templates/contact.html

@ -0,0 +1,52 @@
{{define "content"}}
<article class="contact-page">
<h1>Contact Us</h1>
<p>Have a question, suggestion, or want to report an issue? Fill out the form below and we'll get back to you.</p>
{{if .Success}}
<div class="alert alert-success" role="alert">
<strong>Success!</strong> Your message has been submitted. Thank you for contacting us!
{{if .EventID}}
<br><small>Issue ID: {{.EventID}}</small>
{{end}}
</div>
{{end}}
{{if .Error}}
<div class="alert alert-error" role="alert">
<strong>Error:</strong> {{.Error}}
</div>
{{end}}
<form method="POST" action="/contact" class="contact-form">
<div class="form-group">
<label for="subject">Subject <span class="required">*</span></label>
<input type="text" id="subject" name="subject" required
value="{{.FormData.Subject}}"
placeholder="Brief description of your message"
aria-required="true">
</div>
<div class="form-group">
<label for="content">Message <span class="required">*</span></label>
<textarea id="content" name="content" rows="10" required
placeholder="Please provide details about your question, suggestion, or issue..."
aria-required="true">{{.FormData.Content}}</textarea>
<small class="form-help">You can use Markdown formatting in your message.</small>
</div>
<div class="form-group">
<label for="labels">Labels (optional)</label>
<input type="text" id="labels" name="labels"
value="{{.FormData.Labels}}"
placeholder="bug, feature-request, question (comma-separated)">
<small class="form-help">Add labels to categorize your issue (comma-separated).</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="reset" class="btn btn-secondary">Clear</button>
</div>
</form>
</article>
{{end}}

19
templates/feed_sidebar.html

@ -0,0 +1,19 @@
<div class="feed-sidebar">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<article class="feed-item">
<header class="feed-header">
<span class="feed-author">{{.Author}}</span>
<time class="feed-time" datetime="{{.TimeISO}}">{{.Time}}</time>
</header>
<div class="feed-content">{{.Content}}</div>
<footer class="feed-footer">
<a href="{{.Link}}" class="feed-link" target="_blank" rel="noopener noreferrer">View on Alexandria</a>
</footer>
</article>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
</div>
</div>

47
templates/landing.html

@ -0,0 +1,47 @@
{{define "content"}}
<article class="landing-page">
<section class="hero">
<h1>Welcome to {{.SiteName}}</h1>
<p class="lead">Your gateway to decentralized knowledge and community-driven content.</p>
</section>
<section class="features">
<h2>Explore Our Content</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Wiki</h3>
<p>Browse our comprehensive wiki documentation.</p>
<ul>
{{range .WikiPages}}
<li><a href="/wiki/{{.DTag}}">{{.Title}}</a></li>
{{end}}
</ul>
</div>
<div class="feature-card">
<h3>Blog</h3>
<p>Read the latest articles and updates.</p>
<a href="/blog" class="btn">View Blog</a>
</div>
</div>
</section>
</article>
{{end}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<div class="feed-item">
<div class="feed-author">{{.Author}}</div>
<div class="feed-content">{{.Content}}</div>
<div class="feed-time">{{.Time}}</div>
<a href="{{.Link}}" class="feed-link">View on Alexandria</a>
</div>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
</div>
</div>
{{end}}

45
templates/page.html

@ -0,0 +1,45 @@
{{define "content"}}
<article class="wiki-page">
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
<li><a href="/">Home</a></li>
<li><a href="/wiki">Wiki</a></li>
<li aria-current="page">{{.Title}}</li>
</ol>
</nav>
<header class="page-header">
<h1>{{.Title}}</h1>
{{if .Summary}}<p class="page-summary">{{.Summary}}</p>{{end}}
</header>
<div class="page-content">
{{.Content}}
</div>
{{if .TableOfContents}}
<aside class="table-of-contents">
<h2>Table of Contents</h2>
{{.TableOfContents}}
</aside>
{{end}}
</article>
{{end}}
{{define "feed"}}
<div class="feed-container">
<h3>Recent Notes</h3>
<div class="feed-items">
{{range .FeedItems}}
<div class="feed-item">
<div class="feed-author">{{.Author}}</div>
<div class="feed-content">{{.Content}}</div>
<div class="feed-time">{{.Time}}</div>
<a href="{{.Link}}" class="feed-link">View on Alexandria</a>
</div>
{{else}}
<p class="feed-empty">No recent notes available.</p>
{{end}}
</div>
</div>
{{end}}
Loading…
Cancel
Save