{{$item.Title}}
+Longform article
+{{$item.Summary}}
{{end}} +diff --git a/cmd/server/main.go b/cmd/server/main.go index 5bc7e94..2d04801 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -52,16 +52,12 @@ func main() { } // Initialize services - // Get the primary wiki kind (first from config) - wikiKind := cfg.WikiKinds[0] - // Get the primary blog kind (first from config) - blogKind := cfg.BlogKinds[0] - // Combine wiki and blog kinds for articleKinds (for backward compatibility) - articleKinds := append(cfg.WikiKinds, cfg.BlogKinds...) - wikiService := nostr.NewWikiService(nostrClient, articleKinds, wikiKind, cfg.Relays.AdditionalFallback, cfg.IndexKind, blogKind) - feedService := nostr.NewFeedService(nostrClient, cfg.FeedKind) - issueService := nostr.NewIssueService(nostrClient, cfg.IssueKind, cfg.RepoAnnouncementKind) - ebooksService := nostr.NewEBooksService(nostrClient, cfg.IndexKind, "wss://theforest.nostr1.com") + // Use standard Nostr kind constants + articleKinds := nostr.SupportedArticleKinds() + wikiService := nostr.NewWikiService(nostrClient, articleKinds, nostr.KindWiki, cfg.Relays.AdditionalFallback, nostr.KindIndex, nostr.KindBlog, nostr.KindLongform) + feedService := nostr.NewFeedService(nostrClient, nostr.KindNote) + issueService := nostr.NewIssueService(nostrClient, nostr.KindIssue, nostr.KindRepoAnnouncement) + ebooksService := nostr.NewEBooksService(nostrClient, nostr.KindIndex, "wss://theforest.nostr1.com") // Initialize HTML generator htmlGenerator, err := generator.NewHTMLGenerator( diff --git a/config.yaml.example b/config.yaml.example index 8041945..f3bf092 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -18,11 +18,4 @@ server: seo: site_name: "GitCitadel" site_url: "https://gitcitadel.com" - default_image: "/static/GitCitadel_Graphic_Landscape.png" -wiki_kind: 30818 # Wiki pages -blog_kind: 30041 # Blog articles -longform_kind: 30023 # Markdown articles -index_kind: 30040 # Index event kind (NKBIP-01) -issue_kind: 1621 # Issue event kind -repo_announcement_kind: 30617 # Repository announcement kind -feed_kind: 1 # Feed event kind (notes) \ No newline at end of file + default_image: "/static/GitCitadel_Graphic_Landscape.png" \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index acc1e09..6eed5a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,13 +35,6 @@ type Config struct { SiteURL string `yaml:"site_url"` DefaultImage string `yaml:"default_image"` } `yaml:"seo"` - WikiKinds []int `yaml:"wiki_kinds"` // Supported wiki kinds (default: 30818) - BlogKinds []int `yaml:"blog_kinds"` // Supported blog kinds (default: 30041) - ArticleKinds []int `yaml:"article_kinds"` // Supported article kinds (deprecated, use wiki_kinds and blog_kinds) - IndexKind int `yaml:"index_kind"` // Index event kind (default: 30040) - IssueKind int `yaml:"issue_kind"` // Issue event kind (default: 1621) - RepoAnnouncementKind int `yaml:"repo_announcement_kind"` // Repo announcement kind (default: 30617) - FeedKind int `yaml:"feed_kind"` // Feed event kind (default: 1) } // LoadConfig loads configuration from a YAML file @@ -94,44 +87,6 @@ func LoadConfig(path string) (*Config, error) { config.SEO.DefaultImage = "/static/GitCitadel_Graphic_Landscape.png" } - // Backward compatibility: if ArticleKinds is set, use it to populate WikiKinds and BlogKinds - if len(config.ArticleKinds) > 0 { - config.WikiKinds = []int{} - config.BlogKinds = []int{} - for _, kind := range config.ArticleKinds { - switch kind { - case 30818: - config.WikiKinds = append(config.WikiKinds, kind) - case 30041: - config.BlogKinds = append(config.BlogKinds, kind) - } - } - } - - // Set default wiki kind if not specified - if len(config.WikiKinds) == 0 { - config.WikiKinds = []int{30818} // Default: wiki (30818) - } - - // Set default blog kind if not specified - if len(config.BlogKinds) == 0 { - config.BlogKinds = []int{30041} // Default: blog (30041) - } - - // Set default event kinds if not specified - if config.IndexKind == 0 { - config.IndexKind = 30040 // Default: index (30040) - } - if config.IssueKind == 0 { - config.IssueKind = 1621 // Default: issue (1621) - } - if config.RepoAnnouncementKind == 0 { - config.RepoAnnouncementKind = 30617 // Default: repo announcement (30617) - } - if config.FeedKind == 0 { - config.FeedKind = 1 // Default: feed (1) - } - // Validate required fields if config.WikiIndex == "" { return nil, fmt.Errorf("wiki_index is required") diff --git a/internal/generator/html.go b/internal/generator/html.go index 5ec247d..6a969f1 100644 --- a/internal/generator/html.go +++ b/internal/generator/html.go @@ -7,6 +7,7 @@ import ( "fmt" "html/template" "os" + "os/exec" "path/filepath" "strings" "time" @@ -97,6 +98,7 @@ type PageData struct { WikiPages []WikiPageInfo BlogItems []BlogItemInfo BlogSummary string + ArticleItems []ArticleItemInfo FeedItems []FeedItemInfo EBooks []EBookInfo Content template.HTML @@ -123,6 +125,18 @@ type BlogItemInfo struct { TimeISO string // ISO time } +// ArticleItemInfo represents info about a longform article item +type ArticleItemInfo struct { + DTag string + Title string + Summary string + Content template.HTML + Author string + CreatedAt int64 + Time string // Formatted time + TimeISO string // ISO time +} + // FeedItemInfo represents info about a feed item type FeedItemInfo struct { Author string @@ -231,6 +245,59 @@ func (g *HTMLGenerator) ProcessAsciiDoc(content string) (string, error) { return g.asciidocProc.Process(content) } +// ProcessMarkdown processes Markdown content to HTML using marked via Node.js +func (g *HTMLGenerator) ProcessMarkdown(markdownContent 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 marked + jsCode := ` + const { marked } = require('marked'); + + let content = ''; + process.stdin.setEncoding('utf8'); + + process.stdin.on('data', (chunk) => { + content += chunk; + }); + + process.stdin.on('end', () => { + try { + // Configure marked options + marked.setOptions({ + breaks: true, + gfm: true, + headerIds: true, + mangle: false + }); + + const html = marked.parse(content); + process.stdout.write(html); + } catch (error) { + console.error('Error converting Markdown:', 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(markdownContent) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("marked conversion failed: %w, stderr: %s", err, stderr.String()) + } + + return stdout.String(), nil +} + // GenerateLandingPage generates the static landing page func (g *HTMLGenerator) GenerateLandingPage(wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { // Collect pubkeys from feed items @@ -385,6 +452,67 @@ func (g *HTMLGenerator) GenerateBlogPage(blogIndex *nostr.IndexEvent, blogItems return buf.String(), nil } +// GenerateArticlesPage generates the articles page with longform articles +func (g *HTMLGenerator) GenerateArticlesPage(articleItems []ArticleItemInfo, feedItems []FeedItemInfo) (string, error) { + description := "Longform articles from " + g.siteName + canonicalURL := g.siteURL + "/articles" + + // Format times for article items and collect pubkeys + formattedArticleItems := make([]ArticleItemInfo, len(articleItems)) + pubkeys := make([]string, 0, len(articleItems)) + for i, item := range articleItems { + formattedArticleItems[i] = item + if item.CreatedAt > 0 { + createdTime := time.Unix(item.CreatedAt, 0) + formattedArticleItems[i].Time = createdTime.Format("Jan 2, 2006") + formattedArticleItems[i].TimeISO = createdTime.Format(time.RFC3339) + } + if item.Author != "" { + pubkeys = append(pubkeys, item.Author) + } + } + + // Fetch profiles for article authors + ctx := context.Background() + profiles := g.fetchProfilesBatch(ctx, pubkeys) + + data := PageData{ + Title: "Articles", + Description: description, + CanonicalURL: canonicalURL, + OGImage: g.siteURL + g.defaultImage, + OGType: "website", + SiteName: g.siteName, + SiteURL: g.siteURL, + CurrentYear: time.Now().Year(), + ArticleItems: formattedArticleItems, + FeedItems: []FeedItemInfo{}, // Empty - feed only on landing page + Content: template.HTML(""), + Profiles: profiles, + } + + // Use renderTemplate but with custom data + renderTmpl := template.New("articles-render").Funcs(getTemplateFuncs()) + + files := []string{ + filepath.Join(g.templateDir, "components.html"), + filepath.Join(g.templateDir, "base.html"), + filepath.Join(g.templateDir, "articles.html"), + } + + _, err := renderTmpl.ParseFiles(files...) + if err != nil { + return "", fmt.Errorf("failed to parse articles templates: %w", err) + } + + var buf bytes.Buffer + if err := renderTmpl.ExecuteTemplate(&buf, "base.html", data); err != nil { + return "", fmt.Errorf("failed to execute articles template: %w", err) + } + + return buf.String(), nil +} + // GenerateWikiIndexPage generates the wiki index page func (g *HTMLGenerator) GenerateWikiIndexPage(wikiIndex *nostr.IndexEvent, wikiPages []WikiPageInfo, feedItems []FeedItemInfo) (string, error) { description := "Wiki documentation from " + g.siteName diff --git a/internal/nostr/client.go b/internal/nostr/client.go index 097f5a9..b292914 100644 --- a/internal/nostr/client.go +++ b/internal/nostr/client.go @@ -221,7 +221,7 @@ func (c *Client) HealthCheck(ctx context.Context, timeout time.Duration) error { // Try to fetch a recent event to test connectivity // Note: Using kind 1 for health check (this is a standard kind, not configurable) filter := nostr.Filter{ - Kinds: []int{1}, // kind 1 (notes) for testing + Kinds: []int{KindNote}, // kind 1 (notes) for testing Limit: 1, } diff --git a/internal/nostr/events.go b/internal/nostr/events.go index 617916b..095de18 100644 --- a/internal/nostr/events.go +++ b/internal/nostr/events.go @@ -229,6 +229,56 @@ func ParseBlogEvent(event *nostr.Event, expectedKind int) (*BlogEvent, error) { return blog, nil } +// LongformEvent represents a kind 30023 longform article event +type LongformEvent struct { + Event *nostr.Event + DTag string + Title string + Summary string + Content string +} + +// ParseLongformEvent parses a longform article event +func ParseLongformEvent(event *nostr.Event, expectedKind int) (*LongformEvent, error) { + if event.Kind != expectedKind { + return nil, fmt.Errorf("expected kind %d, got %d", expectedKind, event.Kind) + } + + longform := &LongformEvent{ + 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 { + longform.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 { + longform.Title = tag[1] + break + } + } + if longform.Title == "" { + longform.Title = longform.DTag + } + + // Extract summary tag (optional) + for _, tag := range event.Tags { + if len(tag) > 0 && tag[0] == "summary" && len(tag) > 1 { + longform.Summary = tag[1] + break + } + } + + return longform, nil +} + // NormalizeDTag normalizes a d tag according to NIP-54 rules func NormalizeDTag(dTag string) string { // Convert to lowercase diff --git a/internal/nostr/kinds.go b/internal/nostr/kinds.go new file mode 100644 index 0000000..2b06d4f --- /dev/null +++ b/internal/nostr/kinds.go @@ -0,0 +1,44 @@ +package nostr + +// Standard Nostr event kinds +// These are protocol-level constants and should not be configurable +const ( + // KindProfile is kind 0 - user profile/metadata + KindProfile = 0 + + // KindNote is kind 1 - regular notes/text posts + KindNote = 1 + + // KindWiki is kind 30818 - wiki pages (NIP-54) + KindWiki = 30818 + + // KindBlog is kind 30041 - blog articles + KindBlog = 30041 + + // KindLongform is kind 30023 - longform markdown articles + KindLongform = 30023 + + // KindIndex is kind 30040 - publication index events (NKBIP-01) + KindIndex = 30040 + + // KindIssue is kind 1621 - issue events + KindIssue = 1621 + + // KindRepoAnnouncement is kind 30617 - repository announcement events + KindRepoAnnouncement = 30617 +) + +// SupportedWikiKinds returns the list of supported wiki kinds +func SupportedWikiKinds() []int { + return []int{KindWiki} +} + +// SupportedBlogKinds returns the list of supported blog kinds +func SupportedBlogKinds() []int { + return []int{KindBlog} +} + +// SupportedArticleKinds returns all supported article kinds (wiki + blog) +func SupportedArticleKinds() []int { + return []int{KindWiki, KindBlog} +} diff --git a/internal/nostr/profile.go b/internal/nostr/profile.go index 60b3b96..540ed2b 100644 --- a/internal/nostr/profile.go +++ b/internal/nostr/profile.go @@ -109,7 +109,7 @@ func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[ // Query ALL kind 0 events for these authors in one batch filter := nostr.Filter{ - Kinds: []int{0}, + Kinds: []int{KindProfile}, Authors: uniquePubkeys, Limit: len(uniquePubkeys), // One profile per author } @@ -152,8 +152,8 @@ func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[ // ParseProfile parses a kind 0 profile event func ParseProfile(event *nostr.Event) (*Profile, error) { - if event.Kind != 0 { - return nil, fmt.Errorf("expected kind 0, got %d", event.Kind) + if event.Kind != KindProfile { + return nil, fmt.Errorf("expected kind %d, got %d", KindProfile, event.Kind) } profile := &Profile{ diff --git a/internal/nostr/wiki.go b/internal/nostr/wiki.go index 7fb206f..2fc53e7 100644 --- a/internal/nostr/wiki.go +++ b/internal/nostr/wiki.go @@ -27,17 +27,19 @@ type WikiService struct { articleKinds []int // Allowed article kinds (from config) wikiKind int // Primary wiki kind constant (first from wiki_kinds config) blogKind int // Primary blog kind constant (first from blog_kinds config) + longformKind int // Longform article kind (from config) additionalFallback string // Additional fallback relay URL (from config) indexKind int // Index event kind (from config) } // NewWikiService creates a new wiki service -func NewWikiService(client *Client, articleKinds []int, wikiKind int, additionalFallback string, indexKind int, blogKind int) *WikiService { +func NewWikiService(client *Client, articleKinds []int, wikiKind int, additionalFallback string, indexKind int, blogKind int, longformKind int) *WikiService { return &WikiService{ client: client, articleKinds: articleKinds, wikiKind: wikiKind, blogKind: blogKind, + longformKind: longformKind, additionalFallback: additionalFallback, indexKind: indexKind, } @@ -165,6 +167,53 @@ func (ws *WikiService) GetBlogKind() int { return ws.blogKind } +// GetLongformKind returns the longform kind configured in this service +func (ws *WikiService) GetLongformKind() int { + return ws.longformKind +} + +// FetchLongformArticles fetches the newest longform articles (kind 30023) from a specific relay +// Queries by kind only, sorted by newest first, limit 1000 +func (ws *WikiService) FetchLongformArticles(ctx context.Context, relayURL string, longformKind int, limit int) ([]*nostr.Event, error) { + // Connect to the specific relay + relay, err := ws.client.ConnectToRelay(ctx, relayURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to relay %s: %w", relayURL, err) + } + defer relay.Close() + + // Query ALL events of this kind, sorted by newest first + filter := nostr.Filter{ + Kinds: []int{longformKind}, + Limit: limit, + } + + logFilter(filter, fmt.Sprintf("longform articles (kind %d) from %s", longformKind, relayURL)) + + events, err := relay.QuerySync(ctx, filter) + if err != nil { + return nil, fmt.Errorf("failed to query events: %w", err) + } + + // Sort by created_at descending (newest first) + // Note: go-nostr may already return sorted, but we'll ensure it + for i := 0; i < len(events)-1; i++ { + for j := i + 1; j < len(events); j++ { + if events[i].CreatedAt < events[j].CreatedAt { + events[i], events[j] = events[j], events[i] + } + } + } + + logger.WithFields(map[string]interface{}{ + "events": len(events), + "relay": relayURL, + "kind": longformKind, + }).Debug("Fetched longform articles") + + return events, nil +} + // FetchIndexEvents fetches all events of a specific kind referenced in an index // Only supports article kinds configured in the service // Queries by kind only, then filters locally diff --git a/internal/server/handlers.go b/internal/server/handlers.go index dbf1906..0f51f36 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -28,6 +28,7 @@ func (s *Server) setupRoutes(mux *http.ServeMux) { mux.HandleFunc("/", s.handleLanding) mux.HandleFunc("/wiki/", s.handleWiki) mux.HandleFunc("/blog", s.handleBlog) + mux.HandleFunc("/articles", s.handleArticles) mux.HandleFunc("/ebooks", s.handleEBooks) mux.HandleFunc("/contact", s.handleContact) @@ -95,6 +96,17 @@ func (s *Server) handleBlog(w http.ResponseWriter, r *http.Request) { s.servePage(w, r, page) } +// handleArticles handles the articles page +func (s *Server) handleArticles(w http.ResponseWriter, r *http.Request) { + page, exists := s.cache.Get("/articles") + if !exists { + http.Error(w, "Page not ready", http.StatusServiceUnavailable) + return + } + + s.servePage(w, r, page) +} + // handleEBooks handles the e-books listing page func (s *Server) handleEBooks(w http.ResponseWriter, r *http.Request) { page, exists := s.cache.Get("/ebooks") diff --git a/package-lock.json b/package-lock.json index 917ecce..55a1438 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,10 +5,8 @@ "packages": { "": { "dependencies": { - "@asciidoctor/core": "^3.0.4" - }, - "devDependencies": { - "lucide": "^0.564.0" + "@asciidoctor/core": "^3.0.4", + "marked": "^12.0.0" } }, "node_modules/@asciidoctor/core": { @@ -96,12 +94,17 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/lucide": { - "version": "0.564.0", - "resolved": "https://registry.npmjs.org/lucide/-/lucide-0.564.0.tgz", - "integrity": "sha512-FasyXKHWon773WIl3HeCQpd5xS6E0aLjqxiQStlHNKktni+HDncc1sqY+6vRUbCfmDsIaKQz43EEQLAUDLZO0g==", - "dev": true, - "license": "ISC" + "node_modules/marked": { + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } }, "node_modules/minimatch": { "version": "5.1.6", diff --git a/package.json b/package.json index c6633fe..49a1589 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,6 @@ { "dependencies": { - "@asciidoctor/core": "^3.0.4" - }, - "devDependencies": { - "lucide": "^0.564.0" + "@asciidoctor/core": "^3.0.4", + "marked": "^12.0.0" } } diff --git a/templates/articles.html b/templates/articles.html new file mode 100644 index 0000000..7fd5cb0 --- /dev/null +++ b/templates/articles.html @@ -0,0 +1,130 @@ +{{define "content"}} +
Longform article
+{{$item.Summary}}
{{end}} +{{icon "inbox"}} No articles available yet.
+