devtools/hackdoc: init

This is hackdoc, a documentation rendering tool for monorepos.

This is the first code iteration, that can only serve from a local git
checkout.

The code is incomplete, and is WIP.

Change-Id: I68ef7a991191c1bb1b0fdd2a8d8353aba642e28f
changes/47/247/1
q3k 2020-04-08 20:03:12 +02:00
parent 154baf1cf6
commit c881cf3c22
18 changed files with 898 additions and 24 deletions

24
README
View File

@ -1,24 +0,0 @@
HSCloud
=======
This is a monorepo. You'll need bash and Bazel 1.0.0+ to use it.
If you have Nix installed you will also be able to manage bare metal nodes. If you don't want that, you can skip it.
Getting started
---------------
cd hscloud
. env.sh # setup PATH and hscloud_root
tools/install.sh # build tools
Then, to get Kubernetes access to k0.hswaw.net (current nearly-production cluster):
prodaccess
kubectl version
You will automatically get a `personal-$USERNAME` namespace created in which you have full admin rights.
For mor information about the cluster, see [cluster/README].

35
README.md Normal file
View File

@ -0,0 +1,35 @@
hscloud
=======
`hscloud` is the main monorepo of the Warsaw Hackerspace infrastructure code.
Any time you see a `//path/like/this`, it refers to the root of hscloud, ie. the path `path/like/this` in this repository. Perforce and/or Bazel users should feel right at home.
Viewing this documentation
--------------------------
For a please web viewing experience, [see this documentation in hackdoc](https://hackdoc.hackerspace.pl/). This will allow you to read this markdown file (and others) in a pretty, linkable view.
Getting started
---------------
You will need Bash and Bazel (1.2.0+). Clone this repo, cd into it and:
. ./env.sh # setup PATH and hscloud_root
tools/install.sh # build tools
A bunch of common tools will appearify in your `$PATH`. You should now be ready to follow other documentation.
This does not pollute your system, and you can work on multiple hscloud checkouts independently.
What now?
---------
If you want to use our Kubernetes cluster to run some stuff, see [//cluster/doc/user](cluster/doc/user).
If you're looking for administrative docs about cluster maintenance, see [//cluster/doc/admin](cluster/doc/admin).
If you want to browse the source of `hscloud` in a web browser, use [gerrit's gitiles](https://gerrit.hackerspace.pl/plugins/gitiles/hscloud/+/refs/heads/master/).
If you want to learn how to contribute to this repository, see [//doc/codelab/gerrit](doc/codelab/gerrit).

View File

@ -191,8 +191,11 @@ http_archive(
# Invoke go_rules_dependencies depending on host platform.
load("//tools:go_sdk.bzl", "gen_imports")
gen_imports(name = "go_sdk_imports")
load("@go_sdk_imports//:imports.bzl", "load_go_sdk")
load_go_sdk()
# Go Gazelle rules
@ -1948,3 +1951,15 @@ go_repository(
commit = "d07dcb9293789fdc99c797d3499a5799bc343b86",
importpath = "gopkg.in/irc.v3",
)
go_repository(
name = "in_gopkg_russross_blackfriday_v2",
commit = "d3b5b032dc8e8927d31a5071b56e14c89f045135",
importpath = "gopkg.in/russross/blackfriday.v2",
)
go_repository(
name = "com_github_shurcool_sanitized_anchor_name",
commit = "7bfe4c7ecddb3666a94b053b422cdd8f5aaa3615",
importpath = "github.com/shurcooL/sanitized_anchor_name",
)

View File

@ -0,0 +1,24 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "go_default_library",
srcs = [
"helpers.go",
"main.go",
"markdown.go",
],
importpath = "code.hackerspace.pl/hscloud/devtools/hackdoc",
visibility = ["//visibility:private"],
deps = [
"//devtools/hackdoc/config:go_default_library",
"//devtools/hackdoc/source:go_default_library",
"@com_github_golang_glog//:go_default_library",
"@in_gopkg_russross_blackfriday_v2//:go_default_library",
],
)
go_binary(
name = "hackdoc",
embed = [":go_default_library"],
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,18 @@
Hackdoc
=======
Hackdoc is a tool to automatically serve documentation based on a checkout of the [hscloud](/) source.
Usage
-----
Any Markdown submitted to hscloud is visible via hackdoc. Simply go to https://hackdoc.hackerspace.pl/path/to/markdown.md to see it rendered.
Local Rendering
---------------
To run hackdoc locally on a filesystem checkout (ie. when working on docs, templates, or hackdoc itself), run:
bazel run //devtools/hackdoc:local
The output log should tell you where hackdoc just started listening at. Currently this is `127.0.0.1:8080` by default. You can change this by passing a `-listen` flag, eg. `-listen 127.0.0.1:4242`.

View File

@ -0,0 +1,20 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
go_library(
name = "go_default_library",
srcs = ["config.go"],
importpath = "code.hackerspace.pl/hscloud/devtools/hackdoc/config",
visibility = ["//visibility:public"],
deps = [
"//devtools/hackdoc/source:go_default_library",
"@com_github_burntsushi_toml//:go_default_library",
"@com_github_golang_glog//:go_default_library",
],
)
go_test(
name = "go_default_test",
srcs = ["config_test.go"],
embed = [":go_default_library"],
deps = ["@com_github_go_test_deep//:go_default_library"],
)

View File

@ -0,0 +1,152 @@
package config
import (
"fmt"
"html/template"
"strings"
"github.com/BurntSushi/toml"
"github.com/golang/glog"
"code.hackerspace.pl/hscloud/devtools/hackdoc/source"
)
// Config is a configuration concerning a given path of the source. It is built
// from files present in the source, and from global configuration.
type Config struct {
// DefaultIndex is the filenames that should attempt to be rendered if no exact path is given
DefaultIndex []string
// Templates are the templates available to render markdown files, keyed by template name.
Templates map[string]*template.Template
// Errors that occured while building this config (due to config file errors, etc).
Errors map[string]error
}
type configToml struct {
DefaultIndex []string `toml:"default_index"`
Templates map[string]*configTomlTemplate `toml:"template"`
}
type configTomlTemplate struct {
Sources []string `toml:"sources"`
}
func parseToml(data []byte) (*configToml, error) {
var c configToml
err := toml.Unmarshal(data, &c)
if err != nil {
return nil, err
}
if c.Templates == nil {
c.Templates = make(map[string]*configTomlTemplate)
}
return &c, nil
}
func configFileLocations(path string) []string {
// Support for unix-style filesystem prefix (/foo/bar/baz) and
// perforce-depot-style prefix (//foo/bar/baz).
// Also support relative paths.
pathTrimmed := strings.TrimLeft(path, "/")
prefixLen := len(path) - len(pathTrimmed)
prefix := path[:prefixLen]
path = pathTrimmed
if len(prefix) > 2 {
return nil
}
// Turn path into possible directory names, including root.
path = strings.Trim(path, "/")
parts := strings.Split(path, "/")
if parts[0] != "" {
parts = append([]string{""}, parts...)
}
locations := []string{}
for i, _ := range parts {
p := strings.Join(parts[:i+1], "/")
p += "/hackdoc.toml"
p = prefix + strings.Trim(p, "/")
locations = append(locations, p)
}
return locations
}
func ForPath(s source.Source, path string) (*Config, error) {
if path != "//" {
path = strings.TrimRight(path, "/")
}
// Try cache.
cacheKey := fmt.Sprintf("config:%s", path)
if v := s.CacheGet(cacheKey); v != nil {
cfg, ok := v.(*Config)
if !ok {
glog.Errorf("Cache key %q corrupted, deleting", cacheKey)
s.CacheSet([]string{}, cacheKey, nil)
} else {
return cfg, nil
}
}
// Feed cache.
cfg := &Config{
Templates: make(map[string]*template.Template),
Errors: make(map[string]error),
}
tomlPaths := configFileLocations(path)
for _, p := range tomlPaths {
file, err := s.IsFile(p)
if err != nil {
return nil, fmt.Errorf("IsFile(%q): %w", path, err)
}
if !file {
continue
}
data, err := s.ReadFile(p)
if err != nil {
return nil, fmt.Errorf("ReadFile(%q): %w", path, err)
}
c, err := parseToml(data)
if err != nil {
cfg.Errors[p] = err
continue
}
err = cfg.updateFromToml(p, s, c)
if err != nil {
return nil, fmt.Errorf("updating from %q: %w", p, err)
}
}
return cfg, nil
}
func (c *Config) updateFromToml(p string, s source.Source, t *configToml) error {
if t.DefaultIndex != nil {
c.DefaultIndex = t.DefaultIndex
}
for k, v := range t.Templates {
tmpl := template.New(k)
for _, source := range v.Sources {
data, err := s.ReadFile(source)
if err != nil {
c.Errors[p] = fmt.Errorf("reading template file %q: %w", source, err)
return nil
}
tmpl, err = tmpl.Parse(string(data))
if err != nil {
c.Errors[p] = fmt.Errorf("parsing template file %q: %w", source, err)
return nil
}
}
c.Templates[k] = tmpl
}
return nil
}

View File

@ -0,0 +1,99 @@
package config
import (
"testing"
"github.com/go-test/deep"
)
func TestParse(t *testing.T) {
for _, test := range []struct {
name string
data string
want *configToml
}{
{
name: "normal config",
data: `
default_index = ["foo.md", "bar.md"]
[template.default]
sources = ["hackdoc/bar.html", "hackdoc/baz.html"]
[template.foo]
sources = ["foo/bar.html", "foo/baz.html"]
`,
want: &configToml{
DefaultIndex: []string{"foo.md", "bar.md"},
Templates: map[string]*configTomlTemplate{
"default": &configTomlTemplate{
Sources: []string{"hackdoc/bar.html", "hackdoc/baz.html"},
},
"foo": &configTomlTemplate{
Sources: []string{"foo/bar.html", "foo/baz.html"},
},
},
},
}, {
name: "empty config",
data: "",
want: &configToml{
DefaultIndex: nil,
Templates: map[string]*configTomlTemplate{},
},
},
} {
t.Run(test.name, func(t *testing.T) {
got, err := parseToml([]byte(test.data))
if err != nil {
t.Fatalf("could not parse config: %w", err)
}
if diff := deep.Equal(test.want, got); diff != nil {
t.Fatal(diff)
}
})
}
}
func TestLocations(t *testing.T) {
for _, test := range []struct {
name string
path string
want []string
}{
{
name: "perforce-style path",
path: "//foo/bar/baz",
want: []string{"//hackdoc.toml", "//foo/hackdoc.toml", "//foo/bar/hackdoc.toml", "//foo/bar/baz/hackdoc.toml"},
}, {
name: "unix-style path",
path: "/foo/bar/baz",
want: []string{"/hackdoc.toml", "/foo/hackdoc.toml", "/foo/bar/hackdoc.toml", "/foo/bar/baz/hackdoc.toml"},
}, {
name: "relative-style path",
path: "foo/bar/baz",
want: []string{"hackdoc.toml", "foo/hackdoc.toml", "foo/bar/hackdoc.toml", "foo/bar/baz/hackdoc.toml"},
}, {
name: "root perforce-style path",
path: "//",
want: []string{"//hackdoc.toml"},
}, {
name: "root unix-style path",
path: "/",
want: []string{"/hackdoc.toml"},
}, {
name: "empty path",
path: "",
want: []string{"hackdoc.toml"},
}, {
name: "weird path",
path: "///what/is///this///",
want: nil,
},
} {
t.Run(test.name, func(t *testing.T) {
got := configFileLocations(test.path)
if diff := deep.Equal(test.want, got); diff != nil {
t.Fatal(diff)
}
})
}
}

View File

@ -0,0 +1,18 @@
package main
import (
"fmt"
"net/http"
)
func handle404(w http.ResponseWriter, r *http.Request) {
logRequest(w, r, "404")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "404!\n")
}
func handle500(w http.ResponseWriter, r *http.Request) {
logRequest(w, r, "500")
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "500 :(\n")
}

178
devtools/hackdoc/main.go Normal file
View File

@ -0,0 +1,178 @@
package main
import (
"flag"
"fmt"
"net/http"
"path/filepath"
"regexp"
"strings"
"github.com/golang/glog"
"code.hackerspace.pl/hscloud/devtools/hackdoc/config"
"code.hackerspace.pl/hscloud/devtools/hackdoc/source"
)
var (
flagListen = "127.0.0.1:8080"
flagDocRoot = "./docroot"
flagHackdocURL = ""
flagGitwebURLPattern = "https://gerrit.hackerspace.pl/plugins/gitiles/hscloud/+/%s/%s"
flagGitwebDefaultBranch = "master"
rePagePath = regexp.MustCompile(`^/([A-Za-z0-9_\-/\. ]*)$`)
)
func init() {
flag.Set("logtostderr", "true")
}
func main() {
flag.StringVar(&flagListen, "listen", flagListen, "Address to listen on for HTTP traffic")
flag.StringVar(&flagDocRoot, "docroot", flagDocRoot, "Path from which to serve documents")
flag.StringVar(&flagHackdocURL, "hackdoc_url", flagHackdocURL, "Public URL of hackdoc. If not given, autogenerate from listen path for dev purposes")
flag.StringVar(&flagGitwebURLPattern, "gitweb_url_pattern", flagGitwebURLPattern, "Pattern to sprintf to for URL for viewing a file in Git. First string is ref/rev, second is bare file path (sans //)")
flag.StringVar(&flagGitwebDefaultBranch, "gitweb_default_rev", flagGitwebDefaultBranch, "Default Git rev to render/link to")
flag.Parse()
if flagHackdocURL == "" {
flagHackdocURL = fmt.Sprintf("http://%s", flagListen)
}
path, err := filepath.Abs(flagDocRoot)
if err != nil {
glog.Fatalf("Could not dereference path %q: %w", path, err)
}
s := &service{
source: source.NewLocal(path),
}
http.HandleFunc("/", s.handler)
glog.Infof("Listening on %q...", flagListen)
if err := http.ListenAndServe(flagListen, nil); err != nil {
glog.Fatal(err)
}
}
type service struct {
source source.Source
}
func (s *service) handler(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" && r.Method != "HEAD" {
w.WriteHeader(http.StatusMethodNotAllowed)
fmt.Fprintf(w, "method not allowed")
return
}
glog.Infof("%+v", r.URL.Query())
rev := r.URL.Query().Get("rev")
if rev == "" {
rev = flagGitwebDefaultBranch
}
path := r.URL.Path
if match := rePagePath.FindStringSubmatch(path); match != nil {
s.handlePage(w, r, rev, match[1])
return
}
handle404(w, r)
}
func logRequest(w http.ResponseWriter, r *http.Request, format string, args ...interface{}) {
result := fmt.Sprintf(format, args...)
glog.Infof("result: %s, remote: %q, ua: %q, referrer: %q, host: %q path: %q", result, r.RemoteAddr, r.Header.Get("User-Agent"), r.Header.Get("Referrer"), r.Host, r.URL.Path)
}
func urlPathToDepotPath(url string) string {
// Sanitize request.
parts := strings.Split(url, "/")
for i, p := range parts {
// Allow last part to be "", ie, for a path to end in /
if p == "" {
if i != len(parts)-1 {
return ""
}
}
// net/http sanitizes this anyway, but we better be sure.
if p == "." || p == ".." {
return ""
}
}
path := "//" + strings.Join(parts, "/")
return path
}
func (s *service) handlePageAuto(w http.ResponseWriter, r *http.Request, rev, rpath, dirpath string) {
cfg, err := config.ForPath(s.source, dirpath)
if err != nil {
glog.Errorf("could not get config for path %q: %w", dirpath, err)
handle500(w, r)
return
}
for _, f := range cfg.DefaultIndex {
fpath := dirpath + f
file, err := s.source.IsFile(fpath)
if err != nil {
glog.Errorf("IsFile(%q): %w", fpath, err)
handle500(w, r)
return
}
if file {
s.handleMarkdown(w, r, s.source, rev, fpath, cfg)
return
}
}
handle404(w, r)
}
func (s *service) handlePage(w http.ResponseWriter, r *http.Request, rev, page string) {
path := urlPathToDepotPath(page)
if strings.HasSuffix(path, "/") {
// Directory path given, autoresolve.
dirpath := path
if path != "//" {
dirpath = strings.TrimSuffix(path, "/") + "/"
}
s.handlePageAuto(w, r, rev, path, dirpath)
return
}
// Otherwise, try loading the file.
file, err := s.source.IsFile(path)
if err != nil {
glog.Errorf("IsFile(%q): %w", path, err)
handle500(w, r)
return
}
// File exists, render that.
if file {
parts := strings.Split(path, "/")
dirpath := strings.Join(parts[:(len(parts)-1)], "/")
cfg, err := config.ForPath(s.source, dirpath)
if err != nil {
glog.Errorf("could not get config for path %q: %w", dirpath, err)
handle500(w, r)
return
}
s.handleMarkdown(w, r, s.source, rev, path, cfg)
return
}
// Otherwise assume directory, try all posibilities.
dirpath := path
if path != "//" {
dirpath = strings.TrimSuffix(path, "/") + "/"
}
s.handlePageAuto(w, r, rev, path, dirpath)
}

View File

@ -0,0 +1,49 @@
package main
import (
"fmt"
"html/template"
"net/http"
"strings"
"code.hackerspace.pl/hscloud/devtools/hackdoc/config"
"code.hackerspace.pl/hscloud/devtools/hackdoc/source"
"github.com/golang/glog"
"gopkg.in/russross/blackfriday.v2"
)
func (s *service) handleMarkdown(w http.ResponseWriter, r *http.Request, src source.Source, branch, path string, cfg *config.Config) {
data, err := src.ReadFile(path)
if err != nil {
glog.Errorf("ReadFile(%q): %w", err)
handle500(w, r)
return
}
rendered := blackfriday.Run([]byte(data))
logRequest(w, r, "serving markdown at %s, cfg %+v", path, cfg)
// TODO(q3k): allow markdown files to override which template to load
tmpl, ok := cfg.Templates["default"]
if !ok {
glog.Errorf("No default template found for %s", path)
// TODO(q3k): implement fallback template
w.Write(rendered)
return
}
vars := map[string]interface{}{
"Rendered": template.HTML(rendered),
"Title": path,
"Path": path,
"PathInDepot": strings.TrimPrefix(path, "//"),
"HackdocURL": flagHackdocURL,
"GitwebURL": fmt.Sprintf(flagGitwebURLPattern, flagGitwebDefaultBranch, strings.TrimPrefix(path, "//")),
}
err = tmpl.Execute(w, vars)
if err != nil {
glog.Errorf("Could not execute template for %s: %v", err)
}
}

View File

@ -0,0 +1,12 @@
load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "go_default_library",
srcs = [
"source.go",
"source_local.go",
],
importpath = "code.hackerspace.pl/hscloud/devtools/hackdoc/source",
visibility = ["//visibility:public"],
deps = ["@com_github_golang_glog//:go_default_library"],
)

View File

@ -0,0 +1,10 @@
package source
type Source interface {
IsFile(path string) (bool, error)
ReadFile(path string) ([]byte, error)
IsDirectory(path string) (bool, error)
CacheSet(dependencies []string, key string, value interface{})
CacheGet(key string) interface{}
}

View File

@ -0,0 +1,78 @@
package source
import (
"fmt"
"io/ioutil"
"os"
"strings"
)
type LocalSource struct {
root string
}
func NewLocal(root string) Source {
return &LocalSource{
root: strings.TrimRight(root, "/"),
}
}
func (s *LocalSource) resolve(path string) (string, error) {
if !strings.HasPrefix(path, "//") {
return "", fmt.Errorf("invalid path %q, expected // prefix", path)
}
path = path[2:]
if strings.HasPrefix(path, "/") {
return "", fmt.Errorf("invalid path %q, expected // prefix", path)
}
return s.root + "/" + path, nil
}
func (s *LocalSource) IsFile(path string) (bool, error) {
path, err := s.resolve(path)
if err != nil {
return false, err
}
stat, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("os.Stat(%q): %w", path, err)
}
return !stat.IsDir(), nil
}
func (s *LocalSource) ReadFile(path string) ([]byte, error) {
path, err := s.resolve(path)
if err != nil {
return nil, err
}
// TODO(q3k): limit size
return ioutil.ReadFile(path)
}
func (s *LocalSource) IsDirectory(path string) (bool, error) {
path, err := s.resolve(path)
if err != nil {
return false, err
}
stat, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("os.Stat(%q): %w", path, err)
}
return stat.IsDir(), nil
}
func (s *LocalSource) CacheSet(dependencies []string, key string, value interface{}) {
// Swallow writes. The local filesystem can always change underneath us and
// we're not tracking anything, so we cannot ever keep caches.
}
func (s *LocalSource) CacheGet(key string) interface{} {
return nil
}

