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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ To disable automatic browser reload on file changes (useful for stable editing):
go-grip --no-reload README.md
```

To use a formatted version of the Markdown filename as the browser page title
(`my-guide_v2.md` becomes `My Guide V2`):

```bash
go-grip --filename-title my-guide_v2.md
```

To terminate the current server simply press `CTRL-C`.

## :pencil: Examples
Expand Down
4 changes: 3 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ var rootCmd = &cobra.Command{
port, _ := cmd.Flags().GetInt("port")
boundingBox, _ := cmd.Flags().GetBool("bounding-box")
noReload, _ := cmd.Flags().GetBool("no-reload")
filenameTitle, _ := cmd.Flags().GetBool("filename-title")

var file string
if len(args) == 1 {
file = args[0]
}

parser := internal.NewParser()
server := internal.NewServer(host, port, boundingBox, browser, !noReload, parser)
server := internal.NewServer(host, port, boundingBox, browser, !noReload, filenameTitle, parser)
return server.Serve(file)
},
}
Expand All @@ -42,4 +43,5 @@ func init() {
rootCmd.Flags().IntP("port", "p", 6419, "Port to use")
rootCmd.Flags().Bool("bounding-box", true, "Add bounding box to HTML")
rootCmd.Flags().Bool("no-reload", false, "Disable automatic browser reload on file changes")
rootCmd.Flags().Bool("filename-title", false, "Use the Markdown filename as the HTML page title")
}
2 changes: 1 addition & 1 deletion defaults/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>go-grip - markdown preview</title>
<title>{{ .Title }}</title>
<link rel="icon" type="image/x-icon" href="/static/images/favicon.ico" />
<link
id="theme-light"
Expand Down
69 changes: 56 additions & 13 deletions internal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package internal
import (
"bytes"
"fmt"
"html"
"io"
"log"
"net/http"
Expand All @@ -11,30 +12,36 @@ import (
"regexp"
"strings"
"text/template"
"unicode"
"unicode/utf8"

"github.com/aarol/reload"
chroma_html "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/styles"
"github.com/chrishrb/go-grip/defaults"
)

const defaultHTMLTitle = "go-grip - markdown preview"

type Server struct {
parser *Parser
boundingBox bool
host string
port int
browser bool
enableReload bool
parser *Parser
boundingBox bool
host string
port int
browser bool
enableReload bool
filenameTitle bool
}

func NewServer(host string, port int, boundingBox bool, browser bool, enableReload bool, parser *Parser) *Server {
func NewServer(host string, port int, boundingBox bool, browser bool, enableReload bool, filenameTitle bool, parser *Parser) *Server {
return &Server{
host: host,
port: port,
boundingBox: boundingBox,
browser: browser,
enableReload: enableReload,
parser: parser,
host: host,
port: port,
boundingBox: boundingBox,
browser: browser,
enableReload: enableReload,
filenameTitle: filenameTitle,
parser: parser,
}
}

Expand Down Expand Up @@ -117,6 +124,7 @@ func (s *Server) newHandler(dir http.Dir) http.Handler {
BoundingBox: s.boundingBox,
CssCodeLight: getCssCode("github"),
CssCodeDark: getCssCode("github-dark"),
Title: html.EscapeString(s.pageTitle(r.URL.Path)),
})
if err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -159,6 +167,41 @@ type htmlStruct struct {
BoundingBox bool
CssCodeLight string
CssCodeDark string
Title string
}

func (s *Server) pageTitle(filename string) string {
if !s.filenameTitle {
return defaultHTMLTitle
}

title := formatFilenameTitle(filename)
if title == "" {
return defaultHTMLTitle
}
return title
}

func formatFilenameTitle(filename string) string {
filename = path.Base(filename)
extension := path.Ext(filename)
if strings.EqualFold(extension, ".md") {
filename = strings.TrimSuffix(filename, extension)
}

filename = strings.Map(func(r rune) rune {
if r == '-' || r == '_' {
return ' '
}
return r
}, filename)

words := strings.Fields(filename)
for i, word := range words {
first, size := utf8.DecodeRuneInString(word)
words[i] = string(unicode.ToUpper(first)) + word[size:]
}
return strings.Join(words, " ")
}

func serveTemplate(w http.ResponseWriter, html htmlStruct) error {
Expand Down
76 changes: 73 additions & 3 deletions internal/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, false, NewParser())
handler := server.newHandler(http.Dir(tmpDir))

req := httptest.NewRequest(http.MethodGet, "/", nil)
Expand Down Expand Up @@ -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, false, NewParser())
handler := server.newHandler(http.Dir(tmpDir))

req := httptest.NewRequest(http.MethodGet, "/plain.txt", nil)
Expand All @@ -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, false, NewParser())
handler := server.newHandler(http.Dir(tmpDir))

req := httptest.NewRequest(http.MethodGet, "/README.md", nil)
Expand All @@ -89,4 +89,74 @@ func TestMarkdownResponsesDisableCaching(t *testing.T) {
if !strings.Contains(recorder.Body.String(), "Hello") {
t.Fatalf("expected rendered markdown response to contain document content, got %q", recorder.Body.String())
}
if !strings.Contains(recorder.Body.String(), "<title>"+defaultHTMLTitle+"</title>") {
t.Fatalf("expected default HTML title, got %q", recorder.Body.String())
}
}

func TestFormatFilenameTitle(t *testing.T) {
t.Parallel()

tests := []struct {
name string
filename string
want string
}{
{name: "humanizes separators", filename: "my-guide_v2.md", want: "My Guide V2"},
{name: "uses basename", filename: "/docs/getting-started.md", want: "Getting Started"},
{name: "preserves acronym", filename: "README.md", want: "README"},
{name: "strips uppercase extension", filename: "release_notes.MD", want: "Release Notes"},
{name: "collapses separators and whitespace", filename: " release---notes__v2.md", want: "Release Notes V2"},
{name: "supports unicode", filename: "überblick-plan.md", want: "Überblick Plan"},
{name: "falls back for empty stem", filename: ".md", want: ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := formatFilenameTitle(tt.filename); got != tt.want {
t.Fatalf("formatFilenameTitle(%q) = %q, want %q", tt.filename, got, tt.want)
}
})
}
}

func TestFilenameTitleResponses(t *testing.T) {
t.Parallel()

tmpDir := t.TempDir()
files := []string{"my-guide_v2.md", "unsafe-<script>.md", ".md"}
for _, filename := range files {
if err := os.WriteFile(filepath.Join(tmpDir, filename), []byte("# Hello\n"), 0o644); err != nil {
t.Fatalf("write %s: %v", filename, err)
}
}

server := NewServer("localhost", 6419, false, false, false, true, NewParser())
handler := server.newHandler(http.Dir(tmpDir))

tests := []struct {
name string
path string
want string
}{
{name: "humanized title", path: "/my-guide_v2.md", want: "<title>My Guide V2</title>"},
{name: "escaped title", path: "/unsafe-%3Cscript%3E.md", want: "<title>Unsafe &lt;script&gt;</title>"},
{name: "empty title fallback", path: "/.md", want: "<title>" + defaultHTMLTitle + "</title>"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, tt.path, nil)
handler.ServeHTTP(recorder, request)

if recorder.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, recorder.Code)
}
if !strings.Contains(recorder.Body.String(), tt.want) {
t.Fatalf("expected response to contain %q, got %q", tt.want, recorder.Body.String())
}
})
}
}