@ -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 |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
go.sum database tree |
||||||
|
50144980 |
||||||
|
nD9Fj466sgN+5qITNI4UsGgIubMahhAoSzy1anczwi8= |
||||||
|
|
||||||
|
— sum.golang.org Az3grqlGKF2Yc0/NkMjN7LHBaVRPaKlJ5uUCHoglziDyBPnI1eFnBBfKg04+o8e++xMZ7DrVMkgtXLG+FQz9RlZ82wE= |
||||||
@ -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 |
||||||
|
|||||||
@ -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() |
||||||
|
} |
||||||
@ -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" |
||||||
@ -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 |
||||||
|
) |
||||||
@ -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= |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 + `" |
||||||
|
} |
||||||
|
}` |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
@ -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}, |
||||||
|
}, |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
@ -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)) |
||||||
|
}) |
||||||
|
} |
||||||
@ -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") |
||||||
|
} |
||||||
@ -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" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
{ |
||||||
|
"dependencies": { |
||||||
|
"@asciidoctor/core": "^3.0.4" |
||||||
|
} |
||||||
|
} |
||||||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
@ -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; |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
} |
||||||
@ -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> |
||||||
@ -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> |
||||||
@ -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>© {{.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> |
||||||
@ -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}} |
||||||
@ -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}} |
||||||
@ -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> |
||||||
@ -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}} |
||||||
@ -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}} |
||||||