all repos — searchix @ 813e5a26b629d4c27b6ce0a8de2e3d04308ef535

Search engine for NixOS, nix-darwin, home-manager and NUR users

feat: parse {man} in markdown documentation

Alan Pearce
commit

813e5a26b629d4c27b6ce0a8de2e3d04308ef535

parent

a4c441fe30ae008460149a06a5937ca37b4c2d72

A internal/nixdocs/manpage/example/main.go
@@ -0,0 +1,45 @@
+package main + +import ( + "bytes" + "fmt" + "os" + + "github.com/yuin/goldmark" + "go.alanpearce.eu/searchix/internal/nixdocs/manpage" +) + +func main() { + // Create a new Goldmark instance with our manpage extension + md := goldmark.New( + goldmark.WithExtensions( + manpage.New(), + ), + ) + + // Some example Markdown content with manpage references + src := []byte(` +# Man Page References Example + +Basic command documentation can be found in {manpage}` + "`bash(1)`" + ` and {manpage}` + "`zsh(1)`" + `. + +System configuration is documented in {manpage}` + "`nix.conf(5)`" + `. + +Common system administration commands: +- {manpage}` + "`systemctl(1)`" + ` for managing services +- {manpage}` + "`journalctl(1)`" + ` for viewing logs +- {manpage}` + "`mount(8)`" + ` for filesystem operations + +See {manpage}` + "`man(1)`" + ` for information about the manual pages system itself. +`) + + // Convert the markdown to HTML + var buf bytes.Buffer + if err := md.Convert(src, &buf); err != nil { + fmt.Println("Conversion error:", err) + os.Exit(1) + } + + // Print the HTML output + fmt.Println(buf.String()) +}
A internal/nixdocs/manpage/manpage.go
@@ -0,0 +1,189 @@
+//nolint:wrapcheck +package manpage + +import ( + "bytes" + "fmt" + "regexp" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// Node represents a manpage reference node in markdown AST. +type Node struct { + ast.BaseInline + ManPage []byte +} + +// Inline implements ast.Inline interface. +func (n *Node) Inline() bool { + return true +} + +// Kind implements ast.Node.Kind interface. +func (n *Node) Kind() ast.NodeKind { + return KindManPage +} + +// Dump implements ast.Node.Dump interface. +func (n *Node) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "ManPage": string(n.ManPage), + }, nil) +} + +// KindManPage is a NodeKind for manpage nodes. +var KindManPage = ast.NewNodeKind("ManPage") + +// manPagePattern matches a manpage reference with section number in parentheses +// e.g., "nix.conf(5)", "git(1)", etc. +var manPagePattern = regexp.MustCompile(`^([^()]+)\(([1-9])\)$`) + +// Parser is a Goldmark inline parser for parsing manpage nodes. +// +// Manpage references have the format {manpage}`nix.conf(5)` which will be rendered as +// a link to the manpage with the appropriate section. +type Parser struct{} + +var _ parser.InlineParser = (*Parser)(nil) + +// Trigger reports characters that trigger this parser. +func (*Parser) Trigger() []byte { + return []byte{'{'} +} + +// Parse parses a manpage node. +func (p *Parser) Parse(_ ast.Node, block text.Reader, _ parser.Context) ast.Node { + line, segment := block.PeekLine() + + // Check if we have enough characters for {manpage}` + if len(line) < 11 || line[0] != '{' { + return nil + } + + // Check for {manpage}` prefix + if !bytes.HasPrefix(line, []byte("{manpage}`")) { + return nil + } + + // Skip the {manpage}` prefix + line = line[10:] + start := segment.Start + 10 + + // Find the closing backtick + endPos := bytes.IndexByte(line, '`') + if endPos < 0 { + return nil + } + + // Extract the manpage reference + manPageRef := line[:endPos] + if len(manPageRef) == 0 { + return nil + } + + // Create the node + n := &Node{ + ManPage: manPageRef, + } + + // Set the text segment to include the entire {manpage}`ref` text + textSegment := text.NewSegment(segment.Start, start+endPos+1) + n.AppendChild(n, ast.NewTextSegment(textSegment)) + + // Advance the reader past this node + block.Advance(10 + endPos + 1) // {manpage}` + ref + ` + + return n +} + +// HTMLRenderer is a renderer for manpage nodes. +type HTMLRenderer struct { + html.Config +} + +// NewHTMLRenderer returns a new HTMLRenderer. +func NewHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { + r := &HTMLRenderer{ + Config: html.NewConfig(), + } + for _, opt := range opts { + opt.SetHTMLOption(&r.Config) + } + + return r +} + +// RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. +func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(KindManPage, r.renderManPage) +} + +func (r *HTMLRenderer) renderManPage( + w util.BufWriter, + source []byte, //nolint:revive + node ast.Node, + entering bool, +) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + n := node.(*Node) + manPageRef := string(n.ManPage) + + // Parse the manpage reference to extract name and section + matches := manPagePattern.FindStringSubmatch(manPageRef) + if len(matches) != 3 { + // If it doesn't match the pattern, just render it as plain text + if _, err := w.WriteString(manPageRef); err != nil { + return ast.WalkStop, err + } + + return ast.WalkSkipChildren, nil + } + + name := matches[1] + section := matches[2] + + // Generate the HTML link to the manpage + href := fmt.Sprintf("/man/%s/%s", section, name) + link := fmt.Sprintf(`<a class="manpage" href="%s">%s</a>`, href, manPageRef) + + if _, err := w.WriteString(link); err != nil { + return ast.WalkStop, err + } + + return ast.WalkSkipChildren, nil +} + +// Extension is a goldmark extension for manpage references. +type Extension struct{} + +// Extend implements goldmark.Extender.Extend. +func (e *Extension) Extend(m goldmark.Markdown) { + // Register the parser + m.Parser().AddOptions( + parser.WithInlineParsers( + util.Prioritized(new(Parser), 100), + ), + ) + + // Register the renderer + m.Renderer().AddOptions( + renderer.WithNodeRenderers( + util.Prioritized(NewHTMLRenderer(), 500), + ), + ) +} + +// New returns a new manpage extension. +func New() goldmark.Extender { + return &Extension{} +}
A internal/nixdocs/manpage/manpage_test.go
@@ -0,0 +1,88 @@
+package manpage + +import ( + "bytes" + "testing" + + "github.com/yuin/goldmark" +) + +func TestManPageExtension(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + New(), + ), + ) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "basic manpage reference", + input: "{manpage}`nix.conf(5)`", + expected: "<p><a class=\"manpage\" href=\"/man/5/nix.conf\">nix.conf(5)</a></p>\n", + }, + { + name: "manpage reference in sentence", + input: "See {manpage}`git(1)` for more information.", + expected: "<p>See <a class=\"manpage\" href=\"/man/1/git\">git(1)</a> for more information.</p>\n", + }, + { + name: "multiple manpage references", + input: "Both {manpage}`ssh(1)` and {manpage}`sshd(8)` have configuration options.", + expected: "<p>Both <a class=\"manpage\" href=\"/man/1/ssh\">ssh(1)</a> and " + + "<a class=\"manpage\" href=\"/man/8/sshd\">sshd(8)</a> have configuration options.</p>\n", + }, + { + name: "incomplete manpage reference - no closing backtick", + input: "{manpage}`missing.backtick", + expected: "<p>{manpage}`missing.backtick</p>\n", + }, + { + name: "incomplete manpage reference - empty reference", + input: "{manpage}``", + expected: "<p>{manpage}``</p>\n", + }, + { + name: "invalid manpage reference - no section", + input: "{manpage}`nixos`", + expected: "<p>nixos</p>\n", + }, + { + name: "invalid manpage reference - invalid section format", + input: "{manpage}`bash(x)`", + expected: "<p>bash(x)</p>\n", + }, + { + name: "manpage reference with code block", + input: "Configure {manpage}`httpd.conf(5)` in your `apache` directory.", + expected: "<p>Configure <a class=\"manpage\" href=\"/man/5/httpd.conf\">httpd.conf(5)</a> " + + "in your <code>apache</code> directory.</p>\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var buf bytes.Buffer + if err := markdown.Convert([]byte(test.input), &buf); err != nil { + t.Fatalf("Failed to convert markdown: %v", err) + } + + if got := buf.String(); got != test.expected { + t.Errorf("Expected:\n%q\nGot:\n%q", test.expected, got) + } + }) + } +} + +func TestManPageNodeKind(t *testing.T) { + n := &Node{} + if kind := n.Kind(); kind != KindManPage { + t.Errorf("Expected node kind %v, got %v", KindManPage, kind) + } + if !n.Inline() { + t.Error("Expected node to be inline") + } +}
M internal/nixdocs/nixdocs.gointernal/nixdocs/nixdocs.go
@@ -8,6 +8,7 @@
"go.alanpearce.eu/searchix/internal/nixdocs/command" "go.alanpearce.eu/searchix/internal/nixdocs/envvar" "go.alanpearce.eu/searchix/internal/nixdocs/filepath" + "go.alanpearce.eu/searchix/internal/nixdocs/manpage" "go.alanpearce.eu/searchix/internal/nixdocs/option" "go.alanpearce.eu/searchix/internal/nixdocs/optlink" "go.alanpearce.eu/searchix/internal/nixdocs/variable"
@@ -23,5 +24,6 @@ command.New(),
envvar.New(), variable.New(), filepath.New(), + manpage.New(), ) }