Files
upstream-mirror 0f4df23290
Some checks failed
kotlin / test (push) Has been skipped
python / test (push) Has been skipped
typescript / test (push) Has been skipped
go / build darwin/amd64 (push) Has been cancelled
go / build linux/amd64 (push) Has been cancelled
go / build windows/amd64 (push) Has been cancelled
go / build darwin/arm64 (push) Has been cancelled
go / build linux/arm64 (push) Has been cancelled
go / build windows/arm64 (push) Has been cancelled
go / test (push) Has been cancelled
go / vuln_check (push) Has been cancelled
snapshot of https://github.com/sqlc-dev/sqlc.git (main, 2026-09-16; blobs > 131072B omitted)
2026-09-16 02:17:34 +05:00

382 lines
10 KiB
Go

// Package docs validates the documentation content contract for docs/.
//
// The docs/ directory is plain CommonMark + GitHub-flavored Markdown with no
// rendering toolchain in this repository; a separate repository consumes it
// and builds the documentation site. This package enforces the contract that
// consumer relies on:
//
// - every page parses as Markdown and starts with exactly one level-1
// heading (the page title)
// - every relative link and image resolves to a file inside docs/, and
// anchor fragments resolve to a heading in the target page
// - every page appears exactly once in toc.yaml, either in a section or in
// the unlisted set, and every toc.yaml entry names a real page
// - no raw HTML or JSX except HTML comments
// - no MyST/Sphinx directives left over from the old toolchain
package docs
import (
"bytes"
"fmt"
"os"
"path"
"path/filepath"
"sort"
"strings"
"unicode"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/text"
"gopkg.in/yaml.v3"
)
// A Problem is a single contract violation, located by file (relative to the
// docs root) and 1-based line number.
type Problem struct {
File string
Line int
Message string
}
func (p Problem) String() string {
return fmt.Sprintf("%s:%d: %s", p.File, p.Line, p.Message)
}
type tocFile struct {
Index string `yaml:"index"`
Sections []struct {
Title string `yaml:"title"`
Pages []string `yaml:"pages"`
} `yaml:"sections"`
Unlisted []string `yaml:"unlisted"`
}
type page struct {
src []byte
doc ast.Node
anchors map[string]bool
}
// Lint validates every Markdown file under root against the content
// contract and cross-checks the set of files against root/toc.yaml.
func Lint(root string) ([]Problem, error) {
var problems []Problem
files, err := markdownFiles(root)
if err != nil {
return nil, err
}
md := goldmark.New(goldmark.WithExtensions(extension.GFM))
// First pass: parse everything and collect heading anchors, so link
// fragments can be validated against any page.
pages := make(map[string]*page, len(files))
for _, rel := range files {
src, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return nil, err
}
doc := md.Parser().Parse(text.NewReader(src))
pages[rel] = &page{src: src, doc: doc, anchors: headingAnchors(doc, src)}
}
for _, rel := range files {
problems = append(problems, lintPage(rel, pages)...)
}
problems = append(problems, lintTOC(root, files)...)
sort.Slice(problems, func(i, j int) bool {
if problems[i].File != problems[j].File {
return problems[i].File < problems[j].File
}
return problems[i].Line < problems[j].Line
})
return problems, nil
}
// markdownFiles returns the slash-separated paths of all .md files under
// root, relative to root.
func markdownFiles(root string) ([]string, error) {
var files []string
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasSuffix(d.Name(), ".md") {
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
files = append(files, filepath.ToSlash(rel))
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(files)
return files, nil
}
func lintPage(rel string, pages map[string]*page) []Problem {
var problems []Problem
p := pages[rel]
report := func(n ast.Node, format string, args ...any) {
problems = append(problems, Problem{
File: rel,
Line: nodeLine(n, p.src),
Message: fmt.Sprintf(format, args...),
})
}
var h1s int
firstBlock := true
ast.Walk(p.doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if n.Type() == ast.TypeBlock && n.Parent() == p.doc {
if firstBlock {
firstBlock = false
if h, ok := n.(*ast.Heading); !ok || h.Level != 1 {
report(n, "page must start with a level-1 heading (its title)")
}
}
}
switch n := n.(type) {
case *ast.Heading:
if n.Level == 1 {
h1s++
if h1s == 2 {
report(n, "page has more than one level-1 heading")
}
}
case *ast.Link:
problems = append(problems, checkLink(rel, string(n.Destination), false, n, pages)...)
case *ast.Image:
problems = append(problems, checkLink(rel, string(n.Destination), true, n, pages)...)
case *ast.HTMLBlock, *ast.RawHTML:
if raw := rawHTMLText(n, p.src); !isHTMLComment(raw) {
report(n, "raw HTML is not allowed: %s", firstLine(raw))
}
case *ast.Paragraph:
if t := nodeText(n, p.src); strings.HasPrefix(t, ":::") {
report(n, "MyST directive syntax is not allowed; use GitHub alerts (> [!NOTE]) instead")
}
case *ast.FencedCodeBlock:
if info := fenceInfo(n, p.src); strings.HasPrefix(info, "{") {
report(n, "MyST fenced directive %q is not allowed; use GitHub alerts (> [!NOTE]) instead", info)
}
}
return ast.WalkContinue, nil
})
return problems
}
func checkLink(rel, dest string, isImage bool, n ast.Node, pages map[string]*page) []Problem {
p := pages[rel]
problem := func(format string, args ...any) []Problem {
return []Problem{{
File: rel,
Line: nodeLine(n, p.src),
Message: fmt.Sprintf(format, args...),
}}
}
switch {
case dest == "":
return problem("empty link destination")
case strings.HasPrefix(dest, "http://"), strings.HasPrefix(dest, "https://"), strings.HasPrefix(dest, "mailto:"):
return nil
case strings.HasPrefix(dest, "#"):
if !p.anchors[strings.TrimPrefix(dest, "#")] {
return problem("anchor %s not found in this page", dest)
}
return nil
case strings.HasPrefix(dest, "/"):
return problem("absolute link %q; use a path relative to this file", dest)
case strings.Contains(dest, "://"):
return problem("unsupported link scheme in %q", dest)
}
pathPart, frag, _ := strings.Cut(dest, "#")
target := path.Join(path.Dir(rel), pathPart)
if target == ".." || strings.HasPrefix(target, "../") {
return problem("link %q points outside docs/", dest)
}
tp, ok := pages[target]
if !ok {
if isImage || !strings.HasSuffix(target, ".md") {
// Non-page assets (images, other files) just need to exist on
// disk; they were not parsed in the first pass.
return problem("link target %q does not exist", target)
}
return problem("link target %q does not exist", target)
}
if frag != "" && !tp.anchors[frag] {
return problem("anchor #%s not found in %s", frag, target)
}
return nil
}
func lintTOC(root string, files []string) []Problem {
const tocName = "toc.yaml"
problem := func(format string, args ...any) Problem {
return Problem{File: tocName, Line: 1, Message: fmt.Sprintf(format, args...)}
}
data, err := os.ReadFile(filepath.Join(root, tocName))
if err != nil {
return []Problem{problem("%v", err)}
}
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var toc tocFile
if err := dec.Decode(&toc); err != nil {
return []Problem{problem("%v", err)}
}
var problems []Problem
listed := make(map[string]int)
add := func(entry string) {
listed[entry]++
}
add(toc.Index)
for _, s := range toc.Sections {
for _, pg := range s.Pages {
add(pg)
}
}
for _, pg := range toc.Unlisted {
add(pg)
}
onDisk := make(map[string]bool, len(files))
for _, f := range files {
onDisk[f] = true
}
for entry, count := range listed {
if count > 1 {
problems = append(problems, problem("%s is listed %d times", entry, count))
}
if !onDisk[entry] {
problems = append(problems, problem("%s is listed but does not exist", entry))
}
}
for _, f := range files {
if listed[f] == 0 {
problems = append(problems, problem("%s is not listed; add it to a section or to unlisted", f))
}
}
return problems
}
// headingAnchors returns the set of GitHub-style anchor slugs for the
// headings in doc, applying GitHub's -1, -2 suffixes for duplicates.
func headingAnchors(doc ast.Node, src []byte) map[string]bool {
anchors := make(map[string]bool)
seen := make(map[string]int)
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if h, ok := n.(*ast.Heading); ok && entering {
slug := slugify(nodeText(h, src))
if c := seen[slug]; c > 0 {
anchors[fmt.Sprintf("%s-%d", slug, c)] = true
} else {
anchors[slug] = true
}
seen[slug]++
}
return ast.WalkContinue, nil
})
return anchors
}
// slugify converts heading text to a GitHub-style anchor: lowercase, spaces
// become hyphens, and everything except letters, digits, hyphens and
// underscores is dropped.
func slugify(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-':
b.WriteRune(r)
case r == ' ':
b.WriteByte('-')
}
}
return b.String()
}
func nodeText(n ast.Node, src []byte) string {
var b strings.Builder
ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
switch c := c.(type) {
case *ast.Text:
b.Write(c.Segment.Value(src))
case *ast.String:
b.Write(c.Value)
}
}
return ast.WalkContinue, nil
})
return b.String()
}
func rawHTMLText(n ast.Node, src []byte) string {
var b strings.Builder
switch n := n.(type) {
case *ast.HTMLBlock:
for i := 0; i < n.Lines().Len(); i++ {
seg := n.Lines().At(i)
b.Write(seg.Value(src))
}
if n.HasClosure() {
b.Write(n.ClosureLine.Value(src))
}
case *ast.RawHTML:
for i := 0; i < n.Segments.Len(); i++ {
seg := n.Segments.At(i)
b.Write(seg.Value(src))
}
}
return strings.TrimSpace(b.String())
}
func isHTMLComment(raw string) bool {
return strings.HasPrefix(raw, "<!--") && strings.HasSuffix(raw, "-->")
}
func fenceInfo(n *ast.FencedCodeBlock, src []byte) string {
if n.Info == nil {
return ""
}
return strings.TrimSpace(string(n.Info.Segment.Value(src)))
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// nodeLine returns the 1-based line number of n, using the nearest
// enclosing block that carries source positions.
func nodeLine(n ast.Node, src []byte) int {
if rh, ok := n.(*ast.RawHTML); ok && rh.Segments.Len() > 0 {
return 1 + bytes.Count(src[:rh.Segments.At(0).Start], []byte("\n"))
}
for cur := n; cur != nil; cur = cur.Parent() {
if cur.Type() == ast.TypeBlock && cur.Lines().Len() > 0 {
return 1 + bytes.Count(src[:cur.Lines().At(0).Start], []byte("\n"))
}
}
return 1
}