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 }