From 97193bb9fa492d00f6b0c33ff34b73abbef6455e Mon Sep 17 00:00:00 2001 From: Ani Date: Thu, 15 Oct 2026 17:17:55 +0000 Subject: [PATCH] feat: add DOCX export Add faithful Word document export with rendered Markdown, embedded images and Mermaid diagrams, deterministic page ordering, and scoped hyperlink handling. Modernize the WebUI launch configuration and unify its dynamic port source. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10ee7096-0a2f-4a28-b60f-06c66409f0a3 --- .devcontainer/devcontainer.json | 14 +- .devcontainer/initializeCommand.sh | 13 +- .vscode/launch.json | 16 +- .vscode/tasks.json | 5 +- README.md | 13 +- go.mod | 1 + go.sum | 2 + internal/share/docx.go | 715 +++++++++++++++++++++++++++++ internal/share/docx_test.go | 264 +++++++++++ internal/share/mermaid.go | 77 ++++ internal/wiki/export.go | 8 +- internal/wiki/export_test.go | 4 +- internal/wiki/pages.go | 2 +- 13 files changed, 1096 insertions(+), 38 deletions(-) create mode 100644 internal/share/docx.go create mode 100644 internal/share/docx_test.go create mode 100644 internal/share/mermaid.go diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ff2b5b8..000e0b0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,8 +14,9 @@ } }, // Runs on the HOST before the container is created/started. Picks a - // free TCP port, writes it to .devcontainer/ports.env. The container - // runs with --network host (see runArgs) so the same port number is + // free TCP port and writes it to .devcontainer/ports.env. Launch and + // task configurations read that file directly. The container runs + // with --network host (see runArgs) so the same port number is // both the in-container bind and the host-visible address — no port // forwarding involved. See the wiki page // [[preferences/devcontainer-ports]] for the full rationale and the @@ -57,13 +58,10 @@ // server binds directly on the host's network namespace at whatever // port initializeCommand picked. // - // --env-file injects MIND_MAP_HOST_PORT into the container so the - // mind-map binary (which reads it via `serve --addr`) listens on - // the right port. host-side consumers (launch.json, scripts) read - // the same file directly. + // Consumers read .devcontainer/ports.env at execution time instead + // of relying on container environment captured only at creation. "runArgs": [ - "--network", "host", - "--env-file", ".devcontainer/ports.env" + "--network", "host" ], "portsAttributes": { "4242": { diff --git a/.devcontainer/initializeCommand.sh b/.devcontainer/initializeCommand.sh index ecee54f..c2703d4 100755 --- a/.devcontainer/initializeCommand.sh +++ b/.devcontainer/initializeCommand.sh @@ -1,12 +1,10 @@ #!/usr/bin/env bash # Runs on the HOST (not in the container) before docker run. Picks a # free TCP port for the mind-map server and writes it to -# .devcontainer/ports.env. The value is consumed in two places: -# -# 1. Inside the container, via runArgs --env-file, so the mind-map -# binary's `serve --addr` reads $MIND_MAP_HOST_PORT. -# 2. On the host, by tools (launch.json, the screenshot harness, -# ad-hoc curl) that need to know what URL to hit. +# .devcontainer/ports.env. Launch configurations, tasks, the screenshot +# harness, and ad-hoc commands read the file when they run. Keeping the +# file as the single source of truth avoids stale container environment +# values when an existing devcontainer is restarted. # # Because the container runs with --network host (see devcontainer.json # runArgs), there is no port forwarding involved — the container binds @@ -47,8 +45,7 @@ PORT=$(pick_port) # devcontainer CLI was invoked from. cd "$(dirname "$0")" -# `--env-file` (used in runArgs) accepts bare KEY=VALUE lines. Don't -# quote the value — docker chokes on that. +# Keep this shell-sourceable for tasks and simple to parse from launch.json. cat > ports.env < 1 { + if _, err := r.addParagraph(domain.StyleIDHeading1, "Contents"); err != nil { + return fmt.Errorf("add DOCX contents heading: %w", err) + } + for _, page := range pages { + title := page.Title + if title == "" { + title = page.Path + } + paragraph, err := r.addParagraph(domain.StyleIDListParagraph, "") + if err != nil { + return fmt.Errorf("add DOCX contents entry: %w", err) + } + if err := r.addStyledText(paragraph, "• ", docxTextStyle{}); err != nil { + return err + } + if err := r.addStyledText(paragraph, title, docxTextStyle{linkTarget: "#" + r.pageAnchors[normalizePagePath(page.Path)]}); err != nil { + return err + } + } + } + + for i, page := range pages { + if err := r.ctx.Err(); err != nil { + return err + } + if i > 0 || (includeTOC && len(pages) > 1) { + if err := r.document.AddPageBreak(); err != nil { + return fmt.Errorf("add DOCX page break: %w", err) + } + } + + r.page = page + r.pendingPageAnchor = r.pageAnchors[normalizePagePath(page.Path)] + r.source = []byte(rewriteWikilinks(page.Body)) + md := goldmark.New(goldmark.WithExtensions(extension.Table, extension.Strikethrough, extension.TaskList)) + parsed := md.Parser().Parse(text.NewReader(r.source)) + if err := r.renderBlocks(parsed, 0, false); err != nil { + return fmt.Errorf("render DOCX page %q: %w", page.Path, err) + } + if r.pendingPageAnchor != "" { + if _, err := r.addParagraph(domain.StyleIDNormal, ""); err != nil { + return fmt.Errorf("add DOCX bookmark for empty page %q: %w", page.Path, err) + } + } + } + return nil +} + +func (r *docxRenderer) addParagraph(style, value string) (domain.Paragraph, error) { + paragraph, err := r.document.AddParagraph() + if err != nil { + return nil, err + } + if style != "" { + if err := paragraph.SetStyle(style); err != nil { + return nil, err + } + } + if err := r.attachPageBookmark(paragraph, style); err != nil { + return nil, err + } + if value != "" { + if err := r.addStyledText(paragraph, value, docxTextStyle{}); err != nil { + return nil, err + } + } + return paragraph, nil +} + +func (r *docxRenderer) attachPageBookmark(paragraph domain.Paragraph, style string) error { + if r.pendingPageAnchor == "" || strings.HasPrefix(style, "Heading") { + return nil + } + bookmarkable, ok := paragraph.(bookmarkSetter) + if !ok { + return fmt.Errorf("DOCX paragraph does not support bookmarks") + } + r.bookmarkID++ + bookmarkable.SetBookmark(fmt.Sprint(r.bookmarkID), r.pendingPageAnchor) + r.pendingPageAnchor = "" + return nil +} + +func (r *docxRenderer) renderBlocks(parent ast.Node, listLevel int, quote bool) error { + for node := parent.FirstChild(); node != nil; node = node.NextSibling() { + if err := r.ctx.Err(); err != nil { + return err + } + switch n := node.(type) { + case *ast.Heading: + level := min(n.Level, 6) + paragraph, err := r.addParagraph(fmt.Sprintf("Heading%d", level), "") + if err != nil { + return err + } + if err := r.renderInlines(paragraph, n, docxTextStyle{}); err != nil { + return err + } + case *ast.Paragraph: + style := domain.StyleIDNormal + if quote { + style = domain.StyleIDIntenseQuote + } + paragraph, err := r.addParagraph(style, "") + if err != nil { + return err + } + if err := r.renderInlines(paragraph, n, docxTextStyle{}); err != nil { + return err + } + case *ast.List: + if err := r.renderList(n, listLevel); err != nil { + return err + } + case *ast.Blockquote: + if err := r.renderBlocks(n, listLevel, true); err != nil { + return err + } + case *ast.CodeBlock: + if err := r.addCodeBlock(string(n.Text(r.source))); err != nil { + return err + } + case *ast.FencedCodeBlock: + if r.isMermaidBlock(n) { + if err := r.addMermaidDiagram(string(n.Text(r.source))); err != nil { + return err + } + } else if err := r.addCodeBlock(string(n.Text(r.source))); err != nil { + return err + } + case *ast.ThematicBreak: + if _, err := r.addParagraph(domain.StyleIDNormal, "―"); err != nil { + return err + } + case *extast.Table: + if err := r.addTable(n); err != nil { + return err + } + case *ast.TextBlock: + paragraph, err := r.addParagraph(domain.StyleIDNormal, "") + if err != nil { + return err + } + if err := r.renderInlines(paragraph, n, docxTextStyle{}); err != nil { + return err + } + default: + if n.Type() == ast.TypeBlock && n.HasChildren() { + if err := r.renderBlocks(n, listLevel, quote); err != nil { + return err + } + } + } + } + return nil +} + +func (r *docxRenderer) renderList(list *ast.List, level int) error { + itemNumber := list.Start + for item := list.FirstChild(); item != nil; item = item.NextSibling() { + listItem, ok := item.(*ast.ListItem) + if !ok { + continue + } + firstBlock := true + for child := listItem.FirstChild(); child != nil; child = child.NextSibling() { + switch n := child.(type) { + case *ast.Paragraph, *ast.TextBlock: + paragraph, err := r.addParagraph(domain.StyleIDListParagraph, "") + if err != nil { + return err + } + if err := paragraph.SetIndentLeft((level + 1) * 360); err != nil { + return err + } + if firstBlock { + marker := "• " + if list.IsOrdered() { + marker = fmt.Sprintf("%d. ", itemNumber) + } + if err := r.addStyledText(paragraph, marker, docxTextStyle{}); err != nil { + return err + } + } + if err := r.renderInlines(paragraph, n, docxTextStyle{}); err != nil { + return err + } + firstBlock = false + case *ast.List: + if err := r.renderList(n, level+1); err != nil { + return err + } + case *ast.Blockquote: + if err := r.renderBlocks(n, level, true); err != nil { + return err + } + case *ast.CodeBlock: + if err := r.addCodeBlock(string(n.Text(r.source))); err != nil { + return err + } + case *ast.FencedCodeBlock: + if r.isMermaidBlock(n) { + if err := r.addMermaidDiagram(string(n.Text(r.source))); err != nil { + return err + } + } else if err := r.addCodeBlock(string(n.Text(r.source))); err != nil { + return err + } + case *extast.Table: + if err := r.addTable(n); err != nil { + return err + } + } + } + itemNumber++ + } + return nil +} + +func (r *docxRenderer) addCodeBlock(code string) error { + code = strings.TrimSuffix(code, "\n") + for _, line := range strings.Split(code, "\n") { + paragraph, err := r.addParagraph(domain.StyleIDNoSpacing, "") + if err != nil { + return err + } + if err := r.addStyledText(paragraph, line, docxTextStyle{code: true}); err != nil { + return err + } + } + return nil +} + +func (r *docxRenderer) isMermaidBlock(block *ast.FencedCodeBlock) bool { + return strings.EqualFold(strings.TrimSpace(string(block.Language(r.source))), "mermaid") +} + +func (r *docxRenderer) addMermaidDiagram(definition string) error { + png, err := renderMermaidPNG(r.ctx, definition) + if err != nil { + slog.Warn("docx: Mermaid render failed; using code fallback", + slog.String("page", r.page.Path), + slog.Any("error", err), + ) + return r.addCodeBlock(definition) + } + paragraph, err := r.addParagraph(domain.StyleIDNormal, "") + if err != nil { + return err + } + if err := r.addPictureBytes(paragraph, png, domain.ImageFormatPNG, "Mermaid diagram"); err != nil { + slog.Warn("docx: Mermaid image embed failed; using code fallback", + slog.String("page", r.page.Path), + slog.Any("error", err), + ) + return r.addCodeBlock(definition) + } + return nil +} + +func (r *docxRenderer) addTable(tableNode *extast.Table) error { + var rows []ast.Node + maxColumns := 0 + for rowNode := tableNode.FirstChild(); rowNode != nil; rowNode = rowNode.NextSibling() { + switch rowNode.(type) { + case *extast.TableHeader, *extast.TableRow: + rows = append(rows, rowNode) + columns := 0 + for cell := rowNode.FirstChild(); cell != nil; cell = cell.NextSibling() { + if _, ok := cell.(*extast.TableCell); ok { + columns++ + } + } + maxColumns = max(maxColumns, columns) + } + } + if len(rows) == 0 || maxColumns == 0 { + return nil + } + + table, err := r.document.AddTable(len(rows), maxColumns) + if err != nil { + return err + } + if err := table.SetStyle(domain.TableStyleAccent1); err != nil { + return err + } + for rowIndex, rowNode := range rows { + header := false + if _, ok := rowNode.(*extast.TableHeader); ok { + header = true + } + row, err := table.Row(rowIndex) + if err != nil { + return err + } + column := 0 + for cellNode := rowNode.FirstChild(); cellNode != nil; cellNode = cellNode.NextSibling() { + if _, ok := cellNode.(*extast.TableCell); !ok { + continue + } + cell, err := row.Cell(column) + if err != nil { + return err + } + paragraph, err := cell.AddParagraph() + if err != nil { + return err + } + if err := r.attachPageBookmark(paragraph, domain.StyleIDNormal); err != nil { + return err + } + if err := r.renderInlines(paragraph, cellNode, docxTextStyle{bold: header}); err != nil { + return err + } + column++ + } + } + return nil +} + +func (r *docxRenderer) renderInlines(paragraph domain.Paragraph, parent ast.Node, style docxTextStyle) error { + for node := parent.FirstChild(); node != nil; node = node.NextSibling() { + switch n := node.(type) { + case *ast.Text: + if err := r.addStyledText(paragraph, string(n.Segment.Value(r.source)), style); err != nil { + return err + } + if n.HardLineBreak() || n.SoftLineBreak() { + if err := r.addStyledText(paragraph, " ", style); err != nil { + return err + } + } + case *ast.String: + if err := r.addStyledText(paragraph, string(n.Value), style); err != nil { + return err + } + case *ast.CodeSpan: + next := style + next.code = true + if err := r.renderInlines(paragraph, n, next); err != nil { + return err + } + case *ast.Emphasis: + next := style + if n.Level == 2 { + next.bold = true + } else { + next.italic = true + } + if err := r.renderInlines(paragraph, n, next); err != nil { + return err + } + case *extast.Strikethrough: + next := style + next.strike = true + if err := r.renderInlines(paragraph, n, next); err != nil { + return err + } + case *ast.Link: + next := style + next.linkTarget, next.outOfScopeWiki = r.resolveLink(string(n.Destination)) + if err := r.renderInlines(paragraph, n, next); err != nil { + return err + } + case *ast.AutoLink: + destination := string(n.URL(r.source)) + target, outOfScope := r.resolveLink(destination) + if err := r.addStyledText(paragraph, destination, docxTextStyle{linkTarget: target, outOfScopeWiki: outOfScope}); err != nil { + return err + } + case *ast.Image: + if err := r.addImage(paragraph, string(n.Destination), r.inlinePlainText(n)); err != nil { + return err + } + case *extast.TaskCheckBox: + mark := "☐ " + if n.IsChecked { + mark = "☒ " + } + if err := r.addStyledText(paragraph, mark, style); err != nil { + return err + } + default: + if n.HasChildren() { + if err := r.renderInlines(paragraph, n, style); err != nil { + return err + } + } + } + } + return nil +} + +func (r *docxRenderer) resolveLink(destination string) (target string, outOfScopeWiki bool) { + if strings.HasPrefix(destination, "mind-map:") { + path, err := url.PathUnescape(strings.TrimPrefix(destination, "mind-map:")) + if err != nil { + return "", true + } + anchor, ok := r.pageAnchors[normalizePagePath(path)] + if !ok { + return "", true + } + return "#" + anchor, false + } + if isExternalLink(destination) { + return destination, false + } + return "", false +} + +func (r *docxRenderer) addStyledText(paragraph domain.Paragraph, value string, style docxTextStyle) error { + if value == "" { + return nil + } + run, err := paragraph.AddRun() + if err != nil { + return err + } + if err := run.SetText(value); err != nil { + return err + } + if err := run.SetBold(style.bold); err != nil { + return err + } + if err := run.SetItalic(style.italic); err != nil { + return err + } + if err := run.SetStrike(style.strike); err != nil { + return err + } + if style.code { + if err := run.SetFont(domain.Font{Name: "Consolas"}); err != nil { + return err + } + if err := run.SetHighlight(domain.HighlightLightGray); err != nil { + return err + } + } + if style.outOfScopeWiki { + if err := run.SetColor(domain.Color{R: 0x66, G: 0x66, B: 0x66}); err != nil { + return err + } + return run.SetUnderline(domain.UnderlineSingle) + } + if style.linkTarget != "" { + field := docx.NewHyperlinkField(style.linkTarget, value) + if err := run.SetColor(domain.Color{R: 0x05, G: 0x63, B: 0xC1}); err != nil { + return err + } + if err := run.SetUnderline(domain.UnderlineSingle); err != nil { + return err + } + if err := run.AddField(field); err != nil { + return err + } + } + return nil +} + +func (r *docxRenderer) addImage(paragraph domain.Paragraph, destination, alt string) error { + fallback := alt + if fallback == "" { + fallback = filepath.Base(destination) + } + assetPath := r.resolveImagePath(destination) + if !r.includeAssets || r.assets == nil || assetPath == "" { + return r.addStyledText(paragraph, "[Image: "+fallback+"]", docxTextStyle{italic: true}) + } + + content, mimeType, err := r.assets.ReadAsset(r.ctx, assetPath) + if err != nil { + return r.addStyledText(paragraph, "[Image: "+fallback+"]", docxTextStyle{italic: true}) + } + format := docxImageFormat(mimeType) + if format == "" { + return r.addStyledText(paragraph, "[Image: "+fallback+"]", docxTextStyle{italic: true}) + } + if err := r.addPictureBytes(paragraph, content, format, assetPath); err != nil { + return r.addStyledText(paragraph, "[Image: "+fallback+"]", docxTextStyle{italic: true}) + } + return nil +} + +func (r *docxRenderer) addPictureBytes(paragraph domain.Paragraph, content []byte, format domain.ImageFormat, name string) error { + cfg, _, err := image.DecodeConfig(bytes.NewReader(content)) + if err != nil { + return fmt.Errorf("read DOCX image dimensions for %q: %w", name, err) + } + if cfg.Width <= 0 || cfg.Height <= 0 { + return fmt.Errorf("read DOCX image dimensions for %q: invalid dimensions", name) + } + + width := float64(cfg.Width) / 96 + height := float64(cfg.Height) / 96 + if width > 6.5 { + height *= 6.5 / width + width = 6.5 + } + if height > 8 { + width *= 8 / height + height = 8 + } + picture, err := paragraph.AddImageFromBytesWithSize(content, format, domain.NewImageSizeInches(width, height)) + if err != nil { + return fmt.Errorf("embed DOCX image %q: %w", name, err) + } + if err := picture.SetDescription(name); err != nil { + return fmt.Errorf("set DOCX image description for %q: %w", name, err) + } + return nil +} + +func (r *docxRenderer) resolveImagePath(destination string) string { + clean := strings.TrimPrefix(destination, "/assets/") + clean = strings.TrimPrefix(clean, "assets/") + for _, ref := range r.page.ImageRefs { + if ref == destination || ref == clean { + return ref + } + } + return "" +} + +func (r *docxRenderer) inlinePlainText(parent ast.Node) string { + var out strings.Builder + _ = ast.Walk(parent, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch n := node.(type) { + case *ast.Text: + out.Write(n.Segment.Value(r.source)) + case *ast.String: + out.Write(n.Value) + } + return ast.WalkContinue, nil + }) + return out.String() +} + +func rewriteWikilinks(body string) string { + var out strings.Builder + for { + start := strings.Index(body, "[[") + if start < 0 { + out.WriteString(body) + break + } + end := strings.Index(body[start+2:], "]]") + if end < 0 { + out.WriteString(body) + break + } + + out.WriteString(body[:start]) + raw := strings.TrimSpace(body[start+2 : start+2+end]) + target := raw + label := raw + if pipe := strings.Index(raw, "|"); pipe >= 0 { + target = strings.TrimSpace(raw[:pipe]) + label = strings.TrimSpace(raw[pipe+1:]) + } + if target == "" || label == "" { + out.WriteString(body[start : start+2+end+2]) + } else { + label = strings.NewReplacer(`\`, `\\`, `[`, `\[`, `]`, `\]`).Replace(label) + out.WriteString("[") + out.WriteString(label) + out.WriteString("](mind-map:") + out.WriteString(url.PathEscape(target)) + out.WriteString(")") + } + body = body[start+2+end+2:] + } + return out.String() +} + +func normalizePagePath(path string) string { + path = strings.TrimSpace(strings.TrimPrefix(path, "/")) + return strings.TrimSuffix(path, ".md") +} + +func isExternalLink(destination string) bool { + parsed, err := url.Parse(destination) + return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https" || parsed.Scheme == "mailto") +} + +func docxImageFormat(mimeType string) domain.ImageFormat { + switch strings.ToLower(strings.TrimSpace(strings.Split(mimeType, ";")[0])) { + case "image/png": + return domain.ImageFormatPNG + case "image/jpeg", "image/jpg": + return domain.ImageFormatJPEG + case "image/gif": + return domain.ImageFormatGIF + default: + return "" + } +} diff --git a/internal/share/docx_test.go b/internal/share/docx_test.go new file mode 100644 index 0000000..3bf2689 --- /dev/null +++ b/internal/share/docx_test.go @@ -0,0 +1,264 @@ +package share + +import ( + "archive/zip" + "bytes" + "context" + "encoding/xml" + "image" + "image/color" + "image/png" + "io" + "strings" + "testing" +) + +func TestDOCXRegistered(t *testing.T) { + s := Get("docx") + if s == nil { + t.Fatal("docx sharer not registered") + } + if s.ContentType() != docxContentType { + t.Fatalf("unexpected content type: %q", s.ContentType()) + } + if s.FileExtension() != ".docx" { + t.Fatalf("unexpected extension: %q", s.FileExtension()) + } +} + +func TestDOCXExportRendersMarkdown(t *testing.T) { + pages := []Page{ + { + Path: "guides/start", + Title: "Getting Started", + Body: `# Overview + +A paragraph with **bold**, *italic*, ~~deleted~~, ` + "`code`" + `, [a link](https://example.com), [[guides/next|the next guide]], and [[guides/missing|a missing guide]]. + +- first +- second + +1. alpha +2. beta + +> A useful note. + +| Name | Value | +| --- | --- | +| one | two | + +` + "```go\nfmt.Println(\"hello\")\n```" + ` +`, + }, + { + Path: "guides/next", + Title: "Next Steps", + Body: "## Continue\n\nMore details.\n", + }, + } + + var buf bytes.Buffer + err := (&DOCXSharer{}).Export(context.Background(), &buf, ExportRequest{ + Config: ShareConfig{Settings: map[string]any{"include_toc": true}}, + Pages: pages, + }) + if err != nil { + t.Fatalf("Export: %v", err) + } + + parts := readDOCXParts(t, buf.Bytes()) + for _, name := range []string{ + "[Content_Types].xml", + "_rels/.rels", + "word/document.xml", + "word/styles.xml", + "word/_rels/document.xml.rels", + } { + if _, ok := parts[name]; !ok { + t.Errorf("missing DOCX part %s", name) + } + } + + for name, data := range parts { + if !strings.HasSuffix(name, ".xml") && !strings.HasSuffix(name, ".rels") { + continue + } + var value any + if err := xml.Unmarshal(data, &value); err != nil { + t.Errorf("invalid XML in %s: %v", name, err) + } + } + + doc := string(parts["word/document.xml"]) + for _, want := range []string{ + "Contents", + "Getting Started", + "Next Steps", + "Overview", + "A useful note.", + "first", + "second", + "alpha", + "beta", + "the next guide", + "a missing guide", + "fmt.Println", + ``) || !strings.Contains(doc, ``) { + t.Error("out-of-scope wikilink is not rendered as dark gray underlined text") + } + if count := strings.Count(doc, "`) || + !strings.Contains(string(parts["word/document.xml"]), `r:embed=`) { + t.Error("document image drawing missing") + } + if !strings.Contains(string(parts["word/_rels/document.xml.rels"]), `Target="media/image1.png"`) { + t.Error("image relationship target missing") + } + if !strings.Contains(string(parts["[Content_Types].xml"]), `Extension="png" ContentType="image/png"`) { + t.Error("PNG content type missing") + } +} + +func TestDOCXExportRendersMermaidAsImage(t *testing.T) { + if findBrowser() == "" { + t.Skip("no Chromium-based browser on $PATH") + } + if findMermaidJS() == nil { + t.Skip("mermaid.min.js not found") + } + + var buf bytes.Buffer + err := (&DOCXSharer{}).Export(context.Background(), &buf, ExportRequest{ + Pages: []Page{{ + Path: "architecture/index", + Title: "Architecture", + Body: "```mermaid\ngraph LR\n A[Web UI] --> B[Go server]\n```\n", + }}, + }) + if err != nil { + t.Fatalf("Export: %v", err) + } + + parts := readDOCXParts(t, buf.Bytes()) + if _, ok := parts["word/media/image1.png"]; !ok { + t.Fatal("rendered Mermaid image part missing") + } + doc := string(parts["word/document.xml"]) + if !strings.Contains(doc, ``) { + t.Error("Mermaid drawing missing from document") + } + if strings.Contains(doc, "graph LR") { + t.Error("Mermaid source rendered as code instead of an image") + } +} + +func TestDOCXExportImageFallbackWhenAssetsDisabled(t *testing.T) { + assetPath := "guides/start.assets/diagram.png" + var buf bytes.Buffer + err := (&DOCXSharer{}).Export(context.Background(), &buf, ExportRequest{ + Config: ShareConfig{Settings: map[string]any{"include_assets": false}}, + Pages: []Page{{ + Path: "guides/start", + Title: "Guide", + Body: "![Architecture](" + assetPath + ")\n", + ImageRefs: []string{assetPath}, + }}, + }) + if err != nil { + t.Fatalf("Export: %v", err) + } + + parts := readDOCXParts(t, buf.Bytes()) + if !strings.Contains(string(parts["word/document.xml"]), "[Image: Architecture]") { + t.Error("image fallback text missing") + } + for name := range parts { + if strings.HasPrefix(name, "word/media/") { + t.Errorf("unexpected media part %s", name) + } + } +} + +func readDOCXParts(t *testing.T, data []byte) map[string][]byte { + t.Helper() + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("zip.NewReader: %v", err) + } + parts := make(map[string][]byte, len(reader.File)) + for _, file := range reader.File { + rc, err := file.Open() + if err != nil { + t.Fatalf("open %s: %v", file.Name, err) + } + content, err := io.ReadAll(rc) + closeErr := rc.Close() + if err != nil { + t.Fatalf("read %s: %v", file.Name, err) + } + if closeErr != nil { + t.Fatalf("close %s: %v", file.Name, closeErr) + } + parts[file.Name] = content + } + return parts +} diff --git a/internal/share/mermaid.go b/internal/share/mermaid.go new file mode 100644 index 0000000..eb83dfd --- /dev/null +++ b/internal/share/mermaid.go @@ -0,0 +1,77 @@ +package share + +import ( + "context" + "fmt" + "net" + "net/http" + "time" + + "github.com/chromedp/chromedp" +) + +// renderMermaidPNG renders a Mermaid definition to PNG using the same local +// Mermaid bundle and Chromium discovery used by PDF export. +func renderMermaidPNG(ctx context.Context, definition string) ([]byte, error) { + browserPath := findBrowser() + if browserPath == "" { + return nil, fmt.Errorf("no Chromium-based browser found") + } + mermaidJS := findMermaidJS() + if mermaidJS == nil { + return nil, fmt.Errorf("mermaid.min.js not found") + } + + htmlContent := renderHTMLDocument([]Page{{ + Path: "diagram", + Body: "```mermaid\n" + definition + "\n```\n", + }}, nil, ctx, false, false, true) + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(htmlContent)) + }) + mux.HandleFunc("/mermaid.min.js", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/javascript") + _, _ = w.Write(mermaidJS) + }) + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("start Mermaid render server: %w", err) + } + server := &http.Server{Handler: mux} + go func() { + _ = server.Serve(listener) + }() + defer server.Close() + defer listener.Close() + + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.ExecPath(browserPath), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + ) + allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...) + defer allocCancel() + taskCtx, taskCancel := chromedp.NewContext(allocCtx) + defer taskCancel() + taskCtx, timeoutCancel := context.WithTimeout(taskCtx, 30*time.Second) + defer timeoutCancel() + + pageURL := fmt.Sprintf("http://127.0.0.1:%d/", listener.Addr().(*net.TCPAddr).Port) + var png []byte + if err := chromedp.Run(taskCtx, + chromedp.Navigate(pageURL), + chromedp.ActionFunc(func(ctx context.Context) error { + return chromedp.Poll(`document.body && document.body.getAttribute('data-mermaid-done') === 'true'`, nil, chromedp.WithPollingInterval(100*time.Millisecond)).Do(ctx) + }), + chromedp.Screenshot(".mermaid", &png, chromedp.ByQuery), + ); err != nil { + return nil, fmt.Errorf("render Mermaid diagram: %w", err) + } + if len(png) == 0 { + return nil, fmt.Errorf("render Mermaid diagram: empty screenshot") + } + return png, nil +} diff --git a/internal/wiki/export.go b/internal/wiki/export.go index fb2bb88..9a6bff0 100644 --- a/internal/wiki/export.go +++ b/internal/wiki/export.go @@ -41,6 +41,7 @@ func (w *Wiki) ExportPages(ctx context.Context, startPage string, depth int) ([] // BFS traversal of the wikilink graph visited := map[string]bool{startPage: true} + orderedPaths := []string{startPage} queue := []string{startPage} currentDepth := 0 @@ -62,6 +63,7 @@ func (w *Wiki) ExportPages(ctx context.Context, startPage string, depth int) ([] for _, target := range links { if !visited[target] { visited[target] = true + orderedPaths = append(orderedPaths, target) nextLevel = append(nextLevel, target) } } @@ -70,9 +72,10 @@ func (w *Wiki) ExportPages(ctx context.Context, startPage string, depth int) ([] currentDepth++ } - // Fetch full content for all visited pages + // Fetch full content in BFS discovery order. The selected page is always + // first, followed by linked pages in the order they appear in source. var pages []ExportPage - for path := range visited { + for _, path := range orderedPaths { if err := ctx.Err(); err != nil { return nil, err } @@ -122,4 +125,3 @@ func (w *Wiki) exportOnePage(ctx context.Context, pagePath string) (*ExportPage, return &p, nil } - diff --git a/internal/wiki/export_test.go b/internal/wiki/export_test.go index e5fe8b3..3274d3c 100644 --- a/internal/wiki/export_test.go +++ b/internal/wiki/export_test.go @@ -2,7 +2,6 @@ package wiki import ( "context" - "sort" "testing" ) @@ -38,8 +37,7 @@ func TestExportPages_OneHop(t *testing.T) { } names := pageNames(pages) - sort.Strings(names) - expected := []string{"index", "people/alice", "projects/mind-map"} + expected := []string{"index", "projects/mind-map", "people/alice"} if len(names) != len(expected) { t.Fatalf("expected %v, got %v", expected, names) } diff --git a/internal/wiki/pages.go b/internal/wiki/pages.go index 6622b9e..750be66 100644 --- a/internal/wiki/pages.go +++ b/internal/wiki/pages.go @@ -582,7 +582,7 @@ func (w *Wiki) releaseLock(pagePath string) { // --- internal helpers --- func (w *Wiki) getLinks(ctx context.Context, pagePath string) ([]string, error) { - rows, err := w.db.QueryContext(ctx, "SELECT target FROM links WHERE source = ? AND kind = 'link'", pagePath) + rows, err := w.db.QueryContext(ctx, "SELECT target FROM links WHERE source = ? AND kind = 'link' ORDER BY rowid", pagePath) if err != nil { return nil, err }