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
diff --git a/README b/README
deleted file mode 100644
index dc389bd..0000000
--- a/README
+++ /dev/null
@@ -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].
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7383e89
--- /dev/null
+++ b/README.md
@@ -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).
diff --git a/WORKSPACE b/WORKSPACE
index 65278ba..d4d45b1 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -191,8 +191,11 @@
# 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 @@
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",
+)
diff --git a/cluster/README b/cluster/README.md
similarity index 100%
rename from cluster/README
rename to cluster/README.md
diff --git a/devtools/hackdoc/BUILD.bazel b/devtools/hackdoc/BUILD.bazel
new file mode 100644
index 0000000..b29950d
--- /dev/null
+++ b/devtools/hackdoc/BUILD.bazel
@@ -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"],
+)
diff --git a/devtools/hackdoc/README.md b/devtools/hackdoc/README.md
new file mode 100644
index 0000000..d3c5187
--- /dev/null
+++ b/devtools/hackdoc/README.md
@@ -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`.
diff --git a/devtools/hackdoc/config/BUILD.bazel b/devtools/hackdoc/config/BUILD.bazel
new file mode 100644
index 0000000..fecf638
--- /dev/null
+++ b/devtools/hackdoc/config/BUILD.bazel
@@ -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"],
+)
diff --git a/devtools/hackdoc/config/config.go b/devtools/hackdoc/config/config.go
new file mode 100644
index 0000000..70b7b46
--- /dev/null
+++ b/devtools/hackdoc/config/config.go
@@ -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
+}
diff --git a/devtools/hackdoc/config/config_test.go b/devtools/hackdoc/config/config_test.go
new file mode 100644
index 0000000..ba542fe
--- /dev/null
+++ b/devtools/hackdoc/config/config_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/devtools/hackdoc/helpers.go b/devtools/hackdoc/helpers.go
new file mode 100644
index 0000000..dd7269f
--- /dev/null
+++ b/devtools/hackdoc/helpers.go
@@ -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")
+}
diff --git a/devtools/hackdoc/main.go b/devtools/hackdoc/main.go
new file mode 100644
index 0000000..938a426
--- /dev/null
+++ b/devtools/hackdoc/main.go
@@ -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)
+}
diff --git a/devtools/hackdoc/markdown.go b/devtools/hackdoc/markdown.go
new file mode 100644
index 0000000..da52aa5
--- /dev/null
+++ b/devtools/hackdoc/markdown.go
@@ -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)
+ }
+}
diff --git a/devtools/hackdoc/source/BUILD.bazel b/devtools/hackdoc/source/BUILD.bazel
new file mode 100644
index 0000000..b896159
--- /dev/null
+++ b/devtools/hackdoc/source/BUILD.bazel
@@ -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"],
+)
diff --git a/devtools/hackdoc/source/source.go b/devtools/hackdoc/source/source.go
new file mode 100644
index 0000000..a79a920
--- /dev/null
+++ b/devtools/hackdoc/source/source.go
@@ -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{}
+}
diff --git a/devtools/hackdoc/source/source_local.go b/devtools/hackdoc/source/source_local.go
new file mode 100644
index 0000000..35ad172
--- /dev/null
+++ b/devtools/hackdoc/source/source_local.go
@@ -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
+}
diff --git a/devtools/hackdoc/tpl/base.html b/devtools/hackdoc/tpl/base.html
new file mode 100644
index 0000000..5fd861a
--- /dev/null
+++ b/devtools/hackdoc/tpl/base.html
@@ -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>
diff --git a/devtools/hackdoc/tpl/default.html b/devtools/hackdoc/tpl/default.html
new file mode 100644
index 0000000..3bfe62a
--- /dev/null
+++ b/devtools/hackdoc/tpl/default.html
@@ -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 }}
diff --git a/hackdoc.toml b/hackdoc.toml
new file mode 100644
index 0000000..83eacea
--- /dev/null
+++ b/hackdoc.toml
@@ -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",
+]