Skip to content

Commit 99a087a

Browse files
committed
build: Add md build tool
Add md build tool to generate static html files from markdown files. We are using rsc.io/markdown as a library for md parsing, currently modifying links so that their relative URLs point to .html rather than .md files. we also rewrite abosulte URLs to `https://XXX.evy.dev/YYY` to `/XXX/YYY. We've added an template in build-tools/md/tmpl/docs.html.tmpl that we'll iterate and improve on. We've also added some initial styles for the docs page. Docs can be generated into the preview directory with: go run ./build-tools/md docs frontend/preview In a follow-up commit we will add Maketargets and the generated HTML files.
1 parent 657571e commit 99a087a

23 files changed

+369
-1
lines changed

build-tools/md/main.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Command md is a markdown processing tool
2+
//
3+
// md generates evy frontend code
4+
package main
5+
6+
import (
7+
"bytes"
8+
"embed"
9+
"errors"
10+
"fmt"
11+
"io"
12+
"io/fs"
13+
"net/url"
14+
"os"
15+
"path/filepath"
16+
"strings"
17+
"text/template"
18+
19+
"github.com/alecthomas/kong"
20+
"rsc.io/markdown"
21+
)
22+
23+
//go:embed tmpl/*
24+
var tmplFS embed.FS
25+
26+
type app struct {
27+
SrcDir string `arg:"" type:"existingdir" help:"source directory" placeholder:"SRCDIR"`
28+
DestDir string `arg:"" help:"target directory" placeholder:"DESTDIR"`
29+
}
30+
31+
func main() {
32+
kctx := kong.Parse(&app{})
33+
kctx.FatalIfErrorf(kctx.Run())
34+
}
35+
36+
func (a *app) Run() error {
37+
mdFiles, err := a.copy()
38+
if err != nil {
39+
return err
40+
}
41+
return a.genHTMLFiles(mdFiles)
42+
}
43+
44+
// Copy the contents of the `src` directory to the `dest` directory.
45+
// Skip over *.md files as we will generate *.html files from them.
46+
func (a *app) copy() ([]string, error) {
47+
mdFiles := []string{}
48+
srcFS := os.DirFS(a.SrcDir)
49+
err := fs.WalkDir(srcFS, ".", func(filename string, d fs.DirEntry, err error) error {
50+
if err != nil {
51+
// Errors from WalkDir do not include `src` in the path making
52+
// the error messages not useful. Add src back in.
53+
var pe *fs.PathError
54+
if errors.As(err, &pe) {
55+
pe.Path = filepath.Join(a.SrcDir, pe.Path)
56+
return pe
57+
}
58+
return err
59+
}
60+
srcfile := filepath.Join(a.SrcDir, filename)
61+
destfile := filepath.Join(a.DestDir, filename)
62+
63+
if d.IsDir() {
64+
// use MkdirAll in case the directory already exists
65+
return os.MkdirAll(destfile, 0o777) //nolint:gosec
66+
}
67+
68+
if filepath.Ext(filename) == ".md" {
69+
mdFiles = append(mdFiles, filename)
70+
return nil
71+
}
72+
sf, err := os.Open(srcfile)
73+
if err != nil {
74+
return err
75+
}
76+
defer sf.Close() //nolint:errcheck // don't care about close failing on read-only files
77+
df, err := os.Create(destfile)
78+
if err != nil {
79+
df.Close() //nolint:errcheck,gosec // we're returning the more important error
80+
return err
81+
}
82+
if _, err := io.Copy(df, sf); err != nil {
83+
df.Close() //nolint:errcheck,gosec // we're returning the more important error
84+
return err
85+
}
86+
return df.Close()
87+
})
88+
if err != nil {
89+
return nil, err
90+
}
91+
return mdFiles, nil
92+
}
93+
94+
func (a *app) genHTMLFiles(mdFiles []string) error {
95+
for _, mdf := range mdFiles {
96+
mdFile := filepath.Join(a.SrcDir, mdf)
97+
htmlFile := filepath.Join(a.DestDir, htmlFilename(mdf))
98+
root := toRoot(mdf)
99+
err := genHTMLFile(mdFile, htmlFile, root)
100+
if err != nil {
101+
return err
102+
}
103+
}
104+
return nil
105+
}
106+
107+
var tmpl = template.Must(template.ParseFS(tmplFS, "tmpl/docs.html.tmpl"))
108+
109+
type tmplData struct {
110+
Root string
111+
Title string
112+
Content string
113+
}
114+
115+
func genHTMLFile(mdFile, htmlFile, root string) error {
116+
mdBytes, err := os.ReadFile(mdFile)
117+
if err != nil {
118+
return err
119+
}
120+
title, htmlContent := md2html(mdBytes)
121+
out, err := os.Create(htmlFile)
122+
if err != nil {
123+
return err
124+
}
125+
data := tmplData{
126+
Root: root,
127+
Title: title,
128+
Content: htmlContent,
129+
}
130+
if err := tmpl.Execute(out, data); err != nil {
131+
out.Close() //nolint:errcheck,gosec // we're returning the more important error
132+
return err
133+
}
134+
return out.Close()
135+
}
136+
137+
// md2html converts markdown to HTML and returns the title and HTML.
138+
func md2html(mdBytes []byte) (string, string) {
139+
p := markdown.Parser{
140+
AutoLinkText: true, // turn URLs into links even without []()
141+
}
142+
doc := p.Parse(string(mdBytes))
143+
walk(doc, updateLinks)
144+
title := extractTitle(doc)
145+
return title, markdown.ToHTML(doc)
146+
}
147+
148+
func updateLinks(n node) {
149+
mdl, ok := n.(*markdown.Link)
150+
if !ok {
151+
return
152+
}
153+
u, err := url.Parse(mdl.URL)
154+
if err != nil {
155+
fmt.Fprintf(os.Stderr, "error parsing URL %q: %v\n", mdl.URL, err)
156+
return
157+
}
158+
if u.IsAbs() {
159+
if rootDir, found := strings.CutSuffix(u.Hostname(), ".evy.dev"); found { // subdomain link
160+
mdl.URL, err = url.JoinPath("/", rootDir, u.Path)
161+
if err != nil {
162+
fmt.Fprintf(os.Stderr, "error creating URL %q %q %q: %v\n", "/", rootDir, u.Path, err)
163+
}
164+
}
165+
return
166+
}
167+
// relative path, fix *.md filenames
168+
u.Path = htmlFilename(u.Path)
169+
mdl.URL = u.String()
170+
}
171+
172+
func extractTitle(doc *markdown.Document) string {
173+
level := 100
174+
var titleText *markdown.Text
175+
for _, block := range doc.Blocks {
176+
if h, ok := block.(*markdown.Heading); ok {
177+
if h.Level < level {
178+
level = h.Level
179+
titleText = h.Text
180+
}
181+
}
182+
}
183+
if titleText == nil {
184+
return ""
185+
}
186+
buf := &bytes.Buffer{}
187+
for _, inline := range titleText.Inline {
188+
inline.PrintText(buf)
189+
}
190+
return buf.String()
191+
}
192+
193+
func htmlFilename(mdf string) string {
194+
if filepath.Base(mdf) == "README.md" {
195+
return filepath.Join(filepath.Dir(mdf), "index.html")
196+
}
197+
if filename, found := strings.CutSuffix(mdf, ".md"); found {
198+
return filename + ".html"
199+
}
200+
return mdf
201+
}
202+
203+
func toRoot(p string) string {
204+
if c := strings.Count(p, string(os.PathSeparator)); c > 0 {
205+
return strings.Repeat("/..", c)[1:]
206+
}
207+
return "."
208+
}

build-tools/md/tmpl/docs.html.tmpl

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!doctype html>
2+
<!-- ⚠️ This document is generated. Do not edit it directly. ⚠️ -->
3+
<html lang="en">
4+
<head>
5+
<meta charset="utf-8" />
6+
<title>evy docs · {{.Title}}</title>
7+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
<link rel="icon" href="img/favicon.png" />
9+
<link rel="stylesheet" href="{{.Root}}/css/resets.css" type="text/css" />
10+
<link rel="stylesheet" href="{{.Root}}/css/root.css" type="text/css" />
11+
<link rel="stylesheet" href="{{.Root}}/css/elements.css" type="text/css" />
12+
<link rel="stylesheet" href="{{.Root}}/css/index.css" type="text/css" />
13+
<link rel="stylesheet" href="{{.Root}}/css/fonts.css" type="text/css" />
14+
</head>
15+
<body>
16+
<main class="max-width-wrapper">{{.Content}}</main>
17+
</body>
18+
</html>

build-tools/md/walkmd.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
6+
"rsc.io/markdown"
7+
)
8+
9+
// node is a subset of markdown.Block and markdown.Inline interfaces.
10+
type node interface {
11+
PrintHTML(*bytes.Buffer)
12+
}
13+
14+
func walk(node node, f func(node)) {
15+
f(node)
16+
switch n := node.(type) {
17+
case *markdown.Del:
18+
walkNodes(n.Inner, f)
19+
case *markdown.Document:
20+
walkNodes(n.Blocks, f)
21+
case *markdown.Emph:
22+
walkNodes(n.Inner, f)
23+
case *markdown.Heading:
24+
walk(n.Text, f)
25+
case *markdown.Image:
26+
walkNodes(n.Inner, f)
27+
case *markdown.Item:
28+
walkNodes(n.Blocks, f)
29+
case *markdown.Link:
30+
walkNodes(n.Inner, f)
31+
case *markdown.List:
32+
walkNodes(n.Items, f)
33+
case *markdown.Paragraph:
34+
walk(n.Text, f)
35+
case *markdown.Quote:
36+
walkNodes(n.Blocks, f)
37+
case *markdown.Strong:
38+
walkNodes(n.Inner, f)
39+
case *markdown.Table:
40+
walkNodes(n.Header, f)
41+
for _, row := range n.Rows {
42+
walkNodes(row, f)
43+
}
44+
case *markdown.Text:
45+
walkNodes(n.Inline, f)
46+
}
47+
}
48+
49+
func walkNodes[T node](nodes []T, f func(node)) {
50+
for _, n := range nodes {
51+
walk(n, f)
52+
}
53+
}

frontend/preview/css/elements.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../css/elements.css

frontend/preview/css/fonts.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../css/fonts.css
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../css/fonts/firacode-cyrillic-ext.v22.woff2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../css/fonts/firacode-cyrillic.v22.woff2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../css/fonts/firacode-greek-ext.v22.woff2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../css/fonts/firacode-greek.v22.woff2
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../../css/fonts/firacode-latin-ext.v22.woff2

0 commit comments

Comments
 (0)