Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ npm/npm-debug.log*
.env
.env.*
!.env.example
/features/*
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ continues to run only on localhost and does not look for or require ngrok.
- code block copying
- offline Mermaid diagrams
- images relative to the Markdown file
- PDF download with heading bookmarks (requires a Chrome-compatible browser)

### PDF export

After the document and its Mermaid diagrams load, use **Download PDF**. `mdo`
uses a local headless Chrome-compatible browser to produce the download, so
its headings appear as bookmarks in supporting PDF viewers. Chrome, Chromium,
Brave, and Microsoft Edge are supported; install one of them if PDF export is
unavailable. The normal document view may still use any default browser.

### Install from a release

Expand Down Expand Up @@ -214,6 +223,16 @@ continua funcionando somente em localhost e não procura nem exige o ngrok.
- cópia de blocos de código
- diagramas Mermaid offline
- imagens relativas ao arquivo Markdown
- download de PDF com marcadores de títulos (requer navegador compatível com Chrome)

### Exportação de PDF

Depois que o documento e os diagramas Mermaid carregarem, use **Download PDF**.
O `mdo` usa localmente um navegador compatível com Chrome em modo headless
para gerar o download, para que os títulos apareçam como marcadores em leitores
de PDF compatíveis. Chrome, Chromium, Brave e Microsoft Edge são suportados;
instale um deles caso a exportação esteja indisponível. A visualização normal
do documento pode continuar usando qualquer navegador padrão.

### Instalação por release

Expand Down
12 changes: 11 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@ go 1.26.5

require (
github.com/alecthomas/chroma/v2 v2.2.0
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f
github.com/chromedp/chromedp v0.16.0
github.com/yuin/goldmark v1.7.13
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20220924101305-151362477c87
)

require github.com/dlclark/regexp2 v1.7.0 // indirect
require (
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
golang.org/x/sys v0.47.0 // indirect
)
21 changes: 21 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,30 @@ github.com/alecthomas/chroma/v2 v2.2.0 h1:Aten8jfQwUqEdadVFFjNyjx7HTexhKP0XuqBG6
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae h1:zzGwJfFlFGD94CyyYwCJeSuD32Gj9GTaSi5y9hoVzdY=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f/go.mod h1:RwFsSODCtFExll+GhHM6R92SARHR3Z3oipaxLHj46C0=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
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/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand All @@ -18,6 +36,9 @@ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20220924101305-151362477c87 h1:Py16JEzkSdKAtEFJjiaYLYBOWGXc1r/xHj/Q/5lA37k=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20220924101305-151362477c87/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
60 changes: 57 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"syscall"
"time"

"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"github.com/egomes/mdo/internal/browser"
"github.com/egomes/mdo/internal/document"
"github.com/egomes/mdo/internal/ngrok"
Expand All @@ -27,12 +29,14 @@ import (
const (
maxMarkdownSize = 16 << 20
shutdownTimeout = 20 * time.Second
pdfTimeout = 45 * time.Second
)

var (
errUsage = errors.New("usage: mdo [-l|--live] <file.md>\n mdo --version")
// Version is set by release builds with -ldflags. Development builds use dev.
Version = "dev"
Version = "dev"
pdfGenerator = generatePDF
)

type runOptions struct {
Expand Down Expand Up @@ -69,6 +73,7 @@ func Run(args []string) error {
page, err := webassets.Page(webassets.PageData{
Title: filepath.Base(path),
Path: path,
PDFName: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + ".pdf",
Content: content,
Token: token,
})
Expand Down Expand Up @@ -158,6 +163,8 @@ func serveAndOpen(markdownPath, token string, page []byte, live bool, ngrokBinar

basePath := "/" + token + "/"
readyPath := basePath + "ready"
pdfPath := basePath + "pdf"
url := "http://" + listener.Addr().String() + basePath
ready := make(chan struct{})
var readyOnce sync.Once
assets := http.StripPrefix(basePath, http.FileServer(http.Dir(filepath.Dir(markdownPath))))
Expand All @@ -177,6 +184,26 @@ func serveAndOpen(markdownPath, token string, page []byte, live bool, ngrokBinar
w.WriteHeader(http.StatusNoContent)
readyOnce.Do(func() { close(ready) })
})
mux.HandleFunc("GET "+pdfPath, func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-MDO-Token") != token {
http.Error(w, "forbidden", http.StatusForbidden)
return
}

ctx, cancel := context.WithTimeout(r.Context(), pdfTimeout)
defer cancel()
pdf, err := pdfGenerator(ctx, url+"?pdf=1")
if err != nil {
http.Error(w, "não foi possível gerar o PDF", http.StatusInternalServerError)
return
}

setSecurityHeaders(w)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/pdf")
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.TrimSuffix(filepath.Base(markdownPath), filepath.Ext(markdownPath))+`.pdf"`)
_, _ = w.Write(pdf)
})
mux.Handle(basePath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setSecurityHeaders(w)
assets.ServeHTTP(w, r)
Expand All @@ -185,14 +212,13 @@ func serveAndOpen(markdownPath, token string, page []byte, live bool, ngrokBinar
server := &http.Server{
Handler: mux,
ReadHeaderTimeout: 3 * time.Second,
WriteTimeout: 15 * time.Second,
WriteTimeout: pdfTimeout + 5*time.Second,
IdleTimeout: 15 * time.Second,
}
serveErr := make(chan error, 1)
go func() { serveErr <- server.Serve(listener) }()
defer shutdownServer(server)

url := "http://" + listener.Addr().String() + basePath
var tunnel *ngrok.Tunnel
if live {
tunnel, err = ngrok.Start(ngrokBinary, listener.Addr().String())
Expand Down Expand Up @@ -221,6 +247,34 @@ func serveAndOpen(markdownPath, token string, page []byte, live bool, ngrokBinar
return nil
}

func generatePDF(ctx context.Context, pageURL string) ([]byte, error) {
allocator, cancelAllocator := chromedp.NewExecAllocator(ctx, chromedp.DefaultExecAllocatorOptions[:]...)
defer cancelAllocator()
browserContext, cancelBrowser := chromedp.NewContext(allocator)
defer cancelBrowser()

var pdf []byte
err := chromedp.Run(browserContext,
chromedp.Navigate(pageURL),
chromedp.WaitReady("body", chromedp.ByQuery),
chromedp.Poll(`window.mdoReady === true`, nil, chromedp.WithPollingInterval(100*time.Millisecond)),
chromedp.ActionFunc(func(ctx context.Context) error {
var err error
pdf, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithPreferCSSPageSize(true).
WithGenerateTaggedPDF(true).
WithGenerateDocumentOutline(true).
Do(ctx)
return err
}),
)
if err != nil {
return nil, fmt.Errorf("renderizar PDF: %w", err)
}
return pdf, nil
}

func waitForLiveServer(serveErr <-chan error, tunnel *ngrok.Tunnel) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
Expand Down
42 changes: 42 additions & 0 deletions internal/app/app_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,54 @@
package app

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func TestGeneratePDF(t *testing.T) {
if os.Getenv("CI") != "" {
t.Skip("headless Chrome sandbox is unavailable in CI")
}
if !chromeAvailable() {
t.Skip("no Chrome-compatible browser is installed")
}

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><h1>Documento</h1><h2>Seção</h2><script>window.mdoReady = true</script>`))
}))
defer server.Close()

ctx, cancel := context.WithTimeout(context.Background(), pdfTimeout)
defer cancel()
pdf, err := generatePDF(ctx, server.URL)
if err != nil {
t.Fatalf("generatePDF() error = %v", err)
}
if !bytes.HasPrefix(pdf, []byte("%PDF-")) {
t.Fatalf("generatePDF() returned %q, want a PDF", pdf[:min(len(pdf), 8)])
}
if !bytes.Contains(pdf, []byte("/Outlines")) {
t.Error("generatePDF() did not embed a document outline")
}
}

func chromeAvailable() bool {
for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "brave", "brave-browser", "msedge"} {
if _, err := exec.LookPath(name); err == nil {
return true
}
}
return false
}

func TestReadMarkdown(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "document.md")
Expand Down
1 change: 1 addition & 0 deletions internal/web/embed.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ var mermaid template.JS
type PageData struct {
Title string
Path string
PDFName string
Content template.HTML
Token string
}
Expand Down
7 changes: 4 additions & 3 deletions internal/web/embed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ func TestPageIncludesDocumentAndEmbeddedAssets(t *testing.T) {
`README &lt;test&gt;.md`, `<h1 id="hello">Hello</h1>`,
`<link rel="icon" href="data:,">`,
`globalThis["mermaid"]`, `const token = "abc123"`,
`fetch(`, `enableDiagramZoom`, `Aumentar zoom`, `requestFullscreen`,
`Navegação do diagrama`, `Mover diagrama para a esquerda`,
`fetch(`, `enableDiagramZoom`, `Zoom in`, `requestFullscreen`,
`Diagram navigation`, `Pan diagram left`,
`theme-button`, `prefers-color-scheme: light`, `rerenderMermaid`,
`pdf-button`, `Download PDF`, `pdfBlob`, `window.mdoReady`, `/pdf`, `documentReady`,
`mermaidSourceWithFallback`, `#59;`,
`startViewTransition`, `to-light`, `to-dark`, `theme-icon-sun`,
`pre.mermaid`, `pre:not(.mermaid)`,
`pre.mermaid`, `pre:not(.mermaid)`, `@media print`, `@page { size:A4`, `break-inside:avoid`,
`link.getAttribute('href')`, `a[href^="#"]`, `target.scrollIntoView`,
} {
if !strings.Contains(html, expected) {
Expand Down
Loading
Loading