From ff0372bbe45f3a7ecec876c5415b330a65a7a896 Mon Sep 17 00:00:00 2001 From: Atlas Date: Wed, 8 Oct 2025 11:12:29 -0400 Subject: [PATCH] Add configurable Goldmark extensions via plugins --- CLAUDE.md | 4 +- README.md | 29 +++++++ cmd/mdv-gui/app.go | 2 +- internal/config/config.go | 79 ++++++++++++------- internal/config/config_test.go | 58 ++++++++++++++ internal/render/goldmark_extensions_unix.go | 50 ++++++++++++ .../render/goldmark_extensions_windows.go | 17 ++++ internal/render/render.go | 46 +++++++---- 8 files changed, 240 insertions(+), 45 deletions(-) create mode 100644 internal/config/config_test.go create mode 100644 internal/render/goldmark_extensions_unix.go create mode 100644 internal/render/goldmark_extensions_windows.go diff --git a/CLAUDE.md b/CLAUDE.md index fb556cc..add3579 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,13 +68,13 @@ task clean - Uses Viper for layered config (flags > directory .mdv.yaml > local .mdv.yaml > ~/.config/mdv/config.yaml > env vars) - Directory-specific configs: when viewing a file, mdv checks for .mdv.yaml in that file's directory - Environment variables: MDV_THEME, MDV_THEME_LIGHT, MDV_THEME_DARK, MDV_WRAP, MDV_WATCH, MDV_GUI, MDV_EXCLUDE, MDV_EDITOR - - Config struct: Theme, ThemeLight, ThemeDark, Wrap, GUI, Watch, Exclude, File, Editor + - Config struct: Theme, ThemeLight, ThemeDark, Wrap, GUI, Watch, Exclude, File, Editor, GoldmarkExtensions (plugin metadata) - Setting `gui: true` in config makes `mdv` launch in GUI mode by default (equivalent to `-g` flag) 4. **internal/render/** - Markdown rendering - `ToANSI()`: Converts markdown to ANSI for terminal (uses Glamour) - `ToHTML()`: Converts markdown to HTML for GUI (uses Goldmark) - - Goldmark configured with GitHub Flavored Markdown extensions + - Goldmark configured with GitHub Flavored Markdown extensions and can load user-provided plugins from config - Theme auto-detection: checks macOS AppleInterfaceStyle or Linux COLORFGBG (internal/render/render.go:33-63) ### Key Features diff --git a/README.md b/README.md index 3145afc..4069ea2 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,37 @@ exclude: - README.md - "draft-*.md" - "*-wip.md" + +# Load additional Goldmark extensions compiled as Go plugins +# goldmark: +# extensions: +# - path: "~/.config/mdv/extensions/alerts.so" +# symbol: "Extension" # optional, defaults to "Extension" +``` + +### Custom Goldmark extensions + +The GUI renderer uses [Goldmark](https://github.com/yuin/goldmark). You can +load additional Goldmark extensions by compiling them as Go plugins and listing +them in your config file. Each extension entry must point to a `.so` file built +with `go build -buildmode=plugin` that exports either a `goldmark.Extender` +value or a `func() goldmark.Extender` under the configured symbol name (default: +`Extension`). + +```bash +go build -buildmode=plugin -o ~/.config/mdv/extensions/alerts.so ./cmd/alerts ``` +```yaml +goldmark: + extensions: + - path: "~/.config/mdv/extensions/alerts.so" + symbol: "Extension" # optional override +``` + +> **Note:** Loading plugins is not supported on Windows. mdv will return an +> error at runtime if Goldmark extensions are configured on that platform. + ### Environment Variables ```bash diff --git a/cmd/mdv-gui/app.go b/cmd/mdv-gui/app.go index 43d8994..a075c0e 100644 --- a/cmd/mdv-gui/app.go +++ b/cmd/mdv-gui/app.go @@ -61,7 +61,7 @@ func (a *App) load(path string) error { if err != nil { return err } - htmlBytes, err := render.ToHTML(b, a.cfg.GUITheme, a.cfg.GUIThemeLight, a.cfg.GUIThemeDark, a.cfg.GUIWidth) + htmlBytes, err := render.ToHTML(b, a.cfg.GUITheme, a.cfg.GUIThemeLight, a.cfg.GUIThemeDark, a.cfg.GUIWidth, a.cfg.GoldmarkExtensions) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 8ee88c5..922cb23 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,19 +11,29 @@ import ( // Config is what the rest of your app reads. type Config struct { - Theme string // "dark", "light", "auto", or custom (for TUI/Glamour) - ThemeLight string // theme to use when system is in light mode (only applies when Theme is "auto") - ThemeDark string // theme to use when system is in dark mode (only applies when Theme is "auto") - GUITheme string // "dark", "light", "auto", or custom CSS file (for GUI/Goldmark) - GUIThemeLight string // GUI theme to use when system is in light mode (only applies when GUITheme is "auto") - GUIThemeDark string // GUI theme to use when system is in dark mode (only applies when GUITheme is "auto") - GUIWidth string // "narrow", "medium", "wide", "full" - content width for GUI - Wrap int // wrap width for terminal rendering - GUI bool // open GUI (Wails) instead of TUI - Watch bool // auto-reload on file change - Exclude []string // glob patterns for files to exclude - File string // markdown file path (positional arg) - Editor string // editor command to open files (defaults to $EDITOR or "vim") + Theme string // "dark", "light", "auto", or custom (for TUI/Glamour) + ThemeLight string // theme to use when system is in light mode (only applies when Theme is "auto") + ThemeDark string // theme to use when system is in dark mode (only applies when Theme is "auto") + GUITheme string // "dark", "light", "auto", or custom CSS file (for GUI/Goldmark) + GUIThemeLight string // GUI theme to use when system is in light mode (only applies when GUITheme is "auto") + GUIThemeDark string // GUI theme to use when system is in dark mode (only applies when GUITheme is "auto") + GUIWidth string // "narrow", "medium", "wide", "full" - content width for GUI + Wrap int // wrap width for terminal rendering + GUI bool // open GUI (Wails) instead of TUI + Watch bool // auto-reload on file change + Exclude []string // glob patterns for files to exclude + File string // markdown file path (positional arg) + Editor string // editor command to open files (defaults to $EDITOR or "vim") + GoldmarkExtensions []GoldmarkExtension // custom Goldmark extensions to load when rendering HTML +} + +// GoldmarkExtension describes a dynamically loaded Goldmark extension. +// Users can provide shared objects compiled with -buildmode=plugin that expose +// either a goldmark.Extender or a func() goldmark.Extender under the configured +// symbol name (defaults to "Extension"). +type GoldmarkExtension struct { + Path string `mapstructure:"path"` + Symbol string `mapstructure:"symbol"` } // NewViper sets up Viper with sensible defaults and search paths. @@ -117,22 +127,22 @@ func MergeDirectoryConfig(v *viper.Viper, dir string) { } } -// resolveThemePath resolves a theme path relative to the config directory. +// resolveConfigPath resolves a path relative to the config directory. // If the path is absolute or home-relative (~), returns it as-is. // If the path is relative, tries to resolve it relative to configDir. // If the resolved file exists, returns the absolute path; otherwise returns the original path. -func resolveThemePath(themePath, configDir string) string { - if themePath == "" { - return themePath +func resolveConfigPath(pathValue, configDir string) string { + if pathValue == "" { + return pathValue } // If absolute path or starts with ~, return as-is (will be handled by render package) - if filepath.IsAbs(themePath) || themePath[0] == '~' { - return themePath + if filepath.IsAbs(pathValue) || pathValue[0] == '~' { + return pathValue } // If relative path, try to resolve relative to config directory - resolvedPath := filepath.Join(configDir, themePath) + resolvedPath := filepath.Join(configDir, pathValue) // Check if the resolved path exists if _, err := os.Stat(resolvedPath); err == nil { @@ -145,19 +155,19 @@ func resolveThemePath(themePath, configDir string) string { // File doesn't exist or error getting absolute path, return original // (might be a built-in theme name) - return themePath + return pathValue } // Decode pulls values from Viper into a typed Config. // configDir is the directory containing the config file being used (typically the directory of the file being viewed). func Decode(v *viper.Viper, fileArg string, configDir string) (Config, error) { cfg := Config{ - Theme: resolveThemePath(v.GetString("theme"), configDir), - ThemeLight: resolveThemePath(v.GetString("theme-light"), configDir), - ThemeDark: resolveThemePath(v.GetString("theme-dark"), configDir), - GUITheme: resolveThemePath(v.GetString("gui-theme"), configDir), - GUIThemeLight: resolveThemePath(v.GetString("gui-theme-light"), configDir), - GUIThemeDark: resolveThemePath(v.GetString("gui-theme-dark"), configDir), + Theme: resolveConfigPath(v.GetString("theme"), configDir), + ThemeLight: resolveConfigPath(v.GetString("theme-light"), configDir), + ThemeDark: resolveConfigPath(v.GetString("theme-dark"), configDir), + GUITheme: resolveConfigPath(v.GetString("gui-theme"), configDir), + GUIThemeLight: resolveConfigPath(v.GetString("gui-theme-light"), configDir), + GUIThemeDark: resolveConfigPath(v.GetString("gui-theme-dark"), configDir), GUIWidth: v.GetString("gui-width"), Wrap: v.GetInt("wrap"), GUI: v.GetBool("gui"), @@ -166,6 +176,21 @@ func Decode(v *viper.Viper, fileArg string, configDir string) (Config, error) { File: fileArg, Editor: v.GetString("editor"), } + + var goldmarkConfig struct { + Extensions []GoldmarkExtension `mapstructure:"extensions"` + } + if err := v.UnmarshalKey("goldmark", &goldmarkConfig); err != nil { + return cfg, fmt.Errorf("invalid goldmark config: %w", err) + } + for i := range goldmarkConfig.Extensions { + goldmarkConfig.Extensions[i].Path = resolveConfigPath(goldmarkConfig.Extensions[i].Path, configDir) + if goldmarkConfig.Extensions[i].Symbol == "" { + goldmarkConfig.Extensions[i].Symbol = "Extension" + } + } + cfg.GoldmarkExtensions = goldmarkConfig.Extensions + if cfg.Wrap < 0 { return cfg, fmt.Errorf("wrap must be >= 0") } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..e3acd15 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,58 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" +) + +func TestDecodeGoldmarkExtensions(t *testing.T) { + dir := t.TempDir() + + pluginA := filepath.Join(dir, "extA.so") + if err := os.WriteFile(pluginA, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create pluginA: %v", err) + } + + nestedDir := filepath.Join(dir, "nested") + if err := os.MkdirAll(nestedDir, 0o755); err != nil { + t.Fatalf("failed to create nested dir: %v", err) + } + pluginB := filepath.Join(nestedDir, "extB.so") + if err := os.WriteFile(pluginB, []byte("test"), 0o644); err != nil { + t.Fatalf("failed to create pluginB: %v", err) + } + + v := viper.New() + v.Set("goldmark", map[string]any{ + "extensions": []map[string]any{ + {"path": filepath.Base(pluginA)}, + {"path": filepath.Join("nested", "extB.so"), "symbol": "Custom"}, + }, + }) + + cfg, err := Decode(v, "", dir) + if err != nil { + t.Fatalf("Decode returned error: %v", err) + } + + if len(cfg.GoldmarkExtensions) != 2 { + t.Fatalf("expected 2 extensions, got %d", len(cfg.GoldmarkExtensions)) + } + + if cfg.GoldmarkExtensions[0].Path != pluginA { + t.Fatalf("expected first extension path %q, got %q", pluginA, cfg.GoldmarkExtensions[0].Path) + } + if cfg.GoldmarkExtensions[0].Symbol != "Extension" { + t.Fatalf("expected default symbol 'Extension', got %q", cfg.GoldmarkExtensions[0].Symbol) + } + + if cfg.GoldmarkExtensions[1].Path != pluginB { + t.Fatalf("expected second extension path %q, got %q", pluginB, cfg.GoldmarkExtensions[1].Path) + } + if cfg.GoldmarkExtensions[1].Symbol != "Custom" { + t.Fatalf("expected custom symbol 'Custom', got %q", cfg.GoldmarkExtensions[1].Symbol) + } +} diff --git a/internal/render/goldmark_extensions_unix.go b/internal/render/goldmark_extensions_unix.go new file mode 100644 index 0000000..c585362 --- /dev/null +++ b/internal/render/goldmark_extensions_unix.go @@ -0,0 +1,50 @@ +//go:build !windows + +package render + +import ( + "fmt" + "plugin" + + "github.com/iiatlas/mdv/internal/config" + "github.com/yuin/goldmark" +) + +func loadCustomExtensions(extConfigs []config.GoldmarkExtension) ([]goldmark.Extender, error) { + if len(extConfigs) == 0 { + return nil, nil + } + + extensions := make([]goldmark.Extender, 0, len(extConfigs)) + for idx, extCfg := range extConfigs { + if extCfg.Path == "" { + return nil, fmt.Errorf("goldmark extension %d has empty path", idx) + } + + mod, err := plugin.Open(extCfg.Path) + if err != nil { + return nil, fmt.Errorf("load goldmark extension %q: %w", extCfg.Path, err) + } + + symbolName := extCfg.Symbol + if symbolName == "" { + symbolName = "Extension" + } + + sym, err := mod.Lookup(symbolName) + if err != nil { + return nil, fmt.Errorf("lookup symbol %q in %q: %w", symbolName, extCfg.Path, err) + } + + switch ext := sym.(type) { + case goldmark.Extender: + extensions = append(extensions, ext) + case func() goldmark.Extender: + extensions = append(extensions, ext()) + default: + return nil, fmt.Errorf("symbol %q in %q does not implement goldmark.Extender", symbolName, extCfg.Path) + } + } + + return extensions, nil +} diff --git a/internal/render/goldmark_extensions_windows.go b/internal/render/goldmark_extensions_windows.go new file mode 100644 index 0000000..e89f44e --- /dev/null +++ b/internal/render/goldmark_extensions_windows.go @@ -0,0 +1,17 @@ +//go:build windows + +package render + +import ( + "fmt" + + "github.com/iiatlas/mdv/internal/config" + "github.com/yuin/goldmark" +) + +func loadCustomExtensions(extConfigs []config.GoldmarkExtension) ([]goldmark.Extender, error) { + if len(extConfigs) > 0 { + return nil, fmt.Errorf("goldmark extensions via plugins are not supported on Windows") + } + return nil, nil +} diff --git a/internal/render/render.go b/internal/render/render.go index 17766ec..b906f22 100644 --- a/internal/render/render.go +++ b/internal/render/render.go @@ -10,26 +10,37 @@ import ( "strings" "github.com/charmbracelet/glamour" + "github.com/iiatlas/mdv/internal/config" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/renderer/html" ) -var md = goldmark.New( - goldmark.WithExtensions( - extension.GFM, // tables, strikethrough, task lists - extension.Table, // explicit table extension (redundant, ok) - extension.Linkify, // autolink URLs - extension.Strikethrough, // ~~del~~ - ), - goldmark.WithParserOptions( - parser.WithAutoHeadingID(), - ), - goldmark.WithRendererOptions( - html.WithUnsafe(), // allow inline HTML in markdown - ), -) +func newGoldmark(extConfigs []config.GoldmarkExtension) (goldmark.Markdown, error) { + baseExtensions := []goldmark.Extender{ + extension.GFM, + extension.Table, + extension.Linkify, + extension.Strikethrough, + } + + customExtensions, err := loadCustomExtensions(extConfigs) + if err != nil { + return nil, err + } + baseExtensions = append(baseExtensions, customExtensions...) + + return goldmark.New( + goldmark.WithExtensions(baseExtensions...), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions( + html.WithUnsafe(), // allow inline HTML in markdown + ), + ), nil +} // detectSystemTheme detects the system's dark/light mode preference func detectSystemTheme() string { @@ -87,7 +98,12 @@ func ResolveTheme(theme, themeLight, themeDark string) string { return detected } -func ToHTML(src []byte, theme, themeLight, themeDark, width string) ([]byte, error) { +func ToHTML(src []byte, theme, themeLight, themeDark, width string, extensions []config.GoldmarkExtension) ([]byte, error) { + md, err := newGoldmark(extensions) + if err != nil { + return nil, err + } + // Convert markdown to HTML var buf bytes.Buffer if err := md.Convert(src, &buf); err != nil {