diff --git a/README b/README deleted file mode 100644 index dc389bd4..00000000 --- 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 00000000..7383e891 --- /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 65278ba5..d4d45b12 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -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", +) 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 00000000..b29950d6 --- /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 00000000..d3c5187e --- /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 00000000..fecf6381 --- /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 00000000..70b7b469 --- /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 00000000..ba542fe9 --- /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 00000000..dd7269ff --- /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 00000000..938a4262 --- /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 00000000..da52aa52 --- /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 00000000..b896159b --- /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 00000000..a79a9209 --- /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 00000000..35ad1722 --- /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 00000000..5fd861ad --- /dev/null +++ b/devtools/hackdoc/tpl/base.html @@ -0,0 +1,11 @@ + + + + + hackdoc:{{ .Title }} + {{ template "head" . }} + + + {{ template "body" . }} + + diff --git a/devtools/hackdoc/tpl/default.html b/devtools/hackdoc/tpl/default.html new file mode 100644 index 00000000..3bfe62a0 --- /dev/null +++ b/devtools/hackdoc/tpl/default.html @@ -0,0 +1,172 @@ +{{ define "head" }} + +{{ end }} +{{ define "body" }} +
+
+
+
+ hackdoc:{{ .Path }} [git] +
+ {{ .Rendered }} +
+ +
+
+{{ end }} diff --git a/hackdoc.toml b/hackdoc.toml new file mode 100644 index 00000000..83eaceaa --- /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", +]