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 }