blob: 1de7064bba28bb8798763048117f7af82c09aee4 [file] [log] [blame]
//go:build ignore
// doc generates HTML files from the comments in header files.
//
// doc expects to be given the path to a JSON file via the --config option.
// From that JSON (which is defined by the Config struct) it reads a list of
// header file locations and generates HTML files for each in the current
// directory.
package main
import (
"bufio"
"encoding/json"
"errors"
"flag"
"fmt"
"html/template"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode"
)
// Config describes the structure of the config JSON file.
type Config struct {
// BaseDirectory is a path to which other paths in the file are
// relative.
BaseDirectory string
Sections []ConfigSection
}
type ConfigSection struct {
Name string
// Headers is a list of paths to header files.
Headers []string
}
// HeaderFile is the internal representation of a header file.
type HeaderFile struct {
// Name is the basename of the header file (e.g. "ex_data.html").
Name string
// Preamble contains a comment for the file as a whole. Each string
// is a separate paragraph.
Preamble []CommentBlock
Sections []HeaderSection
// AllDecls maps all decls to their URL fragments.
AllDecls map[string]string
}
type HeaderSection struct {
// Preamble contains a comment for a group of functions.
Preamble []CommentBlock
Decls []HeaderDecl
// Anchor, if non-empty, is the URL fragment to use in anchor tags.
Anchor string
// IsPrivate is true if the section contains private functions (as
// indicated by its name).
IsPrivate bool
}
type HeaderDecl struct {
// Comment contains a comment for a specific function. Each string is a
// paragraph. Some paragraph may contain \n runes to indicate that they
// are preformatted.
Comment []CommentBlock
// Name contains the name of the function, if it could be extracted.
Name string
// Decl contains the preformatted C declaration itself.
Decl string
// Anchor, if non-empty, is the URL fragment to use in anchor tags.
Anchor string
}
type CommentBlockType int
const (
CommentParagraph CommentBlockType = iota
CommentOrderedListItem
CommentBulletListItem
CommentCode
)
type CommentBlock struct {
Type CommentBlockType
Paragraph string
}
const (
cppGuard = "#if defined(__cplusplus)"
commentStart = "/* "
commentEnd = " */"
lineComment = "// "
)
func isComment(line string) bool {
return strings.HasPrefix(line, commentStart) || strings.HasPrefix(line, lineComment)
}
func commentSubject(line string) string {
if strings.HasPrefix(line, "A ") {
line = line[len("A "):]
} else if strings.HasPrefix(line, "An ") {
line = line[len("An "):]
}
idx := strings.IndexAny(line, " ,")
if idx < 0 {
return line
}
return line[:idx]
}
func extractCommentLines(lines []string, lineNo int) (comment []string, rest []string, restLineNo int, err error) {
if len(lines) == 0 {
return nil, lines, lineNo, nil
}
restLineNo = lineNo
rest = lines
var isBlock bool
if strings.HasPrefix(rest[0], commentStart) {
isBlock = true
} else if !strings.HasPrefix(rest[0], lineComment) {
panic("extractComment called on non-comment")
}
comment = []string{rest[0][len(commentStart):]}
rest = rest[1:]
restLineNo++
for len(rest) > 0 {
if isBlock {
last := &comment[len(comment)-1]
if i := strings.Index(*last, commentEnd); i >= 0 {
if i != len(*last)-len(commentEnd) {
err = fmt.Errorf("garbage after comment end on line %d", restLineNo)
return
}
*last = (*last)[:i]
return
}
}
line := rest[0]
if isBlock {
if !strings.HasPrefix(line, " *") {
err = fmt.Errorf("comment doesn't start with block prefix on line %d: %s", restLineNo, line)
return
}
} else if !strings.HasPrefix(line, "//") {
return
}
comment = append(comment, line[2:])
rest = rest[1:]
restLineNo++
}
err = errors.New("hit EOF in comment")
return
}
func removeBulletListMarker(line string) (string, bool) {
orig := line
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "+ ") && !strings.HasPrefix(line, "- ") && !strings.HasPrefix(line, "* ") {
return orig, false
}
return line[2:], true
}
func removeOrderedListMarker(line string) (rest string, num int, ok bool) {
orig := line
line = strings.TrimSpace(line)
if len(line) == 0 || !unicode.IsDigit(rune(line[0])) {
return orig, -1, false
}
l := 0
for l < len(line) && unicode.IsDigit(rune(line[l])) {
l++
}
num, err := strconv.Atoi(line[:l])
if err != nil {
return orig, -1, false
}
line = line[l:]
if line, ok := strings.CutPrefix(line, ". "); ok {
return line, num, true
}
if line, ok := strings.CutPrefix(line, ") "); ok {
return line, num, true
}
return orig, -1, false
}
func removeCodeIndent(line string) (string, bool) {
return strings.CutPrefix(line, " ")
}
func extractComment(lines []string, lineNo int) (comment []CommentBlock, rest []string, restLineNo int, err error) {
commentLines, rest, restLineNo, err := extractCommentLines(lines, lineNo)
if err != nil {
return
}
// This syntax and parsing algorithm is loosely inspired by CommonMark,
// but reduced to a small subset with no nesting. Blocks being open vs.
// closed can be tracked implicitly. We're also much slopplier about how
// indentation. Additionally, rather than grouping list items into
// lists, our parser just emits a list items, which are grouped later at
// rendering time.
//
// If we later need more features, such as nested lists, this can evolve
// into a more complex implementation.
var numBlankLines int
for _, line := range commentLines {
// Defer blank lines until we know the next element.
if len(strings.TrimSpace(line)) == 0 {
numBlankLines++
continue
}
blankLinesSkipped := numBlankLines
numBlankLines = 0
// Attempt to continue the previous block.
if len(comment) > 0 {
last := &comment[len(comment)-1]
if last.Type == CommentCode {
l, ok := removeCodeIndent(line)
if ok {
for i := 0; i < blankLinesSkipped; i++ {
last.Paragraph += "\n"
}
last.Paragraph += l + "\n"
continue
}
} else if blankLinesSkipped == 0 {
_, isBulletList := removeBulletListMarker(line)
_, num, isOrderedList := removeOrderedListMarker(line)
if isOrderedList && last.Type == CommentParagraph && num != 1 {
// A list item can only interrupt a paragraph if the number is one.
// See the discussion in https://spec.commonmark.org/0.30/#lists.
// This avoids wrapping like "(See RFC\n5280)" turning into a list.
isOrderedList = false
}
if !isBulletList && !isOrderedList {
// This is a continuation line of the previous paragraph.
last.Paragraph += " " + strings.TrimSpace(line)
continue
}
}
}
// Make a new block.
if line, ok := removeBulletListMarker(line); ok {
comment = append(comment, CommentBlock{
Type: CommentBulletListItem,
Paragraph: strings.TrimSpace(line),
})
} else if line, _, ok := removeOrderedListMarker(line); ok {
comment = append(comment, CommentBlock{
Type: CommentOrderedListItem,
Paragraph: strings.TrimSpace(line),
})
} else if line, ok := removeCodeIndent(line); ok {
comment = append(comment, CommentBlock{
Type: CommentCode,
Paragraph: line + "\n",
})
} else {
comment = append(comment, CommentBlock{
Type: CommentParagraph,
Paragraph: strings.TrimSpace(line),
})
}
}
return
}
func extractDecl(lines []string, lineNo int) (decl string, rest []string, restLineNo int, err error) {
if len(lines) == 0 || len(lines[0]) == 0 {
return "", lines, lineNo, nil
}
rest = lines
restLineNo = lineNo
var stack []rune
for len(rest) > 0 {
line := rest[0]
for _, c := range line {
switch c {
case '(', '{', '[':
stack = append(stack, c)
case ')', '}', ']':
if len(stack) == 0 {
err = fmt.Errorf("unexpected %c on line %d", c, restLineNo)
return
}
var expected rune
switch c {
case ')':
expected = '('
case '}':
expected = '{'
case ']':
expected = '['
default:
panic("internal error")
}
if last := stack[len(stack)-1]; last != expected {
err = fmt.Errorf("found %c when expecting %c on line %d", c, last, restLineNo)
return
}
stack = stack[:len(stack)-1]
}
}
if len(decl) > 0 {
decl += "\n"
}
decl += line
rest = rest[1:]
restLineNo++
if len(stack) == 0 && (len(decl) == 0 || decl[len(decl)-1] != '\\') {
break
}
}
return
}
func skipLine(s string) string {
i := strings.Index(s, "\n")
if i > 0 {
return s[i:]
}
return ""
}
var stackOfRegexp = regexp.MustCompile(`STACK_OF\(([^)]*)\)`)
var lhashOfRegexp = regexp.MustCompile(`LHASH_OF\(([^)]*)\)`)
func getNameFromDecl(decl string) (string, bool) {
for strings.HasPrefix(decl, "#if") || strings.HasPrefix(decl, "#elif") {
decl = skipLine(decl)
}
if strings.HasPrefix(decl, "typedef ") {
return "", false
}
for _, prefix := range []string{"struct ", "enum ", "#define "} {
if !strings.HasPrefix(decl, prefix) {
continue
}
decl = strings.TrimPrefix(decl, prefix)
for len(decl) > 0 && decl[0] == ' ' {
decl = decl[1:]
}
// struct and enum types can be the return type of a
// function.
if prefix[0] != '#' && strings.Index(decl, "{") == -1 {
break
}
i := strings.IndexAny(decl, "( ")
if i < 0 {
return "", false
}
return decl[:i], true
}
decl = strings.TrimPrefix(decl, "OPENSSL_EXPORT ")
decl = strings.TrimPrefix(decl, "const ")
decl = stackOfRegexp.ReplaceAllString(decl, "STACK_OF_$1")
decl = lhashOfRegexp.ReplaceAllString(decl, "LHASH_OF_$1")
i := strings.Index(decl, "(")
if i < 0 {
return "", false
}
j := strings.LastIndex(decl[:i], " ")
if j < 0 {
return "", false
}
for j+1 < len(decl) && decl[j+1] == '*' {
j++
}
return decl[j+1 : i], true
}
func sanitizeAnchor(name string) string {
return strings.Replace(name, " ", "-", -1)
}
func isPrivateSection(name string) bool {
return strings.HasPrefix(name, "Private functions") || strings.HasPrefix(name, "Private structures") || strings.Contains(name, "(hidden)")
}
func isCollectiveComment(line string) bool {
return strings.HasPrefix(line, "The ") || strings.HasPrefix(line, "These ")
}
func (config *Config) parseHeader(path string) (*HeaderFile, error) {
headerPath := filepath.Join(config.BaseDirectory, path)
headerFile, err := os.Open(headerPath)
if err != nil {
return nil, err
}
defer headerFile.Close()
scanner := bufio.NewScanner(headerFile)
var lines, oldLines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
lineNo := 1
found := false
for i, line := range lines {
if line == cppGuard {
lines = lines[i+1:]
lineNo += i + 1
found = true
break
}
}
if !found {
return nil, errors.New("no C++ guard found")
}
if len(lines) == 0 || lines[0] != "extern \"C\" {" {
return nil, errors.New("no extern \"C\" found after C++ guard")
}
lineNo += 2
lines = lines[2:]
header := &HeaderFile{
Name: filepath.Base(path),
AllDecls: make(map[string]string),
}
for i, line := range lines {
if len(line) > 0 {
lines = lines[i:]
lineNo += i
break
}
}
oldLines = lines
if len(lines) > 0 && isComment(lines[0]) {
comment, rest, restLineNo, err := extractComment(lines, lineNo)
if err != nil {
return nil, err
}
if len(rest) > 0 && len(rest[0]) == 0 {
if len(rest) < 2 || len(rest[1]) != 0 {
return nil, errors.New("preamble comment should be followed by two blank lines")
}
header.Preamble = comment
lineNo = restLineNo + 2
lines = rest[2:]
} else {
lines = oldLines
}
}
allAnchors := make(map[string]struct{})
for {
// Start of a section.
if len(lines) == 0 {
return nil, errors.New("unexpected end of file")
}
line := lines[0]
if line == cppGuard {
break
}
if len(line) == 0 {
return nil, fmt.Errorf("blank line at start of section on line %d", lineNo)
}
var section HeaderSection
if isComment(line) {
comment, rest, restLineNo, err := extractComment(lines, lineNo)
if err != nil {
return nil, err
}
if len(rest) > 0 && len(rest[0]) == 0 {
heading := firstSentence(comment)
anchor := sanitizeAnchor(heading)
if len(anchor) > 0 {
if _, ok := allAnchors[anchor]; ok {
return nil, fmt.Errorf("duplicate anchor: %s", anchor)
}
allAnchors[anchor] = struct{}{}
}
section.Preamble = comment
section.IsPrivate = isPrivateSection(heading)
section.Anchor = anchor
lines = rest[1:]
lineNo = restLineNo + 1
}
}
for len(lines) > 0 {
line := lines[0]
if len(line) == 0 {
lines = lines[1:]
lineNo++
break
}
if line == cppGuard {
return nil, fmt.Errorf("hit ending C++ guard while in section on line %d (possibly missing two empty lines ahead of guard?)", lineNo)
}
var comment []CommentBlock
var decl string
if isComment(line) {
comment, lines, lineNo, err = extractComment(lines, lineNo)
if err != nil {
return nil, err
}
}
if len(lines) == 0 {
return nil, fmt.Errorf("expected decl at EOF on line %d", lineNo)
}
declLineNo := lineNo
decl, lines, lineNo, err = extractDecl(lines, lineNo)
if err != nil {
return nil, err
}
name, ok := getNameFromDecl(decl)
if !ok {
name = ""
}
if last := len(section.Decls) - 1; len(name) == 0 && len(comment) == 0 && last >= 0 {
section.Decls[last].Decl += "\n" + decl
} else {
// As a matter of style, comments should start
// with the name of the thing that they are
// commenting on. We make an exception here for
// collective comments.
sentence := firstSentence(comment)
if len(comment) > 0 &&
len(name) > 0 &&
!isCollectiveComment(sentence) {
subject := commentSubject(sentence)
ok := subject == name
if l := len(subject); l > 0 && subject[l-1] == '*' {
// Groups of names, notably #defines, are often
// denoted with a wildcard.
ok = strings.HasPrefix(name, subject[:l-1])
}
if !ok {
return nil, fmt.Errorf("comment for %q doesn't seem to match line %s:%d\n", name, path, declLineNo)
}
}
anchor := sanitizeAnchor(name)
// TODO(davidben): Enforce uniqueness. This is
// skipped because #ifdefs currently result in
// duplicate table-of-contents entries.
allAnchors[anchor] = struct{}{}
header.AllDecls[name] = anchor
section.Decls = append(section.Decls, HeaderDecl{
Comment: comment,
Name: name,
Decl: decl,
Anchor: anchor,
})
}
if len(lines) > 0 && len(lines[0]) == 0 {
lines = lines[1:]
lineNo++
}
}
header.Sections = append(header.Sections, section)
}
return header, nil
}
func firstSentence(comment []CommentBlock) string {
if len(comment) == 0 {
return ""
}
s := comment[0].Paragraph
i := strings.Index(s, ". ")
if i >= 0 {
return s[:i]
}
if lastIndex := len(s) - 1; s[lastIndex] == '.' {
return s[:lastIndex]
}
return s
}
func markupComment(allDecls map[string]string, comment []CommentBlock) template.HTML {
var b strings.Builder
lastType := CommentParagraph
closeList := func() {
if lastType == CommentOrderedListItem {
b.WriteString("</ol>")
} else if lastType == CommentBulletListItem {
b.WriteString("</ul>")
}
}
for _, block := range comment {
// Group consecutive list items of the same type into a list.
if block.Type != lastType {
closeList()
if block.Type == CommentOrderedListItem {
b.WriteString("<ol>")
} else if block.Type == CommentBulletListItem {
b.WriteString("<ul>")
}
}
lastType = block.Type
switch block.Type {
case CommentParagraph:
if strings.HasPrefix(block.Paragraph, "WARNING:") {
b.WriteString("<p class=\"warning\">")
} else {
b.WriteString("<p>")
}
b.WriteString(string(markupParagraph(allDecls, block.Paragraph)))
b.WriteString("</p>")
case CommentOrderedListItem, CommentBulletListItem:
b.WriteString("<li>")
b.WriteString(string(markupParagraph(allDecls, block.Paragraph)))
b.WriteString("</li>")
case CommentCode:
b.WriteString("<pre>")
b.WriteString(block.Paragraph)
b.WriteString("</pre>")
default:
panic(block.Type)
}
}
closeList()
return template.HTML(b.String())
}
func markupParagraph(allDecls map[string]string, s string) template.HTML {
// TODO(davidben): Ideally the inline transforms would be unified into
// one pass, so that the HTML output of one pass does not interfere with
// the next.
ret := markupPipeWords(allDecls, s, true /* linkDecls */)
ret = markupFirstWord(ret)
ret = markupRFC(ret)
return ret
}
// markupPipeWords converts |s| into an HTML string, safe to be included outside
// a tag, while also marking up words surrounded by |.
func markupPipeWords(allDecls map[string]string, s string, linkDecls bool) template.HTML {
// It is safe to look for '|' in the HTML-escaped version of |s|
// below. The escaped version cannot include '|' instead tags because
// there are no tags by construction.
s = template.HTMLEscapeString(s)
ret := ""
for {
i := strings.Index(s, "|")
if i == -1 {
ret += s
break
}
ret += s[:i]
s = s[i+1:]
i = strings.Index(s, "|")
j := strings.Index(s, " ")
if i > 0 && (j == -1 || j > i) {
ret += "<tt>"
anchor, isLink := allDecls[s[:i]]
if linkDecls && isLink {
ret += fmt.Sprintf("<a href=\"%s\">%s</a>", template.HTMLEscapeString(anchor), s[:i])
} else {
ret += s[:i]
}
ret += "</tt>"
s = s[i+1:]
} else {
ret += "|"
}
}
return template.HTML(ret)
}
func markupFirstWord(s template.HTML) template.HTML {
if isCollectiveComment(string(s)) {
return s
}
start := 0
again:
end := strings.Index(string(s[start:]), " ")
if end > 0 {
end += start
w := strings.ToLower(string(s[start:end]))
// The first word was already marked up as an HTML tag. Don't
// mark it up further.
if strings.ContainsRune(w, '<') {
return s
}
if w == "a" || w == "an" {
start = end + 1
goto again
}
return s[:start] + "<span class=\"first-word\">" + s[start:end] + "</span>" + s[end:]
}
return s
}
var rfcRegexp = regexp.MustCompile("RFC ([0-9]+)")
func markupRFC(html template.HTML) template.HTML {
s := string(html)
matches := rfcRegexp.FindAllStringSubmatchIndex(s, -1)
if len(matches) == 0 {
return html
}
var b strings.Builder
var idx int
for _, match := range matches {
start, end := match[0], match[1]
number := s[match[2]:match[3]]
b.WriteString(s[idx:start])
fmt.Fprintf(&b, "<a href=\"https://www.rfc-editor.org/rfc/rfc%s.html\">%s</a>", number, s[start:end])
idx = end
}
b.WriteString(s[idx:])
return template.HTML(b.String())
}
func generate(outPath string, config *Config) (map[string]string, error) {
allDecls := make(map[string]string)
headerTmpl := template.New("headerTmpl")
headerTmpl.Funcs(template.FuncMap{
"firstSentence": firstSentence,
"markupPipeWordsNoLink": func(s string) template.HTML { return markupPipeWords(allDecls, s, false /* linkDecls */) },
"markupComment": func(c []CommentBlock) template.HTML { return markupComment(allDecls, c) },
})
headerTmpl, err := headerTmpl.Parse(`<!DOCTYPE html>
<html>
<head>
<title>BoringSSL - {{.Name}}</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="doc.css">
</head>
<body>
<div id="main">
<div class="title">
<h2>{{.Name}}</h2>
<a href="headers.html">All headers</a>
</div>
{{if .Preamble}}<div class="comment">{{.Preamble | markupComment}}</div>{{end}}
<ol class="toc">
{{range .Sections}}
{{if not .IsPrivate}}
{{if .Anchor}}<li class="header"><a href="#{{.Anchor}}">{{.Preamble | firstSentence | markupPipeWordsNoLink}}</a></li>{{end}}
{{range .Decls}}
{{if .Anchor}}<li><a href="#{{.Anchor}}"><tt>{{.Name}}</tt></a></li>{{end}}
{{end}}
{{end}}
{{end}}
</ol>
{{range .Sections}}
{{if not .IsPrivate}}
<div class="section" {{if .Anchor}}id="{{.Anchor}}"{{end}}>
{{if .Preamble}}<div class="sectionpreamble comment">{{.Preamble | markupComment}}</div>{{end}}
{{range .Decls}}
<div class="decl" {{if .Anchor}}id="{{.Anchor}}"{{end}}>
{{if .Comment}}<div class="comment">{{.Comment | markupComment}}</div>{{end}}
{{if .Decl}}<pre class="code">{{.Decl}}</pre>{{end}}
</div>
{{end}}
</div>
{{end}}
{{end}}
</div>
</body>
</html>`)
if err != nil {
return nil, err
}
headerDescriptions := make(map[string]string)
var headers []*HeaderFile
for _, section := range config.Sections {
for _, headerPath := range section.Headers {
header, err := config.parseHeader(headerPath)
if err != nil {
return nil, errors.New("while parsing " + headerPath + ": " + err.Error())
}
headerDescriptions[header.Name] = firstSentence(header.Preamble)
headers = append(headers, header)
for name, anchor := range header.AllDecls {
allDecls[name] = fmt.Sprintf("%s#%s", header.Name+".html", anchor)
}
}
}
for _, header := range headers {
filename := filepath.Join(outPath, header.Name+".html")
file, err := os.OpenFile(filename, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
if err != nil {
panic(err)
}
defer file.Close()
if err := headerTmpl.Execute(file, header); err != nil {
return nil, err
}
}
return headerDescriptions, nil
}
func generateIndex(outPath string, config *Config, headerDescriptions map[string]string) error {
indexTmpl := template.New("indexTmpl")
indexTmpl.Funcs(template.FuncMap{
"baseName": filepath.Base,
"headerDescription": func(header string) string {
return headerDescriptions[header]
},
})
indexTmpl, err := indexTmpl.Parse(`<!DOCTYPE html5>
<head>
<title>BoringSSL - Headers</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="doc.css">
</head>
<body>
<div id="main">
<div class="title">
<h2>BoringSSL Headers</h2>
</div>
<table>
{{range .Sections}}
<tr class="header"><td colspan="2">{{.Name}}</td></tr>
{{range .Headers}}
<tr><td><a href="{{. | baseName}}.html">{{. | baseName}}</a></td><td>{{. | baseName | headerDescription}}</td></tr>
{{end}}
{{end}}
</table>
</div>
</body>
</html>`)
if err != nil {
return err
}
file, err := os.OpenFile(filepath.Join(outPath, "headers.html"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0666)
if err != nil {
panic(err)
}
defer file.Close()
if err := indexTmpl.Execute(file, config); err != nil {
return err
}
return nil
}
func copyFile(outPath string, inFilePath string) error {
bytes, err := os.ReadFile(inFilePath)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(outPath, filepath.Base(inFilePath)), bytes, 0666)
}
func main() {
var (
configFlag *string = flag.String("config", "doc.config", "Location of config file")
outputDir *string = flag.String("out", ".", "Path to the directory where the output will be written")
config Config
)
flag.Parse()
if len(*configFlag) == 0 {
fmt.Printf("No config file given by --config\n")
os.Exit(1)
}
if len(*outputDir) == 0 {
fmt.Printf("No output directory given by --out\n")
os.Exit(1)
}
configBytes, err := os.ReadFile(*configFlag)
if err != nil {
fmt.Printf("Failed to open config file: %s\n", err)
os.Exit(1)
}
if err := json.Unmarshal(configBytes, &config); err != nil {
fmt.Printf("Failed to parse config file: %s\n", err)
os.Exit(1)
}
headerDescriptions, err := generate(*outputDir, &config)
if err != nil {
fmt.Printf("Failed to generate output: %s\n", err)
os.Exit(1)
}
if err := generateIndex(*outputDir, &config, headerDescriptions); err != nil {
fmt.Printf("Failed to generate index: %s\n", err)
os.Exit(1)
}
if err := copyFile(*outputDir, "doc.css"); err != nil {
fmt.Printf("Failed to copy static file: %s\n", err)
os.Exit(1)
}
}