Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/mdv-gui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
79 changes: 52 additions & 27 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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"),
Expand All @@ -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")
}
Expand Down
58 changes: 58 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
50 changes: 50 additions & 0 deletions internal/render/goldmark_extensions_unix.go
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions internal/render/goldmark_extensions_windows.go
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 31 additions & 15 deletions internal/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down