Add cmd/docs, the first corestack component: `docs new adr|plan "Title"` writes records from embedded templates, and `docs check` validates them against the public format spec in docs/docs-format.md (timestamp IDs, frontmatter keys and enums, separate decision and implementation status, supersession symmetry, MADR 4 layout with required Consequences, plan sections, and repo-root/durable references), reporting path:line errors. Also adds the module skeleton (go.auvem.com/corestack, BSD-3-Clause, README, AGENTS.md, CLAUDE.md), Gitea CI running tests and docs check, a tag-triggered goreleaser release for darwin/linux binaries, and the accepted ADR adopting the format. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
132 lines
3.7 KiB
Go
132 lines
3.7 KiB
Go
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)], "-")
|
|
}
|
|
|
|
var closingRe = regexp.MustCompile(`(^|[ \t])#+$`)
|
|
|
|
// 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")
|
|
}
|
|
if closingRe.MatchString(title) {
|
|
return "", errors.New(`title must not end in a "#" run: it would be read as a closing sequence of the H1`)
|
|
}
|
|
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
|
|
}
|