Skip to content

Commit

Permalink
Discord mention support (#29)
Browse files Browse the repository at this point in the history
* AST parser, placeholder renderer

* Basic renderer

* Implement proper renderer

* Linter warnings

* Better name for content parser struct
  • Loading branch information
nint8835 authored Dec 21, 2024
1 parent 615d4bf commit 434f5d5
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 20 deletions.
13 changes: 11 additions & 2 deletions cmd/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import (
"log/slog"

"github.com/spf13/cobra"
"github.com/spf13/viper"

"pkg.fogo.sh/almanac/pkg/content"
"pkg.fogo.sh/almanac/pkg/content/extensions"
)

var outputCmd = &cobra.Command{
Expand All @@ -16,14 +18,21 @@ var outputCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
contentDir := must(cmd.Flags().GetString("content-dir"))

pages, err := content.DiscoverPages(contentDir)
resolver, err := extensions.NewDiscordUserResolver(viper.GetString("discord.token"))
if err != nil {
slog.Warn("Failed to create Discord user resolver, Discord user mentions will not be resolved", "error", err)
}

parser := content.Parser{DiscordUserResolver: resolver}

pages, err := parser.DiscoverPages(contentDir)
checkError(err, "failed to discover pages")

outputDir := must(cmd.Flags().GetString("output-dir"))

slog.Info(fmt.Sprintf("discovered %d pages, outputting to %s", len(pages), outputDir))

err = content.OutputAllPagesToDisk(pages, outputDir)
err = parser.OutputAllPagesToDisk(pages, outputDir)
checkError(err, "failed to output pages")

slog.Info("done!")
Expand Down
1 change: 1 addition & 0 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ var serveCmd = &cobra.Command{
DiscordCallbackUrl: viper.GetString("discord.callback_url"),
DiscordGuildId: viper.GetString("discord.guild_id"),
SessionSecret: viper.GetString("discord.session_secret"),
DiscordToken: viper.GetString("discord.token"),
})
err := serverInstance.Start()
checkError(err, "failed to start server")
Expand Down
2 changes: 2 additions & 0 deletions example-content/Riley.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
+++
categories = ["Contributors"]
+++

Discord user: <@106162668032802816>
24 changes: 12 additions & 12 deletions pkg/content/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import (
"strings"
)

func CreateSpecialPages(pages map[string]*Page) error {
func (p *Parser) CreateSpecialPages(pages map[string]*Page) error {
specialPages := make([]string, 0)

pagesByCategory := PagesByCategory(pages)
allCategories := AllCategories(pages)
pagesByCategory := p.PagesByCategory(pages)
allCategories := p.AllCategories(pages)

for _, category := range allCategories {
keysOfPagesInCategory := make([]string, 0, len(pagesByCategory[category]))
Expand Down Expand Up @@ -58,7 +58,7 @@ func CreateSpecialPages(pages map[string]*Page) error {
return nil
}

func DiscoverPages(path string) (map[string]*Page, error) {
func (p *Parser) DiscoverPages(path string) (map[string]*Page, error) {
paths, error := filepath.Glob(filepath.Join(path, "*.md"))

if error != nil {
Expand All @@ -68,25 +68,25 @@ func DiscoverPages(path string) (map[string]*Page, error) {
pages := make(map[string]*Page)

for _, path := range paths {
page, error := ParsePageFile(path)
page, error := p.ParsePageFile(path)
if error != nil {
return nil, fmt.Errorf("failed to parse page: %w", error)
}

pages[page.Title] = &page
}

err := CreateSpecialPages(pages)
err := p.CreateSpecialPages(pages)
if err != nil {
return nil, fmt.Errorf("failed to create special pages: %w", err)
}

PopulateBacklinks(pages)
p.PopulateBacklinks(pages)

return pages, nil
}

func PopulateBacklinks(pages map[string]*Page) {
func (p *Parser) PopulateBacklinks(pages map[string]*Page) {
for _, page := range pages {
for _, link := range page.LinksTo {
if _, ok := pages[link]; ok {
Expand All @@ -113,7 +113,7 @@ func PopulateBacklinks(pages map[string]*Page) {
}
}

func AllPageTitles(pages map[string]*Page) []string {
func (p *Parser) AllPageTitles(pages map[string]*Page) []string {
allPageTitles := make([]string, 0, len(pages))
for key := range pages {
allPageTitles = append(allPageTitles, key)
Expand All @@ -126,7 +126,7 @@ func AllPageTitles(pages map[string]*Page) []string {
return allPageTitles
}

func PagesByCategory(pages map[string]*Page) map[string][]*Page {
func (p *Parser) PagesByCategory(pages map[string]*Page) map[string][]*Page {
pagesByCategory := make(map[string][]*Page)

for _, page := range pages {
Expand All @@ -138,7 +138,7 @@ func PagesByCategory(pages map[string]*Page) map[string][]*Page {
return pagesByCategory
}

func AllCategories(pages map[string]*Page) []string {
func (p *Parser) AllCategories(pages map[string]*Page) []string {
categories := map[string]struct{}{}
for _, page := range pages {
for _, category := range page.Meta.Categories {
Expand All @@ -154,7 +154,7 @@ func AllCategories(pages map[string]*Page) []string {
return keys
}

func FindRootPage(pages map[string]*Page) (*Page, error) {
func (p *Parser) FindRootPage(pages map[string]*Page) (*Page, error) {
var rootPage *Page

for _, page := range pages {
Expand Down
167 changes: 167 additions & 0 deletions pkg/content/extensions/discord_mention.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
package extensions

import (
"fmt"
"log/slog"

"github.com/bwmarrin/discordgo"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)

var DiscordMentionKind = ast.NewNodeKind("DiscordMention")

type DiscordMentionNode struct {
ast.BaseInline

ID string
}

func (m *DiscordMentionNode) Dump(source []byte, level int) {
ast.DumpHelper(
m,
source,
level,
map[string]string{
"ID": m.ID,
},
nil,
)
}

func (m *DiscordMentionNode) Kind() ast.NodeKind {
return DiscordMentionKind
}

var _ ast.Node = (*DiscordMentionNode)(nil)

type discordMentionParser struct{}

func (d discordMentionParser) Trigger() []byte {
return []byte("<")
}

func (d discordMentionParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()

lineHead := 0
userId := ""
inMention := false

for ; lineHead < len(line); lineHead++ {
char := line[lineHead]

if char == '@' {
inMention = true
} else if inMention && char == '>' {
inMention = false
break
} else if inMention {
userId += string(char)
}
}

if inMention || userId == "" {
return nil
}

block.Advance(lineHead + 1)

return &DiscordMentionNode{
ID: userId,
}
}

var _ parser.InlineParser = (*discordMentionParser)(nil)

type discordMentionRenderer struct {
resolver *DiscordUserResolver
}

func (r *discordMentionRenderer) render(
w util.BufWriter, source []byte, n ast.Node, entering bool,
) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}

username := fmt.Sprintf("<@%s>", n.(*DiscordMentionNode).ID)

if r.resolver != nil {
username = r.resolver.Resolve(n.(*DiscordMentionNode).ID)
}

_, err := w.WriteString(username)
if err != nil {
return ast.WalkStop, fmt.Errorf("failed to write string: %w", err)
}

return ast.WalkContinue, nil
}

func (r *discordMentionRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(DiscordMentionKind, r.render)
}

var _ renderer.NodeRenderer = (*discordMentionRenderer)(nil)

type DiscordMention struct {
resolver *DiscordUserResolver
}

func (d *DiscordMention) Extend(m goldmark.Markdown) {
m.Renderer().
AddOptions(renderer.WithNodeRenderers(util.Prioritized(&discordMentionRenderer{resolver: d.resolver}, 500)))
m.Parser().
AddOptions(parser.WithInlineParsers(util.Prioritized(&discordMentionParser{}, 500)))
}

type DiscordUserResolver struct {
cache map[string]string
discordClient *discordgo.Session
}

func (r *DiscordUserResolver) Resolve(userId string) string {
if cachedVal, ok := r.cache[userId]; ok {
return cachedVal
}

slog.Info("Resolving user for the first time, subsequent mentions will be cached...", slog.String("user_id", userId))

var username string
user, err := r.discordClient.User(userId)
if err != nil {
username = fmt.Sprintf("<@%s>", userId)
slog.Error("Failed to resolve user", "error", err)
} else {
username = user.Username
}

r.cache[userId] = username

return username
}

func NewDiscordUserResolver(botToken string) (*DiscordUserResolver, error) {
if botToken == "" {
return nil, fmt.Errorf("bot token cannot be empty")
}

discordClient, err := discordgo.New("Bot " + botToken)
if err != nil {
return nil, fmt.Errorf("failed to create Discord client: %w", err)
}

return &DiscordUserResolver{
cache: make(map[string]string),
discordClient: discordClient,
}, nil
}

func NewDiscordMention(resolver *DiscordUserResolver) goldmark.Extender {
return &DiscordMention{resolver: resolver}
}
4 changes: 2 additions & 2 deletions pkg/content/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import (
cp "github.com/otiai10/copy"
)

func OutputAllPagesToDisk(pages map[string]*Page, outputDir string) error {
func (p *Parser) OutputAllPagesToDisk(pages map[string]*Page, outputDir string) error {
os.RemoveAll(outputDir)

err := os.MkdirAll(outputDir, 0755)
if err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}

allPageTitles := AllPageTitles(pages)
allPageTitles := p.AllPageTitles(pages)

for key, page := range pages {
outputPath := outputDir + key + ".html"
Expand Down
8 changes: 7 additions & 1 deletion pkg/content/parsing.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"go.abhg.dev/goldmark/frontmatter"
"go.abhg.dev/goldmark/wikilink"

"pkg.fogo.sh/almanac/pkg/content/extensions"
"pkg.fogo.sh/almanac/pkg/utils"
)

Expand All @@ -33,7 +34,11 @@ type Page struct {
ParsedContent []byte
}

func ParsePageFile(path string) (Page, error) {
type Parser struct {
DiscordUserResolver *extensions.DiscordUserResolver
}

func (p *Parser) ParsePageFile(path string) (Page, error) {
f, err := os.Open(path)
if err != nil {
return Page{}, fmt.Errorf("failed to open file: %w", err)
Expand Down Expand Up @@ -63,6 +68,7 @@ func ParsePageFile(path string) (Page, error) {
},
},
},
extensions.NewDiscordMention(p.DiscordUserResolver),
))

ctx := parser.NewContext()
Expand Down
Loading

0 comments on commit 434f5d5

Please sign in to comment.