Skip to content
Draft
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
16 changes: 6 additions & 10 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
module github.com/mapitman/mdview

go 1.21.1
go 1.24

toolchain go1.24.11

require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a
github.com/yuin/goldmark v1.7.13
go.abhg.dev/goldmark/mermaid v0.6.0
)

require (
gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 // indirect
gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82 // indirect
gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 // indirect
gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
require golang.org/x/sys v0.34.0 // indirect
47 changes: 28 additions & 19 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0=
github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181 h1:K+bMSIx9A7mLES1rtG+qKduLIXq40DAzYHtb0XuCukA=
gitlab.com/golang-commonmark/html v0.0.0-20191124015941-a22733972181/go.mod h1:dzYhVIwWCtzPAa4QP98wfB9+mzt33MSmM8wsKiMi2ow=
gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82 h1:oYrL81N608MLZhma3ruL8qTM4xcpYECGut8KSxRY59g=
gitlab.com/golang-commonmark/linkify v0.0.0-20191026162114-a0c2df6c8f82/go.mod h1:Gn+LZmCrhPECMD3SOKlE+BOHwhOYD9j7WT9NUtkCrC8=
gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a h1:O85GKETcmnCNAfv4Aym9tepU8OE0NmcZNqPlXcsBKBs=
gitlab.com/golang-commonmark/markdown v0.0.0-20211110145824-bf3e522c626a/go.mod h1:LaSIs30YPGs1H5jwGgPhLzc8vkNc/k0rDX/fEZqiU/M=
gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84 h1:qqjvoVXdWIcZCLPMlzgA7P9FZWdPGPvP/l3ef8GzV6o=
gitlab.com/golang-commonmark/mdurl v0.0.0-20191124015652-932350d1cb84/go.mod h1:IJZ+fdMvbW2qW6htJx7sLJ04FEs4Ldl/MDsJtMKywfw=
gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f h1:Wku8eEdeJqIOFHtrfkYUByc4bCaTeA6fL0UJgfEiFMI=
gitlab.com/golang-commonmark/puny v0.0.0-20191124015043-9f83538fa04f/go.mod h1:Tiuhl+njh/JIg0uS/sOJVYi0x2HEa5rc1OAaVsb5tAs=
gitlab.com/opennota/wd v0.0.0-20180912061657-c5d65f63c638 h1:uPZaMiz6Sz0PZs3IZJWpU5qHKGNy///1pacZC9txiUI=
gitlab.com/opennota/wd v0.0.0-20180912061657-c5d65f63c638/go.mod h1:EGRJaqe2eO9XGmFtQCvV3Lm9NLico3UhFwUpCG/+mVU=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.abhg.dev/goldmark/mermaid v0.6.0 h1:VvkYFWuOjD6cmSBVJpLAtzpVCGM1h0B7/DQ9IzERwzY=
go.abhg.dev/goldmark/mermaid v0.6.0/go.mod h1:uMc+PcnIH2NVL7zjH10Q1wr7hL3+4n4jUMifhyBYB9I=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
182 changes: 119 additions & 63 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"crypto/rand"
"encoding/base64"
_ "embed"
Expand All @@ -16,7 +17,13 @@ import (
"strings"

"github.com/pkg/browser"
"gitlab.com/golang-commonmark/markdown"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"go.abhg.dev/goldmark/mermaid"
)

var appVersion string
Expand All @@ -25,12 +32,20 @@ var appVersion string
// Captures: 1=prefix, 2=opening quote, 3=src path, 4=closing quote
var imgSrcRegex = regexp.MustCompile(`(<img[^>]*\ssrc=)(["']?)([^"'\s>]+)(["']?)`)

// cdnScriptRegex matches Mermaid CDN script tags followed by initialization script
// Goldmark's mermaid extension inserts these tags that need to be replaced with embedded version
// Pattern allows for flexible whitespace between elements
var cdnScriptRegex = regexp.MustCompile(`<script\s+src\s*=\s*"https://cdn\.jsdelivr\.net/npm/mermaid[^"]*"\s*>\s*</script>\s*<script[^>]*>\s*mermaid\.initialize\s*\([^)]*\)\s*;\s*</script>`)

//go:embed github-markdown.css
var style string

//go:embed template.html
var template string

//go:embed mermaid.min.js
var mermaidJS string

func main() {
var outfilePtr = flag.String("o", "", "Output filename. (Optional)")
var versionPtr = flag.Bool("version", false, "Prints mdview version.")
Expand All @@ -57,20 +72,43 @@ func main() {
dat, err := os.ReadFile(inputFilename)
check(err)

md := markdown.New(
markdown.HTML(true),
markdown.Nofollow(true),
markdown.Tables(true),
markdown.Typographer(true))

markdownTokens := md.Parse(dat)

// Convert relative image links to data URIs
// Convert relative image links to data URIs in the markdown source
baseDir := filepath.Dir(inputFilename)
processImageTokens(markdownTokens, baseDir)
processedMarkdown := processMarkdownImages(string(dat), baseDir)
processedBytes := []byte(processedMarkdown)

// Create Goldmark markdown processor with extensions
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM, // GitHub Flavored Markdown (includes tables)
extension.Typographer, // Smart quotes, dashes, etc.
&mermaid.Extender{
NoScript: true, // Don't add CDN script tags - we'll add our own embedded version
},
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(), // Auto-generate heading IDs
),
goldmark.WithRendererOptions(
html.WithUnsafe(), // Allow raw HTML (equivalent to markdown.HTML(true))
),
)

// Parse markdown to AST
doc := md.Parser().Parse(text.NewReader(processedBytes))

html := md.RenderTokensToString(markdownTokens)
title := getTitle(markdownTokens)
// Extract title from AST
title := getTitleFromAST(doc, processedBytes)

// Render to HTML
var buf bytes.Buffer
if err := md.Renderer().Render(&buf, processedBytes, doc); err != nil {
log.Fatal(err)
}
htmlContent := buf.String()

// Replace CDN-based Mermaid scripts with inline script
htmlContent = embedMermaidScript(htmlContent)

outfilePath := *outfilePtr
if outfilePath == "" {
Expand All @@ -87,7 +125,7 @@ func main() {
actualStyle = style
}

_, err = fmt.Fprintf(f, template, actualStyle, title, html)
_, err = fmt.Fprintf(f, template, actualStyle, title, htmlContent)
check(err)
f.Sync()
browser.Stderr = nil
Expand Down Expand Up @@ -145,68 +183,67 @@ func check(e error) {
}
}

func getTitle(tokens []markdown.Token) string {
var result string
if len(tokens) > 0 {
for i := 0; i < len(tokens); i++ {
if topLevelHeading, ok := tokens[i].(*markdown.HeadingOpen); ok {
for j := i + 1; j < len(tokens); j++ {
if token, ok := tokens[j].(*markdown.HeadingClose); ok && token.Lvl == topLevelHeading.Lvl {
break
}
result += getText(tokens[j])
}
result = strings.TrimSpace(result)
break
}
func getTitleFromAST(doc ast.Node, source []byte) string {
var title string
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
}

return result
if heading, ok := n.(*ast.Heading); ok && heading.Level == 1 {
// Extract all text from heading and its children recursively
var buf bytes.Buffer
extractText(heading, source, &buf)
title = strings.TrimSpace(buf.String())
return ast.WalkStop, nil
}
return ast.WalkContinue, nil
})
return title
}

func getText(token markdown.Token) string {
switch token := token.(type) {
case *markdown.Text:
return token.Content
case *markdown.Inline:
result := ""
for _, token := range token.Children {
result += getText(token)
// extractText recursively extracts text from a node and its children
func extractText(node ast.Node, source []byte, buf *bytes.Buffer) {
for child := node.FirstChild(); child != nil; child = child.NextSibling() {
switch n := child.(type) {
case *ast.Text:
buf.Write(n.Segment.Value(source))
case *ast.String:
buf.Write(n.Value)
default:
// Recursively extract text from other node types (emphasis, strong, links, etc.)
extractText(child, source, buf)
}
return result
}
return ""

}

func isSnap() bool {
return os.Getenv("SNAP_USER_COMMON") != ""
}

// processImageTokens walks through markdown tokens and converts relative image paths to data URIs
func processImageTokens(tokens []markdown.Token, baseDir string) {
for _, token := range tokens {
switch t := token.(type) {
case *markdown.Image:
if isRelativePath(t.Src) {
if dataURI := imageToDataURI(t.Src, baseDir); dataURI != "" {
t.Src = dataURI
}
}
case *markdown.HTMLInline:
// Process inline HTML that may contain <img> tags
t.Content = processHTMLImages(t.Content, baseDir)
case *markdown.HTMLBlock:
// Process block HTML that may contain <img> tags
t.Content = processHTMLImages(t.Content, baseDir)
case *markdown.Inline:
// Recursively process child tokens
if t.Children != nil {
processImageTokens(t.Children, baseDir)
// processMarkdownImages processes markdown source and converts relative image paths to data URIs
func processMarkdownImages(markdown string, baseDir string) string {
// Process markdown image syntax: ![alt](path)
imgMarkdownRegex := regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
markdown = imgMarkdownRegex.ReplaceAllStringFunc(markdown, func(match string) string {
parts := imgMarkdownRegex.FindStringSubmatch(match)
if len(parts) < 3 {
return match
}
alt := parts[1]
imgPath := parts[2]

if isRelativePath(imgPath) {
if dataURI := imageToDataURI(imgPath, baseDir); dataURI != "" {
return fmt.Sprintf("![%s](%s)", alt, dataURI)
}
}
}
return match
})

// Process HTML img tags in markdown
markdown = processHTMLImages(markdown, baseDir)

return markdown
}

// processHTMLImages processes HTML content and converts relative image src attributes to data URIs
Expand Down Expand Up @@ -359,3 +396,22 @@ func getMimeType(path string) string {
return "image/*"
}
}

// embedMermaidScript adds the embedded Mermaid.js library to the HTML content
// Since we set NoScript: true on the Mermaid extender, we need to manually add the script
func embedMermaidScript(htmlContent string) string {
// Check if there are any mermaid diagrams in the content
// The Goldmark mermaid extension uses class="mermaid" for diagram blocks
if !strings.Contains(htmlContent, `class="mermaid"`) {
return htmlContent // No mermaid diagrams, don't add the script
}

// Escape any </script> tags (with closing >) inside the mermaid.js code
// to prevent premature script closure. The standard way is to escape the forward slash.
escapedMermaidJS := strings.ReplaceAll(mermaidJS, "</script>", "<\\/script>")

// Add the embedded Mermaid.js and initialization at the end of the content
inlineScript := fmt.Sprintf("<script>%s</script><script>mermaid.initialize({startOnLoad: true});</script>", escapedMermaidJS)

return htmlContent + inlineScript
}
2,811 changes: 2,811 additions & 0 deletions mermaid.min.js

Large diffs are not rendered by default.

Loading