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 me
Inside details\n",
+ wantContains: []string{`Open me
Inside 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)