all repos — searchix @ 0ba69e50d2d1095db9b42c548c0226739d137a62

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

feat: parse {option} in markdown documentation

Alan Pearce
commit

0ba69e50d2d1095db9b42c548c0226739d137a62

parent

2d958a53b942e5c47ce58dd14c15a2c9474ca661

M internal/nixdocs/nixdocs.gointernal/nixdocs/nixdocs.go
@@ -5,6 +5,7 @@ fences "github.com/stefanfritsch/goldmark-fences"
"github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" + "go.alanpearce.eu/searchix/internal/nixdocs/option" "go.alanpearce.eu/searchix/internal/nixdocs/optlink" )
@@ -13,5 +14,6 @@ return goldmark.WithExtensions(
extension.NewLinkify(), &fences.Extender{}, optlink.New(), + option.New(), ) }
A internal/nixdocs/option/example/main.go
@@ -0,0 +1,35 @@
+package main + +import ( + "bytes" + "fmt" + "os" + + "go.alanpearce.eu/searchix/internal/nixdocs/option" + + "github.com/yuin/goldmark" +) + +func main() { + // Create a new markdown parser with the option extension + md := goldmark.New( + goldmark.WithExtensions( + option.New(), + ), + ) + + // Example markdown text with option references + markdown := "Use {option}`initialHashedPassword` to set the initial hashed password for a user." + + // Convert markdown to HTML + var buf bytes.Buffer + if err := md.Convert([]byte(markdown), &buf); err != nil { + fmt.Fprintf(os.Stderr, "Error converting markdown: %v\n", err) + os.Exit(1) + } + + // Print the resulting HTML + fmt.Println("HTML Output:") + fmt.Println("===========") + fmt.Println(buf.String()) +}
A internal/nixdocs/option/option.go
@@ -0,0 +1,178 @@
+//nolint:wrapcheck +package option + +import ( + "bytes" + "html" + "net/url" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + ghtml "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// Node represents an option reference node in markdown AST. +type Node struct { + ast.BaseInline + Option []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 KindOption +} + +// Dump implements ast.Node.Dump interface. +func (n *Node) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "Option": string(n.Option), + }, nil) +} + +// KindOption is a NodeKind for option nodes. +var KindOption = ast.NewNodeKind("Option") + +// Parser is a Goldmark inline parser for parsing option nodes. +// +// Option references have the format {option}`name` which will be rendered as +// a link to the option with the name in code tags. +type Parser struct{} + +var _ parser.InlineParser = (*Parser)(nil) + +var openBrace = byte('{') + +// Trigger reports characters that trigger this parser. +func (*Parser) Trigger() []byte { + return []byte{openBrace} +} + +// Parse parses an option 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 {option}` + if len(line) < 9 || line[0] != openBrace { + return nil + } + + // Check for {option}` prefix + if !bytes.HasPrefix(line, []byte("{option}`")) { + return nil + } + + // Skip the {option}` prefix + line = line[9:] + start := segment.Start + 9 + + // Find the closing backtick + endPos := bytes.IndexByte(line, '`') + if endPos < 0 { + return nil + } + + // Extract the option name + optionName := line[:endPos] + if len(optionName) == 0 { + return nil + } + + // Create the node + n := &Node{ + Option: optionName, + } + + // Set the text segment to include the entire {option}`name` text + textSegment := text.NewSegment(segment.Start, start+endPos+1) + n.AppendChild(n, ast.NewTextSegment(textSegment)) + + // Advance the reader past this node + block.Advance(9 + endPos + 1) // {option}` + name + ` + + return n +} + +// HTMLRenderer is a renderer for option nodes. +type HTMLRenderer struct { + ghtml.Config +} + +// NewHTMLRenderer returns a new HTMLRenderer. +func NewHTMLRenderer(opts ...ghtml.Option) renderer.NodeRenderer { + r := &HTMLRenderer{ + Config: ghtml.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(KindOption, r.renderOption) +} + +func (r *HTMLRenderer) renderOption( + w util.BufWriter, + source []byte, //nolint:revive + node ast.Node, + entering bool, +) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + n := node.(*Node) + + if _, err := w.WriteString( + `<a class="option" href="/?query=` + url.QueryEscape(string(n.Option)) + `"><code>`, + ); err != nil { + return ast.WalkStop, err + } + + if _, err := w.WriteString(html.EscapeString(string(n.Option))); err != nil { + return ast.WalkStop, err + } + + if _, err := w.WriteString("</code></a>"); err != nil { + return ast.WalkStop, err + } + + return ast.WalkSkipChildren, nil +} + +// Extension is a goldmark extension for option 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 option extension. +func New() goldmark.Extender { + return &Extension{} +}
A internal/nixdocs/option/option_test.go
@@ -0,0 +1,87 @@
+package option + +import ( + "bytes" + "testing" + + "github.com/yuin/goldmark" +) + +func TestOptionExtension(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + New(), + ), + ) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "basic option reference", + input: "{option}`initialHashedPassword`", + expected: "<p><a class=\"option\" href=\"/?query=initialHashedPassword\">" + + "<code>initialHashedPassword</code></a></p>\n", + }, + { + name: "option reference in sentence", + input: "You can set {option}`initialHashedPassword` to configure the initial password.", + expected: "<p>You can set <a class=\"option\" href=\"/?query=initialHashedPassword\">" + + "<code>initialHashedPassword</code></a> to configure the initial password.</p>\n", + }, + { + name: "multiple option references", + input: "Both {option}`initialHashedPassword` and {option}`passwordFile` can be used.", + expected: "<p>Both <a class=\"option\" href=\"/?query=initialHashedPassword\">" + + "<code>initialHashedPassword</code></a> and <a class=\"option\" href=\"/?query=passwordFile\">" + + "<code>passwordFile</code></a> can be used.</p>\n", + }, + { + name: "incomplete option reference - no closing backtick", + input: "{option}`missingBacktick", + expected: "<p>{option}`missingBacktick</p>\n", + }, + { + name: "incomplete option reference - empty option name", + input: "{option}``", + expected: "<p>{option}``</p>\n", + }, + { + name: "option reference with code block", + input: "{option}`config` is set in `config.nix`", + expected: "<p><a class=\"option\" href=\"/?query=config\"><code>config</code></a> " + + "is set in <code>config.nix</code></p>\n", + }, + { + name: "option reference with HTML-like content", + input: "{option}`users.users.<name>.hashedPassword`", + expected: "<p><a class=\"option\" href=\"/?query=users.users.%3Cname%3E.hashedPassword\">" + + "<code>users.users.&lt;name&gt;.hashedPassword</code></a></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 TestOptionNodeKind(t *testing.T) { + n := &Node{} + if kind := n.Kind(); kind != KindOption { + t.Errorf("Expected node kind %v, got %v", KindOption, kind) + } + if !n.Inline() { + t.Error("Expected node to be inline") + } +}