View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>hackdoc:{{ .Title }}</title>
{{ template "head" . }}
</head>
<body>
{{ template "body" . }}
</body>
</html>

View File

@ -0,0 +1,172 @@
{{ define "head" }}
<style type="text/css">
html, body, div, span, applet, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
a, abbr, acronym, address, big, cite, code,
del, dfn, em, img, ins, kbd, q, s, samp,
small, strike, strong, sub, sup, tt, var,
b, u, i, center,
dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, embed,
figure, figcaption, footer, header, hgroup,
menu, nav, output, ruby, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
body {
line-height: 1;
}
ol, ul {
list-style: none;
}
blockquote, q {
quotes: none;
}
blockquote:before, blockquote:after,
q:before, q:after {
content: '';
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
body {
font-size: 14px;
line-height: 1.25em;
background-color: #f0f0f0;
}
.wrapper {
display: flex;
flex-direction: row;
justify-content: center;
width: 100%;
}
.column {
max-width: 80em;
padding: 1rem 0 1rem 0;
}
.page {
background-color: #fefefe;
padding: 0.5rem 2rem 3rem 2rem;
}
.header {
font-size: 1.2em;
font-family: Consolas, monospace;
margin-top: 1rem;
padding: 0.5em 0 0.5em 0;
}
.header a {
text-decoration: none;
}
.header a:hover {
text-decoration: underline;
}
.header span.red {
color: #b30014;
}
.footer {
font-size: 0.8em;
color: #ccc;
font-weight: 800;
font-family: helvetica, arial, sans-serif;
padding: 0.5em 1em 1em;
text-align: right;
}
.footer a {
color: #bbb;
}
h1,h2,h3,h4 {
font-family: helvetica, arial, sans-serif;
}
h1 {
font-size: 1.6em;
padding: 1em 0 0 0;
font-weight: 800;
}
h2 {
font-size: 1.3em;
padding: 0.8em 0 0 0;
color: #333;
font-weight: 800;
}
h3 {
font-size: 1.2em;
padding: 0.4em 0 0 0;
color: #444;
}
h4 {
font-size: 1.0em;
color: #555;
}
code {
font-family: Consolas, monospace;
background-color: #f8f8f8;
}
pre {
background-color: #f8f8f8;
border: 1px solid #d8d8d8;
margin: 1em;
padding: 0.5em;
overflow: auto;
}
p {
margin-top: 0.8em;
line-height: 1.5em;
}
ul {
padding-top: 0.5em;
}
ul li {
padding-left: 1em;
}
ul li::before {
content: "•";
color: #333;;
display: inline-block;
width: 1em;
margin-left: -1em;
}
</style>
{{ end }}
{{ define "body" }}
<div class="wrapper">
<div class="column">
<div class="page">
<div class="header">
<span class="red">hackdoc:</span><span>{{ .Path }}</span> <a href="{{ .GitwebURL }}">[git]</a>
</div>
{{ .Rendered }}
</div>
<div class="footer">
Generated by <a href="{{ .HackdocURL }}/devtools/hackdoc">hackdoc</a>.
</div>
</div>
</div>
{{ end }}

7
hackdoc.toml Normal file
View File

@ -0,0 +1,7 @@
default_index = ["index.md", "readme.md", "README.md"]
[template.default]
sources = [
"//devtools/hackdoc/tpl/base.html",
"//devtools/hackdoc/tpl/default.html",
]