diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 4cc7c6f..2e760c7 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -34,7 +34,7 @@
// [[preferences/devcontainer-ports]] for the full rationale and the
// consumer recipes (launch.json, tasks.json, host scripts).
"initializeCommand": ".devcontainer/initializeCommand.sh",
- "postCreateCommand": "go version && node --version",
+ "postCreateCommand": "git config --global --add safe.directory /workspaces/mind-map && go version && node --version && { CHROME=$(find ~/.cache/ms-playwright/chromium-*/chrome-linux/chrome 2>/dev/null | head -1) && [ -n \"$CHROME\" ] && sudo ln -sf \"$CHROME\" /usr/local/bin/chromium || true; }",
"postAttachCommand": "npm install --prefix webui",
"customizations": {
"vscode": {
diff --git a/.gitignore b/.gitignore
index 96ed349..6adf002 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
webui/dist/*
!webui/dist/.gitkeep
webui/node_modules/
+tools/node_modules/
# VS Code cache
.vscode/cache/
diff --git a/README.md b/README.md
index 0ceafdb..2eff183 100644
--- a/README.md
+++ b/README.md
@@ -84,7 +84,7 @@ The web UI is a static Preact app served by `mind-map serve` over HTTP. It uses
Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wiki` by default). Multiple stdio processes can safely share the same wiki via SQLite page locking.
-## MCP Tools (11 total)
+## MCP Tools (12 total)
| Tool | Description |
|------|-------------|
@@ -100,6 +100,7 @@ Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wi
| `get_backlinks` | Get all pages that link to a given page |
| `register_sync` | Register a wiki path prefix to sync with a git remote |
| `reindex_wiki` | Force a reindex pass against on-disk markdown (rarely needed; useful after edits made outside the wiki API) |
+| `export_pages` | Export a page (and linked pages up to N hops) as zip or PDF |
## Wiki Features
@@ -110,6 +111,50 @@ Both modes use the same wiki engine and the same wiki directory (`~/.mind-map/wi
- **Full-text search**: SQLite FTS5 with ranked results and snippets
- **Multi-process safe**: SQLite page locking for concurrent agent access
- **Git sync**: sync wiki pages to GitHub repo wikis via configurable mappings
+- **Export / Share**: export pages as zip or PDF — see below
+
+## Export & Share
+
+Export one or more wiki pages for sharing outside the wiki. The system follows **wikilinks** from a starting page (BFS traversal) to collect a self-contained set of pages with no broken links.
+
+### Depth control
+
+| Depth | Meaning |
+|-------|---------|
+| `0` | Just the selected page |
+| `1` | The page + all pages it links to |
+| `N` | N hops of outgoing links |
+| `-1` | Unlimited — follow all reachable links |
+
+### Formats
+
+Export is **pluggable** — formats register themselves at startup:
+
+| Format | Description | Requirements |
+|--------|-------------|--------------|
+| **zip** | Archive of markdown files + assets | None |
+| **pdf** | Multi-page PDF with table of contents | Chrome, Edge, or Chromium on `$PATH` |
+
+PDF export uses [chromedp](https://github.com/chromedp/chromedp) to render markdown → HTML → PDF via a headless browser. It includes a clickable TOC, embedded images, and a print-friendly stylesheet. The PDF sharer only registers if a supported browser is detected — no browser, no PDF option.
+
+### Using export
+
+**Web UI**: Click the share icon in the page header → pick depth and format → download.
+
+**REST API**:
+```
+GET /api/export?format=zip&page=projects/my-project&depth=1
+GET /api/export/formats # list available formats with settings schemas
+```
+
+**MCP tool**:
+```
+export_pages(format: "pdf", page: "architecture/auth", depth: -1)
+```
+
+### Adding new formats
+
+Implement the `Sharer` interface in `internal/share/` and call `Register()` in an `init()` function. The format automatically appears in the UI, REST API, and MCP tool.
## Web UI
@@ -119,10 +164,60 @@ The built-in web UI is a lightning-fast, Metro-inspired, chromeless Preact app:
- Markdown rendering with wikilinks as clickable links
- Backlinks section on every page
- Edit mode with raw markdown editor
+- Export / share panel for zip and PDF export
+- Interactive graph view of the wiki link structure
- Dark / light theme toggle
The web UI speaks the same language as the wiki engine. If an agent creates a page via stdio, it appears in the browser. If you edit in the browser, the agent sees the change on its next read.
+### Page view
+
+
+
+
+
+### Dark theme
+
+
+
+
+
+### Search
+
+
+
+
+
+### Edit mode
+
+
+
+
+
+### Export panel
+
+
+
+
+
+### Graph view
+
+
+
+
+
+### Backlinks
+
+
+
+
+
+### Settings
+
+
+
+
+
## Service Management
The installer can set up mind-map as a persistent system service that starts on boot:
diff --git a/docs/screenshots/backlinks.png b/docs/screenshots/backlinks.png
new file mode 100644
index 0000000..34e2d96
Binary files /dev/null and b/docs/screenshots/backlinks.png differ
diff --git a/docs/screenshots/edit-mode.png b/docs/screenshots/edit-mode.png
new file mode 100644
index 0000000..4a60017
Binary files /dev/null and b/docs/screenshots/edit-mode.png differ
diff --git a/docs/screenshots/export-panel.png b/docs/screenshots/export-panel.png
new file mode 100644
index 0000000..bedd127
Binary files /dev/null and b/docs/screenshots/export-panel.png differ
diff --git a/docs/screenshots/graph-view.png b/docs/screenshots/graph-view.png
new file mode 100644
index 0000000..7f2cc45
Binary files /dev/null and b/docs/screenshots/graph-view.png differ
diff --git a/docs/screenshots/page-view-dark.png b/docs/screenshots/page-view-dark.png
new file mode 100644
index 0000000..b815d82
Binary files /dev/null and b/docs/screenshots/page-view-dark.png differ
diff --git a/docs/screenshots/page-view.png b/docs/screenshots/page-view.png
new file mode 100644
index 0000000..fccdeb3
Binary files /dev/null and b/docs/screenshots/page-view.png differ
diff --git a/docs/screenshots/search.png b/docs/screenshots/search.png
new file mode 100644
index 0000000..c55912d
Binary files /dev/null and b/docs/screenshots/search.png differ
diff --git a/docs/screenshots/settings.png b/docs/screenshots/settings.png
new file mode 100644
index 0000000..6114daa
Binary files /dev/null and b/docs/screenshots/settings.png differ
diff --git a/go.mod b/go.mod
index e5baf03..5146c81 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,8 @@ module github.com/aniongithub/mind-map
go 1.26.2
require (
+ github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
+ github.com/chromedp/chromedp v0.15.1
github.com/kardianos/service v1.2.4
github.com/modelcontextprotocol/go-sdk v1.5.0
github.com/spf13/cobra v1.10.2
@@ -12,7 +14,12 @@ require (
)
require (
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // 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
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index 7aeb682..b4e9aba 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,20 @@
+github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
+github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
+github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
+github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
+github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -17,12 +31,16 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
+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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.5.0 h1:CHU0FIX9kpueNkxuYtfYQn1Z0slhFzBZuq+x6IiblIU=
github.com/modelcontextprotocol/go-sdk v1.5.0/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
diff --git a/internal/httpapi/export.go b/internal/httpapi/export.go
new file mode 100644
index 0000000..d3bebab
--- /dev/null
+++ b/internal/httpapi/export.go
@@ -0,0 +1,157 @@
+package httpapi
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/aniongithub/mind-map/internal/share"
+ "github.com/aniongithub/mind-map/internal/wiki"
+)
+
+// registerExport wires the export routes. Called from register().
+func (s *Server) registerExport(mux *http.ServeMux) {
+ mux.HandleFunc("GET /api/export/formats", s.getExportFormats)
+ mux.HandleFunc("GET /api/export", s.getExport)
+}
+
+// getExportFormats handles GET /api/export/formats. Returns the list of
+// registered export formats with their settings schemas so the UI can
+// render format-specific options.
+func (s *Server) getExportFormats(rw http.ResponseWriter, r *http.Request) {
+ writeJSON(rw, share.Formats())
+}
+
+// getExport handles GET /api/export. Streams an exported file in the
+// requested format.
+//
+// Query parameters:
+// - format (required): the sharer name (e.g. "zip")
+// - page (required): starting page path for link traversal
+// - depth (optional): link-follow depth (-1 = unlimited, 0 = just this
+// page, 1 = page + its links, etc.). Defaults to 0.
+// - all other params become plugin settings
+func (s *Server) getExport(rw http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+
+ format := r.URL.Query().Get("format")
+ if format == "" {
+ http.Error(rw, "format parameter is required", http.StatusBadRequest)
+ return
+ }
+
+ sharer := share.Get(format)
+ if sharer == nil {
+ http.Error(rw, fmt.Sprintf("unknown export format: %q", format), http.StatusBadRequest)
+ return
+ }
+
+ page := r.URL.Query().Get("page")
+ if page == "" {
+ http.Error(rw, "page parameter is required", http.StatusBadRequest)
+ return
+ }
+
+ depth := 0
+ if d := r.URL.Query().Get("depth"); d != "" {
+ parsed, err := strconv.Atoi(d)
+ if err != nil {
+ http.Error(rw, "depth must be an integer", http.StatusBadRequest)
+ return
+ }
+ depth = parsed
+ }
+
+ // Build plugin-specific settings from remaining query params
+ settings := make(map[string]any)
+ reserved := map[string]bool{"format": true, "page": true, "depth": true}
+ for key, values := range r.URL.Query() {
+ if reserved[key] || len(values) == 0 {
+ continue
+ }
+ val := values[0]
+ if val == "true" || val == "false" {
+ settings[key] = val == "true"
+ } else if n, err := strconv.Atoi(val); err == nil {
+ settings[key] = n
+ } else {
+ settings[key] = val
+ }
+ }
+
+ cfg := share.ShareConfig{
+ Format: format,
+ Page: page,
+ Depth: depth,
+ Settings: settings,
+ }
+
+ // Gather pages via link-graph traversal
+ exportPages, err := s.deps.Wiki.ExportPages(r.Context(), page, depth)
+ if err != nil {
+ http.Error(rw, "export failed: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Convert wiki.ExportPage to share.Page
+ pages := make([]share.Page, len(exportPages))
+ for i, ep := range exportPages {
+ pages[i] = share.Page{
+ Path: ep.Path,
+ Title: ep.Title,
+ Body: ep.Body,
+ Frontmatter: ep.Frontmatter,
+ ModifiedAt: ep.ModifiedAt,
+ ImageRefs: ep.ImageRefs,
+ }
+ }
+
+ req := share.ExportRequest{
+ Config: cfg,
+ Pages: pages,
+ Assets: &wikiAssetReader{wiki: s.deps.Wiki, ctx: r.Context()},
+ }
+
+ // Build filename for Content-Disposition
+ filename := exportFilename(page, sharer.FileExtension())
+
+ rw.Header().Set("Content-Type", sharer.ContentType())
+ rw.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
+
+ if err := sharer.Export(r.Context(), rw, req); err != nil {
+ slog.Error("export stream failed",
+ slog.String("format", format),
+ slog.String("page", page),
+ slog.Any("error", err),
+ )
+ return
+ }
+
+ slog.Info("export completed",
+ slog.String("format", format),
+ slog.String("page", page),
+ slog.Int("depth", depth),
+ slog.Int("pages", len(pages)),
+ slog.Duration("elapsed", time.Since(start)),
+ )
+}
+
+// wikiAssetReader adapts the wiki to the share.AssetReader interface.
+type wikiAssetReader struct {
+ wiki *wiki.Wiki
+ ctx context.Context
+}
+
+func (r *wikiAssetReader) ReadAsset(_ context.Context, path string) ([]byte, string, error) {
+ return r.wiki.ReadAsset(r.ctx, path)
+}
+
+// exportFilename builds a suitable download filename from page path and extension.
+func exportFilename(page, ext string) string {
+ clean := strings.ReplaceAll(page, "/", "-")
+ return clean + ext
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index c8ae0a0..33d6d6e 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -177,6 +177,7 @@ func (s *Server) register(mux *http.ServeMux) {
mux.HandleFunc("POST /api/reindex", s.postReindex)
mux.HandleFunc("GET /api/sync/status", s.getSyncStatus)
s.registerAssets(mux)
+ s.registerExport(mux)
mux.Handle("/", s.staticHandler())
}
diff --git a/internal/mcp/server.go b/internal/mcp/server.go
index 0b2e61b..1152e55 100644
--- a/internal/mcp/server.go
+++ b/internal/mcp/server.go
@@ -8,10 +8,12 @@ import (
"errors"
"fmt"
"log/slog"
+ "os"
"strings"
"time"
"github.com/aniongithub/mind-map/internal/config"
+ "github.com/aniongithub/mind-map/internal/share"
"github.com/aniongithub/mind-map/internal/wiki"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@@ -147,6 +149,11 @@ func (s *Server) registerTools() {
Name: "delete_image",
Description: "Remove an image asset from the wiki. Pages that still reference the deleted image will have a dangling markdown link until edited — the caller is responsible for cleaning up references. Useful for capture tooling that wants a clean canonical filename across re-runs rather than auto-suffixed duplicates.",
}, s.deleteImage)
+
+ mcp.AddTool(s.server, &mcp.Tool{
+ Name: "export_pages",
+ Description: "Export wiki pages as a downloadable file. Starts from a given page and follows wikilinks to the specified depth. Returns the path to the exported file on disk. Depth: -1 = all reachable pages, 0 = just this page, 1 = page + its direct links, etc.",
+ }, s.exportPages)
}
// --- Tool input types ---
@@ -198,6 +205,13 @@ type moveInput struct {
Overwrite bool `json:"overwrite,omitempty" jsonschema:"set true to replace an existing destination page; ask the user for explicit confirmation first since the destination's content will be lost"`
}
+type exportInput struct {
+ Format string `json:"format,omitempty" jsonschema:"export format name (default: 'zip'). Use GET /api/export/formats to list available formats."`
+ Page string `json:"page" jsonschema:"starting page path for link-graph traversal"`
+ Depth int `json:"depth,omitempty" jsonschema:"link-follow depth: -1 = unlimited, 0 = just this page, 1 = page + its links, etc."`
+ Settings map[string]any `json:"settings,omitempty" jsonschema:"plugin-specific settings as key-value pairs"`
+}
+
// --- Tool handlers ---
func (s *Server) searchPages(ctx context.Context, _ *mcp.CallToolRequest, input searchInput) (*mcp.CallToolResult, any, error) {
@@ -475,3 +489,111 @@ func (s *Server) reindexWiki(ctx context.Context, _ *mcp.CallToolRequest, _ any)
)
return textResult(stats)
}
+
+func (s *Server) exportPages(ctx context.Context, _ *mcp.CallToolRequest, input exportInput) (*mcp.CallToolResult, any, error) {
+ start := time.Now()
+
+ format := input.Format
+ if format == "" {
+ format = "zip"
+ }
+
+ sharer := share.Get(format)
+ if sharer == nil {
+ return nil, nil, fmt.Errorf("unknown export format %q; available: %v", format, formatNames())
+ }
+
+ exportPages, err := s.wiki.ExportPages(ctx, input.Page, input.Depth)
+ if err != nil {
+ slog.Error("tool.export_pages gather failed", slog.Any("error", err))
+ return nil, nil, err
+ }
+
+ if len(exportPages) == 0 {
+ return &mcp.CallToolResult{
+ Content: []mcp.Content{
+ &mcp.TextContent{Text: "No pages found matching the given page/depth."},
+ },
+ }, nil, nil
+ }
+
+ // Convert to share.Page
+ pages := make([]share.Page, len(exportPages))
+ for i, ep := range exportPages {
+ pages[i] = share.Page{
+ Path: ep.Path,
+ Title: ep.Title,
+ Body: ep.Body,
+ Frontmatter: ep.Frontmatter,
+ ModifiedAt: ep.ModifiedAt,
+ ImageRefs: ep.ImageRefs,
+ }
+ }
+
+ cfg := share.ShareConfig{
+ Format: format,
+ Page: input.Page,
+ Depth: input.Depth,
+ Settings: input.Settings,
+ }
+
+ // Write to a temp file
+ tmpFile, err := os.CreateTemp("", "mind-map-export-*"+sharer.FileExtension())
+ if err != nil {
+ return nil, nil, fmt.Errorf("create temp file: %w", err)
+ }
+ defer tmpFile.Close()
+
+ req := share.ExportRequest{
+ Config: cfg,
+ Pages: pages,
+ Assets: &mcpAssetReader{wiki: s.wiki},
+ }
+
+ if err := sharer.Export(ctx, tmpFile, req); err != nil {
+ os.Remove(tmpFile.Name())
+ return nil, nil, fmt.Errorf("export failed: %w", err)
+ }
+
+ info, _ := tmpFile.Stat()
+ var sizeBytes int64
+ if info != nil {
+ sizeBytes = info.Size()
+ }
+
+ slog.Info("tool.export_pages",
+ slog.String("format", format),
+ slog.String("page", input.Page),
+ slog.Int("depth", input.Depth),
+ slog.Int("pages", len(pages)),
+ slog.Int64("size_bytes", sizeBytes),
+ slog.Duration("elapsed", time.Since(start)),
+ )
+
+ result := map[string]any{
+ "path": tmpFile.Name(),
+ "format": format,
+ "pages": len(pages),
+ "size_bytes": sizeBytes,
+ }
+ return textResult(result)
+}
+
+// mcpAssetReader adapts the wiki to the share.AssetReader interface for MCP.
+type mcpAssetReader struct {
+ wiki *wiki.Wiki
+}
+
+func (r *mcpAssetReader) ReadAsset(ctx context.Context, path string) ([]byte, string, error) {
+ return r.wiki.ReadAsset(ctx, path)
+}
+
+// formatNames returns the names of all registered share formats.
+func formatNames() []string {
+ formats := share.Formats()
+ names := make([]string, len(formats))
+ for i, f := range formats {
+ names[i] = f.Name
+ }
+ return names
+}
diff --git a/internal/share/pdf.go b/internal/share/pdf.go
new file mode 100644
index 0000000..7f87487
--- /dev/null
+++ b/internal/share/pdf.go
@@ -0,0 +1,403 @@
+package share
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "fmt"
+ "html"
+ "io"
+ "log/slog"
+ "os/exec"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/chromedp/cdproto/page"
+ "github.com/chromedp/chromedp"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "github.com/yuin/goldmark/renderer"
+ goldhtml "github.com/yuin/goldmark/renderer/html"
+)
+
+// PDFSharer exports wiki pages as a multi-page PDF with a table of contents,
+// rendered via a Chromium-based browser (Chrome, Edge, or Chromium).
+type PDFSharer struct{}
+
+func init() {
+ // Only register if a Chromium-based browser is available on the system.
+ if findBrowser() != "" {
+ Register(&PDFSharer{})
+ }
+}
+
+func (p *PDFSharer) Name() string { return "pdf" }
+func (p *PDFSharer) Description() string { return "Multi-page PDF with table of contents (requires Chrome/Edge)" }
+func (p *PDFSharer) ContentType() string { return "application/pdf" }
+func (p *PDFSharer) FileExtension() string { return ".pdf" }
+
+func (p *PDFSharer) Settings() SharerSettings {
+ return SharerSettings{
+ Fields: []SettingsField{
+ {
+ Key: "include_toc",
+ Label: "Include table of contents",
+ Description: "Generate a clickable table of contents at the beginning",
+ Type: "bool",
+ Default: true,
+ },
+ {
+ Key: "include_assets",
+ Label: "Embed images",
+ Description: "Embed referenced images inline in the PDF",
+ Type: "bool",
+ Default: true,
+ },
+ {
+ Key: "page_size",
+ Label: "Page size",
+ Description: "Paper size for the PDF",
+ Type: "enum",
+ Default: "A4",
+ Enum: []string{"A4", "Letter", "A3", "Legal"},
+ },
+ },
+ }
+}
+
+func (p *PDFSharer) Export(ctx context.Context, w io.Writer, req ExportRequest) error {
+ browserPath := findBrowser()
+ if browserPath == "" {
+ return fmt.Errorf("no Chromium-based browser found (need Chrome, Edge, or Chromium)")
+ }
+
+ // Extract settings
+ includeTOC := SettingBool(req.Config, "include_toc", true)
+ includeAssets := SettingBool(req.Config, "include_assets", true)
+ pageSize := SettingString(req.Config, "page_size", "A4")
+
+ // Render pages to HTML
+ htmlDoc := renderHTMLDocument(req.Pages, req.Assets, ctx, includeTOC, includeAssets)
+
+ // Convert to PDF via headless browser
+ pdfBytes, err := htmlToPDF(ctx, browserPath, htmlDoc, pageSize)
+ if err != nil {
+ return fmt.Errorf("PDF generation failed: %w", err)
+ }
+
+ _, err = w.Write(pdfBytes)
+ return err
+}
+
+// renderHTMLDocument builds a complete HTML document from the exported pages.
+func renderHTMLDocument(pages []Page, assets AssetReader, ctx context.Context, includeTOC, includeAssets bool) string {
+ var buf strings.Builder
+
+ buf.WriteString(``)
+ buf.WriteString(``)
+
+ // Table of contents
+ if includeTOC && len(pages) > 1 {
+ buf.WriteString(`
Contents
`)
+ for i, p := range pages {
+ title := p.Title
+ if title == "" {
+ title = p.Path
+ }
+ buf.WriteString(fmt.Sprintf(`