From 699914d1a847a90744e4e360bd8f420a98f75a39 Mon Sep 17 00:00:00 2001 From: Phil Beadling Date: Mon, 18 May 2026 16:52:46 +0100 Subject: [PATCH] add --disable-markdown-feature cmd line switch --- README.md | 20 ++++-- cmd/root.go | 13 +++- defaults/templates/layout.html | 8 +++ internal/parser.go | 112 +++++++++++++++++++++++++++----- internal/parser_test.go | 105 ++++++++++++++++++++++++++++++ internal/server.go | 2 + internal/server_feature_test.go | 67 +++++++++++++++++++ internal/server_test.go | 6 +- 8 files changed, 305 insertions(+), 28 deletions(-) create mode 100644 internal/parser_test.go create mode 100644 internal/server_feature_test.go diff --git a/README.md b/README.md index f025688..c93712e 100644 --- a/README.md +++ b/README.md @@ -105,22 +105,30 @@ You can also specify a port: go-grip -p 80 README.md ``` -or just open a file-tree with all available files in the current directory: +Or preview the current directory. If `README.md` exists, go-grip opens that page first: ```bash -go-grip -r=false +go-grip . ``` -It's also possible to activate the darkmode: +To keep the preview stable while editing and refresh manually in the browser: ```bash -go-grip -d . +go-grip --no-reload README.md ``` -To disable automatic browser reload on file changes (useful for stable editing): +To disable automatic browser reload and MathJax rendering together: ```bash -go-grip --no-reload README.md +go-grip --no-reload --disable-markdown-feature mathjax README.md +``` + +To disable markdown features in the renderer, use `--disable-markdown-feature`. +Supported feature names are `details`, `footnote`, `ghissue`, `mathjax`, and `mermaid`. + +```bash +go-grip --disable-markdown-feature mathjax README.md +go-grip --disable-markdown-feature mathjax --disable-markdown-feature mermaid README.md ``` To terminate the current server simply press `CTRL-C`. diff --git a/cmd/root.go b/cmd/root.go index 12b04ed..3c84bbf 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "os" + "strings" "github.com/chrishrb/go-grip/internal" "github.com/spf13/cobra" @@ -16,6 +17,7 @@ var rootCmd = &cobra.Command{ host, _ := cmd.Flags().GetString("host") port, _ := cmd.Flags().GetInt("port") boundingBox, _ := cmd.Flags().GetBool("bounding-box") + disabledFeatures, _ := cmd.Flags().GetStringSlice("disable-markdown-feature") noReload, _ := cmd.Flags().GetBool("no-reload") var file string @@ -23,7 +25,11 @@ var rootCmd = &cobra.Command{ file = args[0] } - parser := internal.NewParser() + if err := internal.ValidateMarkdownFeatures(disabledFeatures); err != nil { + return err + } + + parser := internal.NewParser(disabledFeatures) server := internal.NewServer(host, port, boundingBox, browser, !noReload, parser) return server.Serve(file) }, @@ -41,5 +47,10 @@ func init() { rootCmd.Flags().StringP("host", "H", "localhost", "Host to use") rootCmd.Flags().IntP("port", "p", 6419, "Port to use") rootCmd.Flags().Bool("bounding-box", true, "Add bounding box to HTML") + rootCmd.Flags().StringSlice( + "disable-markdown-feature", + nil, + "Disable optional markdown feature(s): "+strings.Join(internal.SupportedMarkdownFeatures(), ", "), + ) rootCmd.Flags().Bool("no-reload", false, "Disable automatic browser reload on file changes") } diff --git a/defaults/templates/layout.html b/defaults/templates/layout.html index f53e6fa..4a636ee 100644 --- a/defaults/templates/layout.html +++ b/defaults/templates/layout.html @@ -20,16 +20,24 @@ + {{if index .Features "mermaid"}} + {{end}} + {{if index .Features "mathjax"}} + {{end}} + {{if index .Features "mathjax"}} + {{end}} + {{if index .Features "mermaid"}} + {{end}} diff --git a/internal/parser.go b/internal/parser.go index deaeb2c..668fbb4 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -2,6 +2,9 @@ package internal import ( "bytes" + "fmt" + "slices" + "strings" "github.com/chrishrb/go-grip/pkg/alert" "github.com/chrishrb/go-grip/pkg/details" @@ -19,29 +22,102 @@ import ( "go.abhg.dev/goldmark/mermaid" ) -type Parser struct{} +const ( + FeatureDetails = "details" + FeatureFootnote = "footnote" + FeatureGHIssue = "ghissue" + FeatureMathJax = "mathjax" + FeatureMermaid = "mermaid" +) + +var supportedMarkdownFeatures = []string{ + FeatureDetails, + FeatureFootnote, + FeatureGHIssue, + FeatureMathJax, + FeatureMermaid, +} + +type Parser struct { + disabledFeatures map[string]struct{} +} + +func NewParser(disabledFeatures []string) *Parser { + // Normalize feature names once so the rest of the parser can use simple lookups. + disabled := make(map[string]struct{}, len(disabledFeatures)) + for _, feature := range disabledFeatures { + disabled[strings.ToLower(feature)] = struct{}{} + } + return &Parser{disabledFeatures: disabled} +} + +func SupportedMarkdownFeatures() []string { + return slices.Clone(supportedMarkdownFeatures) +} -func NewParser() *Parser { - return &Parser{} +func ValidateMarkdownFeatures(features []string) error { + var invalid []string + for _, feature := range features { + feature = strings.ToLower(strings.TrimSpace(feature)) + if !slices.Contains(supportedMarkdownFeatures, feature) { + invalid = append(invalid, feature) + } + } + if len(invalid) == 0 { + return nil + } + + return fmt.Errorf( + "unknown markdown feature(s): %s (supported: %s)", + strings.Join(invalid, ", "), + strings.Join(supportedMarkdownFeatures, ", "), + ) +} + +func (m Parser) FeatureEnabled(name string) bool { + _, disabled := m.disabledFeatures[strings.ToLower(name)] + return !disabled +} + +func (m Parser) TemplateFeatures() map[string]bool { + // Keep template asset toggles derived from the same feature registry as parsing. + features := make(map[string]bool, len(supportedMarkdownFeatures)) + for _, feature := range supportedMarkdownFeatures { + features[feature] = m.FeatureEnabled(feature) + } + return features } func (m Parser) MdToHTML(input []byte) ([]byte, error) { + // Always-on GitHub-style extensions stay in the base list; optional ones are gated below. + extensions := []goldmark.Extender{ + extension.Linkify, + extension.Table, + extension.Strikethrough, + tasklist.TaskList, + emoji.Emoji, + &hashtag.Extender{}, + alert.New(), + highlighting.Highlighting, + } + if m.FeatureEnabled(FeatureFootnote) { + extensions = append(extensions, footnote.Footnote) + } + if m.FeatureEnabled(FeatureMermaid) { + extensions = append(extensions, &mermaid.Extender{RenderMode: mermaid.RenderModeClient, NoScript: true}) + } + if m.FeatureEnabled(FeatureMathJax) { + extensions = append(extensions, mathjax.MathJax) + } + if m.FeatureEnabled(FeatureGHIssue) { + extensions = append(extensions, ghissue.New()) + } + if m.FeatureEnabled(FeatureDetails) { + extensions = append(extensions, details.New()) + } + md := goldmark.New( - goldmark.WithExtensions( - extension.Linkify, - extension.Table, - extension.Strikethrough, - footnote.Footnote, - tasklist.TaskList, - emoji.Emoji, - &hashtag.Extender{}, - alert.New(), - highlighting.Highlighting, - &mermaid.Extender{RenderMode: mermaid.RenderModeClient, NoScript: true}, - mathjax.MathJax, - ghissue.New(), - details.New(), - ), + goldmark.WithExtensions(extensions...), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), diff --git a/internal/parser_test.go b/internal/parser_test.go new file mode 100644 index 0000000..4ccd2f0 --- /dev/null +++ b/internal/parser_test.go @@ -0,0 +1,105 @@ +package internal + +import ( + "strings" + "testing" +) + +func TestParserFeatureDisablePassThrough(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disabledFeature string + input string + wantContains []string + wantNotContains []string + }{ + { + name: "mathjax", + disabledFeature: FeatureMathJax, + input: "Inline math: $x + y$\n", + wantContains: []string{"$x + y$"}, + wantNotContains: []string{`\(`, `\)`}, + }, + { + name: "mermaid", + disabledFeature: FeatureMermaid, + input: "```mermaid\ngraph TD;\n A-->B;\n```\n", + wantContains: []string{`class="highlight`, `graph TD;`, `A-->B;`}, + wantNotContains: []string{`
`},
+		},
+		{
+			name:            "details",
+			disabledFeature: FeatureDetails,
+			input:           "
Open meInside details
\n", + wantContains: []string{`
Open meInside details
`}, + wantNotContains: []string{`id="details-`, `sessionStorage`}, + }, + { + name: "footnote", + disabledFeature: FeatureFootnote, + input: "Footnote ref[^1].\n\n[^1]: Footnote text.\n", + wantContains: []string{`Footnote ref[^1].`, `[^1]: Footnote text.`}, + wantNotContains: []string{`class="footnote-ref"`, `class="footnotes"`}, + }, + { + name: "ghissue", + disabledFeature: FeatureGHIssue, + input: "Issue refs: #46 and grafana/grafana#22\n", + wantContains: []string{`Issue refs: #46 and grafana/grafana#22`}, + wantNotContains: []string{`class="issue-link"`}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + parser := NewParser([]string{tt.disabledFeature}) + html, err := parser.MdToHTML([]byte(tt.input)) + if err != nil { + t.Fatalf("MdToHTML returned error: %v", err) + } + + got := string(html) + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Fatalf("expected output to contain %q, got %q", want, got) + } + } + for _, unwanted := range tt.wantNotContains { + if strings.Contains(got, unwanted) { + t.Fatalf("expected output not to contain %q, got %q", unwanted, got) + } + } + }) + } +} + +func TestParserMathEnabledTransformsInlineMath(t *testing.T) { + t.Parallel() + + parser := NewParser(nil) + html, err := parser.MdToHTML([]byte("Inline math: $x + y$\n")) + if err != nil { + t.Fatalf("MdToHTML returned error: %v", err) + } + + got := string(html) + if !strings.Contains(got, `\(`) || !strings.Contains(got, `\)`) { + t.Fatalf("expected mathjax delimiters in output, got %q", got) + } + if strings.Contains(got, "$x + y$") { + t.Fatalf("expected inline math to be transformed, got %q", got) + } +} + +func TestValidateMarkdownFeaturesRejectsUnknownValues(t *testing.T) { + t.Parallel() + + err := ValidateMarkdownFeatures([]string{"mathjax", "bogus"}) + if err == nil { + t.Fatal("expected validation error for unknown feature") + } +} diff --git a/internal/server.go b/internal/server.go index c0dc6b8..b5d323f 100644 --- a/internal/server.go +++ b/internal/server.go @@ -117,6 +117,7 @@ func (s *Server) newHandler(dir http.Dir) http.Handler { BoundingBox: s.boundingBox, CssCodeLight: getCssCode("github"), CssCodeDark: getCssCode("github-dark"), + Features: s.parser.TemplateFeatures(), }) if err != nil { log.Fatal(err) @@ -159,6 +160,7 @@ type htmlStruct struct { BoundingBox bool CssCodeLight string CssCodeDark string + Features map[string]bool } func serveTemplate(w http.ResponseWriter, html htmlStruct) error { diff --git a/internal/server_feature_test.go b/internal/server_feature_test.go new file mode 100644 index 0000000..2bf35ec --- /dev/null +++ b/internal/server_feature_test.go @@ -0,0 +1,67 @@ +package internal + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMarkdownResponseOmitsDisabledFeatureAssets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disabledFeature string + input string + wantContains []string + wantNotContains []string + }{ + { + name: "mathjax", + disabledFeature: FeatureMathJax, + input: "Inline math: $x + y$\n", + wantContains: []string{"$x + y$"}, + wantNotContains: []string{"/static/js/tex-mml-chtml.js", "/static/css/mathjax.css"}, + }, + { + name: "mermaid", + disabledFeature: FeatureMermaid, + input: "```mermaid\ngraph TD;\n A-->B;\n```\n", + wantContains: []string{`class="highlight`, `graph TD;`}, + wantNotContains: []string{"/static/js/mermaid.min.js", "/static/css/github-mermaid.css"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, "README.md"), []byte(tt.input), 0o644); err != nil { + t.Fatalf("write README.md: %v", err) + } + + server := NewServer("localhost", 6419, false, false, false, NewParser([]string{tt.disabledFeature})) + handler := server.newHandler(http.Dir(tmpDir)) + + req := httptest.NewRequest(http.MethodGet, "/README.md", nil) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + body := recorder.Body.String() + for _, want := range tt.wantContains { + if !strings.Contains(body, want) { + t.Fatalf("expected body to contain %q, got %q", want, body) + } + } + for _, unwanted := range tt.wantNotContains { + if strings.Contains(body, unwanted) { + t.Fatalf("expected body not to contain %q, got %q", unwanted, body) + } + } + }) + } +} diff --git a/internal/server_test.go b/internal/server_test.go index bdf3f23..d24d989 100644 --- a/internal/server_test.go +++ b/internal/server_test.go @@ -18,7 +18,7 @@ func TestDirectoryListingIgnoresCacheValidators(t *testing.T) { t.Fatalf("write README.md: %v", err) } - server := NewServer("localhost", 6419, false, false, false, NewParser()) + server := NewServer("localhost", 6419, false, false, false, NewParser(nil)) handler := server.newHandler(http.Dir(tmpDir)) req := httptest.NewRequest(http.MethodGet, "/", nil) @@ -46,7 +46,7 @@ func TestRegularFileStillSupportsConditionalRequests(t *testing.T) { t.Fatalf("write plain.txt: %v", err) } - server := NewServer("localhost", 6419, false, false, false, NewParser()) + server := NewServer("localhost", 6419, false, false, false, NewParser(nil)) handler := server.newHandler(http.Dir(tmpDir)) req := httptest.NewRequest(http.MethodGet, "/plain.txt", nil) @@ -68,7 +68,7 @@ func TestMarkdownResponsesDisableCaching(t *testing.T) { t.Fatalf("write README.md: %v", err) } - server := NewServer("localhost", 6419, false, false, false, NewParser()) + server := NewServer("localhost", 6419, false, false, false, NewParser(nil)) handler := server.newHandler(http.Dir(tmpDir)) req := httptest.NewRequest(http.MethodGet, "/README.md", nil)