feat: add docs checker and record templates
docs new creates ADRs and plans from embedded templates; docs check reports path:line: message problems per docs/docs-format.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0428256bd
commit
750b7449b0
15 files changed
+1856
No files matched your search
@@ -0,0 +1,126 @@
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
timestampLayout = "20060102T150405.000Z"
|
||||
maxSlug = 80
|
||||
)
|
||||
|
||||
var (
|
||||
nameRe = regexp.MustCompile(`^(\d{8}T\d{9}Z)-(.+)\.md$`)
|
||||
slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
)
|
||||
|
||||
// parseName validates a record filename and returns its stem, UTC timestamp
|
||||
// and a problem message (empty when valid).
|
||||
func parseName(name string) (stem string, at time.Time, msg string) {
|
||||
m := nameRe.FindStringSubmatch(name)
|
||||
if m == nil {
|
||||
return "", at, "name must be YYYYMMDDTHHMMSSmmmZ-slug.md"
|
||||
}
|
||||
at, err := time.Parse(timestampLayout, m[1][:15]+"."+m[1][15:])
|
||||
if err != nil {
|
||||
return "", at, "name has an invalid UTC timestamp"
|
||||
}
|
||||
switch {
|
||||
case !slugRe.MatchString(m[2]):
|
||||
return "", at, "slug must be lowercase a-z0-9 words joined by single hyphens"
|
||||
case len(m[2]) > maxSlug:
|
||||
return "", at, fmt.Sprintf("slug is longer than %d characters", maxSlug)
|
||||
}
|
||||
return strings.TrimSuffix(name, ".md"), at, ""
|
||||
}
|
||||
|
||||
// slugify lowercases title and joins its ASCII letter and digit runs with hyphens.
|
||||
func slugify(title string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
} else if b.Len() > 0 && !strings.HasSuffix(b.String(), "-") {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
s := b.String()
|
||||
return strings.Trim(s[:min(len(s), maxSlug)], "-")
|
||||
}
|
||||
|
||||
// New creates a record of the given kind ("adr" or "plan") under root and
|
||||
// returns its root-relative slash path. The file is created exclusively; the
|
||||
// timestamp advances by 1 ms on a collision.
|
||||
func New(root, kind, title string, now time.Time) (string, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || strings.ContainsAny(title, "\r\n") {
|
||||
return "", errors.New("title must be non-empty and a single line")
|
||||
}
|
||||
slug := slugify(title)
|
||||
if slug == "" {
|
||||
return "", errors.New("title needs at least one ASCII letter or digit for the file name")
|
||||
}
|
||||
var dir string
|
||||
for _, d := range dirs {
|
||||
if d.kind == kind {
|
||||
dir = d.dir
|
||||
}
|
||||
}
|
||||
tmpl, err := templates.ReadFile("templates/" + kind + ".md")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unknown record type %q", kind)
|
||||
}
|
||||
quoted, err := json.Marshal(title) // JSON strings are valid YAML double-quoted scalars
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
used, err := stems(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
at := now.UTC().Truncate(time.Millisecond)
|
||||
for ; ; at = at.Add(time.Millisecond) {
|
||||
stem := strings.Replace(at.Format(timestampLayout), ".", "", 1) + "-" + slug
|
||||
if used[stem] {
|
||||
continue
|
||||
}
|
||||
rel := dir + "/" + stem + ".md"
|
||||
f, err := os.OpenFile(filepath.Join(root, filepath.FromSlash(rel)), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
content := strings.NewReplacer("@TITLE_YAML@", string(quoted), "@TITLE@", title, "@DATE@", at.Format(time.DateOnly)).Replace(string(tmpl))
|
||||
_, werr := f.WriteString(content)
|
||||
return rel, errors.Join(werr, f.Close())
|
||||
}
|
||||
}
|
||||
|
||||
// stems returns the IDs of every record-named file in both directories.
|
||||
func stems(root string) (map[string]bool, error) {
|
||||
used := map[string]bool{}
|
||||
for _, d := range dirs {
|
||||
entries, err := os.ReadDir(filepath.Join(root, d.dir))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if stem, _, msg := parseName(e.Name()); msg == "" {
|
||||
used[stem] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
Reference in new issue
Block a user