diff --git a/README.md b/README.md index 042a17d..05da79f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ How markdown-proxy compares to other Markdown viewing tools: | PlantUML diagrams | ✅ | ✅ | ❌ | ❌ | ❌ | | Math rendering | ✅ (KaTeX) | ✅ (KaTeX/MathJax) | ❌ | ❌ | ❌ | | Code highlighting | ✅ | ✅ | ✅ | ✅ | ✅ | -| CSS themes | 3 built-in | 15+ built-in | GitHub only | Customizable | 5 built-in | +| CSS themes | 3 built-in + user-defined | 15+ built-in | GitHub only | Customizable | 5 built-in | | Full-text search | ❌ | ❌ | ❌ | ✅ | ❌ | | Export (PDF, HTML) | △ ² | ✅ (PDF, HTML, Word) | ✅ (HTML) | ❌ | ❌ | | Authentication | Token-based | — | — | HTTP Basic | ❌ | @@ -65,7 +65,7 @@ How markdown-proxy compares to other Markdown viewing tools: - Toolbar actions - Print: browser print with clean filename (toolbar hidden in print output) - Source: link to original URL on remote server (remote pages only) -- Multiple CSS themes (GitHub, Simple, Dark) with switching UI +- CSS themes with dropdown switching — 3 built-in themes (GitHub, Simple, Dark) plus user-defined themes - Table of contents sidebar: toggle `TOC` in the toolbar to open a right-side panel with auto-extracted headings; visibility persists per browser (localStorage) - Live reload for local files (auto-refreshes browser on file changes) - Directory listing for local files @@ -152,7 +152,7 @@ When `file-or-url` is provided: |------|-------------|---------| | `-port`, `-p` | Listen port | `9080` | | `-listen` | Bind address (`127.0.0.1` for local, `0.0.0.0` for remote) | `127.0.0.1` | -| `-theme` | Default CSS theme (`github`, `simple`, `dark`) | `github` | +| `-theme` | Default CSS theme name (any file in the themes directory) | `github` | | `-plantuml-server` | PlantUML server URL | (disabled) | | `-auth-token` | Authentication token (required in remote mode) | | | `-auth-cookie-max-age` | Authentication cookie max age in days | `30` | @@ -198,6 +198,49 @@ The configuration file stores these settings as JSON: Command-line flags override configuration file values. Security-sensitive settings (`-auth-token`, `-access-log`, etc.) are not stored in the config file. +## CSS Themes + +markdown-proxy supports user-defined CSS themes in addition to the three built-in themes. + +### Theme Directory + +| Platform | Path | +|----------|------| +| Linux | `~/.config/markdown-proxy/themes/` | +| Windows | `%APPDATA%/markdown-proxy/themes/` | + +On first launch, the three built-in themes are written here as editable CSS files: + +- `github.css` — GitHub-style light theme +- `simple.css` — Serif light theme +- `dark.css` — Dark theme + +### Adding a Custom Theme + +1. Create a `.css` file in the themes directory, e.g. `~/.config/markdown-proxy/themes/mycolor.css` +2. Write standard CSS (no special scoping required — the file is loaded as the sole active stylesheet) +3. Restart markdown-proxy (or simply open a new page — the theme list is read at startup) +4. Select `mycolor` from the Theme dropdown + +**Example:** + +```css +body { font-family: "Source Serif Pro", serif; color: #1a1a1a; background: #fffff8; } +.toolbar { background: #f0ece0; border-color: #c8bfa0; } +.home-link, .toolbar-link { color: #8b4513; } +.markdown-body a { color: #8b4513; } +.markdown-body pre { background: #f5f0e8; border: 1px solid #c8bfa0; overflow: auto; } +.markdown-body pre code { background: none; padding: 0; font-size: 100%; } +.copy-btn { background: #fff; color: #444; border-color: #c8bfa0; } +.copy-btn:hover { background: #f0ece0; } +.copy-btn.copied { background: #d4edda; color: #155724; border-color: #c3e6cb; } +.toc-panel { background: #f5f0e8; border-left-color: #c8bfa0; } +.toc-header { border-bottom-color: #c8bfa0; } +.toc-list a.active { border-left-color: #8b4513; background: rgba(139,69,19,0.08); } +``` + +You can also edit the built-in themes directly — changes take effect on the next request (the file is read from disk on each theme CSS request). + ## Operation Modes ### Local Mode (default) @@ -396,5 +439,6 @@ internal/ markdown/ - Markdown→HTML conversion, link rewriting, code block processing credential/ - git credential helper integration github/ - GitHub/GitLab URL resolution - template/ - HTML templates and CSS themes + template/ - HTML templates and structural CSS + themes/ - Theme management (built-in CSS generation, file I/O) ``` diff --git a/internal/handler/local.go b/internal/handler/local.go index b5a3c25..2ded195 100644 --- a/internal/handler/local.go +++ b/internal/handler/local.go @@ -15,11 +15,12 @@ import ( ) type LocalHandler struct { - cfg *config.Config + cfg *config.Config + themes []string } -func NewLocalHandler(cfg *config.Config) *LocalHandler { - return &LocalHandler{cfg: cfg} +func NewLocalHandler(cfg *config.Config, themes []string) *LocalHandler { + return &LocalHandler{cfg: cfg, themes: themes} } func (h *LocalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -82,6 +83,7 @@ func (h *LocalHandler) serveFile(w http.ResponseWriter, filePath string) { Title: filepath.Base(filePath), Content: template.HTML(htmlContent), Theme: h.cfg.Theme, + Themes: h.themes, WatchPath: filePath, }) if err != nil { @@ -118,6 +120,7 @@ func (h *LocalHandler) serveFile(w http.ResponseWriter, filePath string) { Title: filepath.Base(filePath), Content: template.HTML(htmlContent), Theme: h.cfg.Theme, + Themes: h.themes, WatchPath: filePath, }) if err != nil { @@ -178,6 +181,7 @@ func (h *LocalHandler) serveDirectory(w http.ResponseWriter, dirPath string) { Path: dirPath, Entries: dirEntries, Theme: h.cfg.Theme, + Themes: h.themes, WatchPath: dirPath, }) if err != nil { diff --git a/internal/handler/remote.go b/internal/handler/remote.go index 5f9cc16..10851c1 100644 --- a/internal/handler/remote.go +++ b/internal/handler/remote.go @@ -20,10 +20,11 @@ import ( type RemoteHandler struct { cfg *config.Config client *http.Client + themes []string } -func NewRemoteHandler(cfg *config.Config, client *http.Client) *RemoteHandler { - return &RemoteHandler{cfg: cfg, client: client} +func NewRemoteHandler(cfg *config.Config, client *http.Client, themes []string) *RemoteHandler { + return &RemoteHandler{cfg: cfg, client: client, themes: themes} } func (h *RemoteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -112,6 +113,7 @@ func (h *RemoteHandler) renderMarkdownResponse(w http.ResponseWriter, body []byt Title: path.Base(remotePath) + " - README.md", Content: template.HTML(htmlContent), Theme: h.cfg.Theme, + Themes: h.themes, SourceURL: scheme + "://" + remotePath, }) if err != nil { @@ -138,6 +140,7 @@ func (h *RemoteHandler) renderResponse(w http.ResponseWriter, body []byte, conte Title: path.Base(remotePath), Content: template.HTML(htmlContent), Theme: h.cfg.Theme, + Themes: h.themes, SourceURL: scheme + "://" + remotePath, }) if err != nil { @@ -172,6 +175,7 @@ func (h *RemoteHandler) renderResponse(w http.ResponseWriter, body []byte, conte Title: path.Base(remotePath), Content: template.HTML(htmlContent), Theme: h.cfg.Theme, + Themes: h.themes, SourceURL: scheme + "://" + remotePath, }) if err != nil { @@ -367,6 +371,7 @@ func (h *RemoteHandler) renderAuthError(w http.ResponseWriter, ae *authError) { page, err := tmpl.RenderError(&tmpl.ErrorPageData{ Title: "Access Denied", Theme: h.cfg.Theme, + Themes: h.themes, Status: statusCode, Message: message, Hints: hints, diff --git a/internal/server/server.go b/internal/server/server.go index d1a66e2..6c7b5a6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,6 +7,7 @@ import ( "log" "net" "net/http" + "net/url" "os" "strings" "time" @@ -14,18 +15,54 @@ import ( "github.com/patakuti/markdown-proxy/internal/config" "github.com/patakuti/markdown-proxy/internal/handler" "github.com/patakuti/markdown-proxy/internal/network" + "github.com/patakuti/markdown-proxy/internal/themes" "gopkg.in/natefinch/lumberjack.v2" ) func Run(cfg *config.Config) error { + // Set up themes directory and load available themes. + themesDir, err := themes.DefaultThemesDir() + if err != nil { + log.Printf("Warning: could not determine themes directory: %v", err) + } else { + if err := themes.EnsureBuiltinThemes(themesDir); err != nil { + log.Printf("Warning: could not write built-in themes: %v", err) + } + } + themeList := themes.ListThemes(themesDir) + mux := http.NewServeMux() + // Serve theme CSS files from the themes directory. + mux.HandleFunc("/_theme/", func(w http.ResponseWriter, r *http.Request) { + // Use url.PathUnescape and path.Base equivalent for safety. + raw := strings.TrimPrefix(r.URL.Path, "/_theme/") + name, _ := url.PathUnescape(raw) + name = strings.TrimSuffix(name, ".css") + // Only allow names without path separators or dots. + if strings.ContainsAny(name, "/\\.") || name == "" { + http.NotFound(w, r) + return + } + data, readErr := themes.ReadTheme(themesDir, name) + if readErr != nil { + // Fall back to in-memory built-in. + data = themes.BuiltinCSS(name) + } + if data == nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/css; charset=utf-8") + w.Write(data) + }) + // In local mode, allow private network access (user is local). // In remote mode, block private network access (SSRF prevention). client := network.NewSafeClient(!cfg.IsRemoteMode()) topHandler := handler.NewTopHandler(cfg) - remoteHandler := handler.NewRemoteHandler(cfg, client) + remoteHandler := handler.NewRemoteHandler(cfg, client, themeList) mux.HandleFunc("/", topHandler.ServeHTTP) mux.HandleFunc("/http/", remoteHandler.ServeHTTP) @@ -44,7 +81,7 @@ func Run(cfg *config.Config) error { mux.HandleFunc("/_login", loginHandler.ServeHTTP) } else { // In local mode, enable local file access and SSE - localHandler := handler.NewLocalHandler(cfg) + localHandler := handler.NewLocalHandler(cfg, themeList) sseHandler := handler.NewSSEHandler() mux.HandleFunc("/local/", localHandler.ServeHTTP) mux.HandleFunc("/_sse", sseHandler.ServeHTTP) diff --git a/internal/template/copybutton.go b/internal/template/copybutton.go index 6eb5f8a..30a1dbc 100644 --- a/internal/template/copybutton.go +++ b/internal/template/copybutton.go @@ -24,37 +24,6 @@ const copyButtonCSS = ` .copy-btn.copied { opacity: 1; } -.theme-github .copy-btn, -.theme-simple .copy-btn { - background: #fff; - color: #444; - border-color: #d0d7de; -} -.theme-github .copy-btn:hover, -.theme-simple .copy-btn:hover { - background: #f3f4f6; - border-color: #adb5bd; -} -.theme-github .copy-btn.copied, -.theme-simple .copy-btn.copied { - background: #d4edda; - color: #155724; - border-color: #c3e6cb; -} -.theme-dark .copy-btn { - background: #21262d; - color: #c9d1d9; - border-color: #30363d; -} -.theme-dark .copy-btn:hover { - background: #2d333b; - border-color: #6e7681; -} -.theme-dark .copy-btn.copied { - background: #1a3d2b; - color: #3fb950; - border-color: #2ea043; -} @media print { .copy-btn { display: none !important; } } diff --git a/internal/template/css.go b/internal/template/css.go index 7af3368..c121830 100644 --- a/internal/template/css.go +++ b/internal/template/css.go @@ -1,5 +1,7 @@ package template +// commonCSS contains structural layout rules only (no colors). +// Visual styling (colors, fonts, backgrounds) is provided by per-theme CSS files. const commonCSS = ` * { box-sizing: border-box; } body { margin: 0; padding: 0; } @@ -7,17 +9,11 @@ body { margin: 0; padding: 0; } position: sticky; top: 0; z-index: 100; display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; - border-bottom: 1px solid #e1e4e8; - background: #f6f8fa; + border-bottom: 1px solid; } -.theme-dark .toolbar { background: #1e1e1e; border-color: #444; } .home-link { text-decoration: none; font-weight: bold; font-size: 14px; } -.theme-github .home-link, .theme-simple .home-link { color: #0366d6; } -.theme-dark .home-link { color: #58a6ff; } .toolbar-actions { display: flex; align-items: center; gap: 12px; } .toolbar-link { font-size: 13px; text-decoration: none; } -.theme-github .toolbar-link, .theme-simple .toolbar-link { color: #0366d6; } -.theme-dark .toolbar-link { color: #58a6ff; } .toolbar-link:hover { text-decoration: underline; } .theme-switcher { display: flex; align-items: center; gap: 6px; font-size: 13px; } .theme-switcher select { padding: 2px 6px; font-size: 13px; } @@ -35,30 +31,13 @@ body { margin: 0; padding: 0; } border-radius: 6px; font-size: 14px; line-height: 1.5; + border: 1px solid; } .plantuml-notice code { padding: .2em .4em; border-radius: 3px; font-size: 85%; } -.theme-github .plantuml-notice, -.theme-simple .plantuml-notice { - background: #fff8c5; - border: 1px solid #d4a72c; - color: #4d3800; -} -.theme-github .plantuml-notice code, -.theme-simple .plantuml-notice code { - background: rgba(0,0,0,.08); -} -.theme-dark .plantuml-notice { - background: #2d2a1e; - border: 1px solid #966c00; - color: #e3b341; -} -.theme-dark .plantuml-notice code { - background: rgba(255,255,255,.1); -} .markdown-body { max-width: 980px; margin: 0 auto; @@ -70,113 +49,7 @@ body { margin: 0; padding: 0; } } .markdown-body table th, .markdown-body table td { - border: 1px solid #dfe2e5; + border: 1px solid; padding: 6px 13px; } -.markdown-body table tr:nth-child(2n) { - background-color: #f6f8fa; -} -.theme-dark .markdown-body table tr:nth-child(2n) { - background-color: #2d2d2d; -} -.theme-dark .markdown-body table th, -.theme-dark .markdown-body table td { - border-color: #444; -} -` - -const githubCSS = ` -.theme-github { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - color: #24292e; - background: #fff; -} -.theme-github .markdown-body h1 { padding-bottom: .3em; border-bottom: 1px solid #eaecef; } -.theme-github .markdown-body h2 { padding-bottom: .3em; border-bottom: 1px solid #eaecef; } -.theme-github .markdown-body a { color: #0366d6; text-decoration: none; } -.theme-github .markdown-body a:hover { text-decoration: underline; } -.theme-github .markdown-body code { - background: rgba(27,31,35,.05); - padding: .2em .4em; - border-radius: 3px; - font-size: 85%; -} -.theme-github .markdown-body pre { - background: #f6f8fa; - padding: 16px; - border-radius: 6px; - border: 1px solid #e1e4e8; - overflow: auto; -} -.theme-github .markdown-body pre code { background: none; padding: 0; font-size: 100%; } -.theme-github .markdown-body blockquote { - color: #6a737d; - border-left: .25em solid #dfe2e5; - padding: 0 1em; - margin: 0; -} -.theme-github .markdown-body img { max-width: 100%; } -` - -const simpleCSS = ` -.theme-simple { - font-family: Georgia, "Times New Roman", serif; - color: #333; - background: #fefefe; - line-height: 1.8; -} -.theme-simple .markdown-body a { color: #07c; } -.theme-simple .markdown-body code { - background: #f0f0f0; - padding: .15em .3em; - border-radius: 2px; -} -.theme-simple .markdown-body pre { - background: #f0f0f0; - padding: 14px; - border-radius: 4px; - border: 1px solid #ddd; - overflow: auto; -} -.theme-simple .markdown-body pre code { background: none; padding: 0; font-size: 100%; } -.theme-simple .markdown-body blockquote { - color: #666; - border-left: 3px solid #ccc; - padding: 0 1em; - margin: 0; -} -.theme-simple .markdown-body img { max-width: 100%; } -` - -const darkCSS = ` -.theme-dark { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - color: #c9d1d9; - background: #0d1117; -} -.theme-dark .markdown-body h1 { padding-bottom: .3em; border-bottom: 1px solid #21262d; } -.theme-dark .markdown-body h2 { padding-bottom: .3em; border-bottom: 1px solid #21262d; } -.theme-dark .markdown-body a { color: #58a6ff; text-decoration: none; } -.theme-dark .markdown-body a:hover { text-decoration: underline; } -.theme-dark .markdown-body code { - background: rgba(110,118,129,.4); - padding: .2em .4em; - border-radius: 3px; - font-size: 85%; -} -.theme-dark .markdown-body pre { - background: #161b22; - padding: 16px; - border-radius: 6px; - border: 1px solid #30363d; - overflow: auto; -} -.theme-dark .markdown-body pre code { background: none; padding: 0; font-size: 100%; } -.theme-dark .markdown-body blockquote { - color: #8b949e; - border-left: .25em solid #30363d; - padding: 0 1em; - margin: 0; -} -.theme-dark .markdown-body img { max-width: 100%; } ` diff --git a/internal/template/highlight.go b/internal/template/highlight.go index af978f4..38cdfe4 100644 --- a/internal/template/highlight.go +++ b/internal/template/highlight.go @@ -1,66 +1 @@ package template - -import ( - "bytes" - "strings" - - chromahtml "github.com/alecthomas/chroma/v2/formatters/html" - "github.com/alecthomas/chroma/v2/styles" -) - -// highlightCSS holds the combined syntax highlight CSS for all themes. -var highlightCSS string - -func init() { - var buf strings.Builder - - // GitHub/Simple themes: use "github" chroma style - githubSyntax := generateSyntaxCSS("github") - buf.WriteString(scopeCSS(githubSyntax, ".theme-github")) - buf.WriteString("\n") - buf.WriteString(scopeCSS(githubSyntax, ".theme-simple")) - buf.WriteString("\n") - - // Dark theme: use "monokai" chroma style - monokaiSyntax := generateSyntaxCSS("monokai") - buf.WriteString(scopeCSS(monokaiSyntax, ".theme-dark")) - buf.WriteString("\n") - - highlightCSS = buf.String() -} - -// generateSyntaxCSS generates CSS from a named chroma style. -func generateSyntaxCSS(styleName string) string { - style := styles.Get(styleName) - formatter := chromahtml.New(chromahtml.WithClasses(true)) - - var buf bytes.Buffer - if err := formatter.WriteCSS(&buf, style); err != nil { - return "" - } - return buf.String() -} - -// scopeCSS prefixes CSS selectors with a theme class scope. -// Each line containing a CSS selector (starting with ".") gets the scope prepended. -func scopeCSS(css, scope string) string { - var result strings.Builder - for _, line := range strings.Split(css, "\n") { - if line == "" { - result.WriteString("\n") - continue - } - // Find the first "." which marks the CSS selector - idx := strings.Index(line, ".") - if idx >= 0 && strings.Contains(line, "{") { - result.WriteString(line[:idx]) - result.WriteString(scope) - result.WriteString(" ") - result.WriteString(line[idx:]) - } else { - result.WriteString(line) - } - result.WriteString("\n") - } - return result.String() -} diff --git a/internal/template/lineanchor.go b/internal/template/lineanchor.go index 788528c..3ea5ab3 100644 --- a/internal/template/lineanchor.go +++ b/internal/template/lineanchor.go @@ -68,14 +68,10 @@ const lineAnchorJS = ` ` const markdownPageTplTail = ` @@ -105,9 +102,8 @@ const markdownPageTplTail = ` @@ -120,7 +116,7 @@ const markdownPageTplTail = `
@@ -190,9 +189,8 @@ const dirPageTpl = `
@@ -212,13 +210,17 @@ const dirPageTpl = `
@@ -256,9 +257,8 @@ const errorPageTpl = `
@@ -275,13 +275,17 @@ const errorPageTpl = `