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",
+]