Skip to content

Commit 9c284dd

Browse files
committed
docs: embed documentation into web UI
1 parent 460d386 commit 9c284dd

11 files changed

Lines changed: 356 additions & 5 deletions

File tree

.github/workflows/release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ jobs:
7777
with:
7878
go-version: ${{ env.GO_VERSION }}
7979

80+
- name: Build docs
81+
if: steps.check-release.outputs.skip_release != 'true'
82+
run: make gen-docs
83+
8084
- name: Run goreleaser
8185
if: steps.check-release.outputs.skip_release != 'true'
8286
uses: goreleaser/goreleaser-action@v7

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ dist/
33
/tmp
44
coverage.*
55
.DS_Store
6+
7+
# Generated documentation
8+
service/docs/

Makefile

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,24 @@ help: ## print a short help message
2727

2828
## building
2929

30-
build: $(TARGET) ## build a development binary
30+
build: gen-docs $(TARGET) ## build a development binary
31+
32+
gen-docs: ## generate HTML documentation from Markdown
33+
go run ./cmd/build gendocs
34+
@mkdir -p service/docs
35+
@mv docs/*.html service/docs/
3136

3237
.PHONY: $(TARGET)
3338
$(TARGET):
3439
go build -o $@ $(GOFLAGS) ./cmd/texd
3540

3641
.PHONY: clean
37-
clean: ## cleanup build fragments
42+
clean: clean-docs ## cleanup build fragments
3843
rm -rf tmp/ dist/ texd coverage.*
3944

45+
.PHONY: clean-docs
46+
clean-docs: ## remove generated HTML documentation
47+
rm -rf docs/*.html service/docs
4048

4149
## development
4250

@@ -77,7 +85,7 @@ run-dind-volume: tmp docker-latest ## build and runs texd in container mode, usi
7785
## testing
7886

7987
.PHONY: coverage.out
80-
coverage.out:
88+
coverage.out: gen-docs
8189
go test -race -covermode=atomic -coverprofile=$@ ./...
8290

8391
coverage.html: coverage.out

cmd/build/gendocs.go

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
package main
2+
3+
import (
4+
_ "embed"
5+
"fmt"
6+
"html/template"
7+
"os"
8+
"path/filepath"
9+
"regexp"
10+
"strings"
11+
12+
"github.com/yuin/goldmark"
13+
"github.com/yuin/goldmark/ast"
14+
"github.com/yuin/goldmark/extension"
15+
extAst "github.com/yuin/goldmark/extension/ast"
16+
"github.com/yuin/goldmark/parser"
17+
"github.com/yuin/goldmark/renderer"
18+
"github.com/yuin/goldmark/renderer/html"
19+
"github.com/yuin/goldmark/text"
20+
"github.com/yuin/goldmark/util"
21+
"golang.org/x/text/cases"
22+
"golang.org/x/text/language"
23+
)
24+
25+
//go:embed template.html
26+
var templateHTML string
27+
28+
type pageData struct {
29+
Title string
30+
Content template.HTML
31+
CurrentPage string
32+
}
33+
34+
var linkRewriter = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\.md(#[^)]*)?\)`)
35+
36+
// cssClassTransformer adds Bootstrap CSS classes to specific HTML elements
37+
type cssClassTransformer struct{}
38+
39+
func (t *cssClassTransformer) Transform(node *ast.Document, reader text.Reader, pc parser.Context) {
40+
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
41+
if !entering {
42+
return ast.WalkContinue, nil
43+
}
44+
45+
switch n.Kind() {
46+
case extAst.KindTable:
47+
// Add Bootstrap table classes to tables
48+
n.SetAttributeString("class", []byte("table table-striped border"))
49+
}
50+
51+
return ast.WalkContinue, nil
52+
})
53+
}
54+
55+
// customCodeBlockRenderer renders code blocks with Bootstrap classes
56+
type customCodeBlockRenderer struct{ html.Config }
57+
58+
func (r *customCodeBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
59+
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
60+
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
61+
}
62+
63+
func (r *customCodeBlockRenderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
64+
if entering {
65+
_, _ = w.WriteString(`<pre class="bg-light border rounded p-3 overflow-x-auto mw-100">`)
66+
if n, ok := node.(*ast.FencedCodeBlock); ok {
67+
language := n.Language(source)
68+
if len(language) > 0 {
69+
_, _ = w.WriteString(`<code class="language-`)
70+
_, _ = w.Write(language)
71+
_, _ = w.WriteString(`">`)
72+
} else {
73+
_, _ = w.WriteString("<code>")
74+
}
75+
} else {
76+
_, _ = w.WriteString("<code>")
77+
}
78+
79+
// Write the code content
80+
l := node.Lines().Len()
81+
for i := 0; i < l; i++ {
82+
line := node.Lines().At(i)
83+
_, _ = w.Write(util.EscapeHTML(line.Value(source)))
84+
}
85+
} else {
86+
_, _ = w.WriteString("</code></pre>\n")
87+
}
88+
return ast.WalkContinue, nil
89+
}
90+
91+
type customBlockquoteRenderer struct{ html.Config }
92+
93+
func (r *customBlockquoteRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
94+
reg.Register(ast.KindBlockquote, r.renderBlockquote)
95+
}
96+
97+
func (r *customBlockquoteRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
98+
if entering {
99+
_, _ = w.WriteString(`<blockquote class="border-start border-3 ms-3 ps-3 fst-italic">`)
100+
} else {
101+
_, _ = w.WriteString("</blockquote>")
102+
}
103+
return ast.WalkContinue, nil
104+
}
105+
106+
func generateDocs() error {
107+
// Create goldmark converter
108+
md := goldmark.New(
109+
goldmark.WithExtensions(
110+
extension.GFM,
111+
extension.Table,
112+
extension.Strikethrough,
113+
extension.TaskList,
114+
),
115+
goldmark.WithParserOptions(
116+
parser.WithAutoHeadingID(),
117+
parser.WithASTTransformers(
118+
util.Prioritized(&cssClassTransformer{}, 100),
119+
),
120+
),
121+
goldmark.WithRendererOptions(
122+
renderer.WithNodeRenderers(
123+
util.Prioritized(&customCodeBlockRenderer{}, 100),
124+
util.Prioritized(&customBlockquoteRenderer{}, 100),
125+
),
126+
),
127+
)
128+
129+
// Parse template
130+
tmpl, err := template.New("doc").Parse(templateHTML)
131+
if err != nil {
132+
return fmt.Errorf("failed to parse template: %w", err)
133+
}
134+
135+
// Find all markdown files in docs/
136+
docsDir := "docs"
137+
entries, err := os.ReadDir(docsDir)
138+
if err != nil {
139+
return fmt.Errorf("failed to read docs directory: %w", err)
140+
}
141+
142+
generatedCount := 0
143+
for _, entry := range entries {
144+
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
145+
continue
146+
}
147+
148+
inputPath := filepath.Join(docsDir, entry.Name())
149+
outputPath := filepath.Join(docsDir, strings.TrimSuffix(entry.Name(), ".md")+".html")
150+
151+
if err := convertMarkdownToHTML(md, tmpl, inputPath, outputPath); err != nil {
152+
return fmt.Errorf("failed to convert %s: %w", entry.Name(), err)
153+
}
154+
155+
generatedCount++
156+
}
157+
158+
if generatedCount == 0 {
159+
return fmt.Errorf("no markdown files found in %s", docsDir)
160+
}
161+
162+
return nil
163+
}
164+
165+
func convertMarkdownToHTML(md goldmark.Markdown, tmpl *template.Template, inputPath, outputPath string) error {
166+
// Read markdown file
167+
content, err := os.ReadFile(inputPath)
168+
if err != nil {
169+
return fmt.Errorf("failed to read file: %w", err)
170+
}
171+
172+
// Rewrite .md links to .html
173+
contentStr := string(content)
174+
contentStr = linkRewriter.ReplaceAllString(contentStr, "[$1]($2.html$3)")
175+
176+
// Convert markdown to HTML
177+
var buf strings.Builder
178+
if err := md.Convert([]byte(contentStr), &buf); err != nil {
179+
return fmt.Errorf("failed to convert markdown: %w", err)
180+
}
181+
182+
// Extract title from first H1 or use filename
183+
title := extractTitle(contentStr)
184+
if title == "" {
185+
title = strings.TrimSuffix(filepath.Base(inputPath), ".md")
186+
title = strings.ReplaceAll(title, "-", " ")
187+
title = cases.Title(language.English).String(title)
188+
}
189+
190+
// Determine current page name (for sidebar highlighting)
191+
currentPage := strings.TrimSuffix(filepath.Base(inputPath), ".md")
192+
193+
// Prepare template data
194+
data := pageData{
195+
Title: title,
196+
Content: template.HTML(buf.String()),
197+
CurrentPage: currentPage,
198+
}
199+
200+
// Create output file
201+
outFile, err := os.Create(outputPath)
202+
if err != nil {
203+
return fmt.Errorf("failed to create output file: %w", err)
204+
}
205+
defer func() { _ = outFile.Close() }()
206+
207+
// Execute template
208+
if err := tmpl.Execute(outFile, data); err != nil {
209+
return fmt.Errorf("failed to execute template: %w", err)
210+
}
211+
212+
return nil
213+
}
214+
215+
func extractTitle(content string) string {
216+
// Look for first H1 heading (# Title)
217+
lines := strings.Split(content, "\n")
218+
for _, line := range lines {
219+
line = strings.TrimSpace(line)
220+
if strings.HasPrefix(line, "# ") {
221+
return strings.TrimSpace(strings.TrimPrefix(line, "# "))
222+
}
223+
}
224+
return ""
225+
}

cmd/build/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ var commands = []command{{
8888
name: "bump",
8989
help: "update Git tag",
9090
run: cmdBump,
91+
}, {
92+
name: "gendocs",
93+
help: "generate HTML documentation from Markdown files",
94+
run: cmdGenDocs,
9195
}}
9296

9397
func cmdBump(args []string) {
@@ -154,3 +158,12 @@ func exec(err, out io.Writer, name string, args ...string) error {
154158
}
155159
return cmd.Run()
156160
}
161+
162+
func cmdGenDocs(args []string) {
163+
fs := pflag.NewFlagSet("gendocs", pflag.ExitOnError)
164+
_ = fs.Parse(args)
165+
166+
if err := generateDocs(); err != nil {
167+
fatalf("gendocs: %v", err)
168+
}
169+
}

cmd/build/template.html

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<title>{{.Title}} - texd Documentation</title>
8+
<link rel="stylesheet" href="/assets/bootstrap-5.3.8.min.css">
9+
<style>
10+
body {
11+
height: 100vh;
12+
}
13+
</style>
14+
</head>
15+
16+
<body>
17+
<div class="d-flex flex-nowrap h-100">
18+
<nav class="d-flex flex-column flex-shrink-0 p-3 bg-light border-end" style="width: 280px">
19+
<p class="lead">texd Documentation</p>
20+
<a href="/" class="btn btn-sm btn-outline-primary mb-3 w-100">← Back to UI</a>
21+
22+
<ul class="nav nav-pills flex-column mb-auto">
23+
<li class="nav-item">
24+
<a class="nav-link{{if eq .CurrentPage "getting-started"}} active{{end}}" href="/docs/getting-started.html">Getting Started</a>
25+
</li>
26+
27+
<li class="nav-item mt-3 mb-1 px-3 border-bottom fw-light text-uppercase">
28+
Configuration
29+
</li>
30+
<li class="nav-item">
31+
<a class="nav-link{{if eq .CurrentPage "cli-options"}} active{{end}}" href="/docs/cli-options.html">CLI Options</a>
32+
</li>
33+
34+
<li class="nav-item mt-3 mb-1 px-3 border-bottom fw-light text-uppercase">
35+
API Reference
36+
</li>
37+
<li class="nav-item">
38+
<a class="nav-link{{if eq .CurrentPage "api-render"}} active{{end}}" href="/docs/api-render.html">Render Endpoint</a>
39+
<a class="nav-link{{if eq .CurrentPage "api-status"}} active{{end}}" href="/docs/api-status.html">Status Endpoint</a>
40+
<a class="nav-link{{if eq .CurrentPage "api-metrics"}} active{{end}}" href="/docs/api-metrics.html">Metrics</a>
41+
</li>
42+
43+
<li class="nav-item mt-3 mb-1 px-3 border-bottom fw-light text-uppercase">
44+
Features
45+
</li>
46+
<li class="nav-item">
47+
<a class="nav-link{{if eq .CurrentPage "reference-store"}} active{{end}}" href="/docs/reference-store.html">Reference Store</a>
48+
<a class="nav-link{{if eq .CurrentPage "web-ui"}} active{{end}}" href="/docs/web-ui.html">Web UI</a>
49+
</li>
50+
51+
<li class="nav-item mt-3 mb-1 px-3 border-bottom fw-light text-uppercase">
52+
More
53+
</li>
54+
<li class="nav-item">
55+
<a class="nav-link{{if eq .CurrentPage "history"}} active{{end}}" href="/docs/history.html">History &amp; Future</a>
56+
<a class="nav-link{{if eq .CurrentPage "contributing"}} active{{end}}" href="/docs/contributing.html">Contributing</a>
57+
</li>
58+
</ul>
59+
</nav>
60+
61+
<main class="p-3">
62+
{{.Content}}
63+
</main>
64+
</div>
65+
</body>
66+
67+
</html>

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@ require (
1818
github.com/spf13/pflag v1.0.10
1919
github.com/stretchr/testify v1.11.1
2020
github.com/urfave/cli/v3 v3.8.0
21+
github.com/yuin/goldmark v1.8.2
2122
gitlab.com/greyxor/slogor v1.6.9
23+
golang.org/x/text v0.36.0
2224
)
2325

2426
require (
@@ -54,7 +56,6 @@ require (
5456
go.opentelemetry.io/otel/trace v1.43.0 // indirect
5557
go.yaml.in/yaml/v2 v2.4.4 // indirect
5658
golang.org/x/sys v0.43.0 // indirect
57-
golang.org/x/text v0.36.0 // indirect
5859
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 // indirect
5960
google.golang.org/protobuf v1.36.11 // indirect
6061
gopkg.in/yaml.v3 v3.0.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
102102
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
103103
github.com/urfave/cli/v3 v3.8.0 h1:XqKPrm0q4P0q5JpoclYoCAv0/MIvH/jZ2umzuf8pNTI=
104104
github.com/urfave/cli/v3 v3.8.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
105+
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
106+
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
105107
gitlab.com/greyxor/slogor v1.6.9 h1:FtoWpf9pZhdEkdb5/6y04+QVLZt4WFoTDf3mPxaJLwc=
106108
gitlab.com/greyxor/slogor v1.6.9/go.mod h1:cTQmsyF7KZCylunZpBr49rae/cKbus5Sw0rBF/zFWJo=
107109
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=

0 commit comments

Comments
 (0)