From fd1f6731ab569c2dd72e7b10e759b7dce4cda805 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 20:35:14 +0000 Subject: [PATCH] Add a Go linter for the docs/ content contract internal/docs validates the documentation source tree with goldmark and runs from go test, so doc-breaking changes fail CI without any JavaScript toolchain. It enforces the contract the external site consumer relies on: every page parses as GFM and starts with exactly one level-1 heading; relative links and images resolve to files inside docs/, with anchor fragments checked against GitHub-style heading slugs; every page appears exactly once in toc.yaml and every toc.yaml entry names a real page; no raw HTML other than comments; and no leftover MyST directives. Fix everything the linter found in the existing docs: convert the eight remaining MyST admonitions to GitHub alert syntax, add the missing level-1 heading to the embedding guide, point extensionless and .html links (which only resolved under Sphinx) at the actual .md files, and correct anchors that did not match GitHub slugging (#sqlcdb-prepare, #rules-using-explain--output, #the-go_type-map, and stale #with-other-tools fragments in the changelog). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013XDcHxZk9UzfzMLayG66Zq --- docs/howto/ci-cd.md | 12 +- docs/howto/embedding.md | 2 +- docs/howto/managed-databases.md | 4 +- docs/howto/named_parameters.md | 5 +- docs/howto/overrides.md | 30 ++- docs/howto/push.md | 5 +- docs/howto/vet.md | 4 +- docs/reference/changelog.md | 8 +- docs/reference/config.md | 4 +- docs/reference/macros.md | 6 +- go.mod | 1 + go.sum | 2 + internal/docs/lint.go | 381 ++++++++++++++++++++++++++++++++ internal/docs/lint_test.go | 116 ++++++++++ 14 files changed, 536 insertions(+), 44 deletions(-) create mode 100644 internal/docs/lint.go create mode 100644 internal/docs/lint_test.go diff --git a/docs/howto/ci-cd.md b/docs/howto/ci-cd.md index 174c91584b..8dc81c685b 100644 --- a/docs/howto/ci-cd.md +++ b/docs/howto/ci-cd.md @@ -38,7 +38,7 @@ database schema. Learn more about uploading projects [here](push.md) ## General setup -Install `sqlc` using the [suggested instructions](../overview/install). +Install `sqlc` using the [suggested instructions](../overview/install.md). Create three steps in your pipeline for `sqlc diff`, `sqlc vet`, and `sqlc verify`. Run `sqlc push` after merge on your `main` branch. @@ -98,9 +98,8 @@ jobs: ### push -```{note} -Pushing a project is powered by [sqlc Cloud](https://dashboard.sqlc.dev). Sign up for [free](https://dashboard.sqlc.dev) today. -``` +> [!NOTE] +> Pushing a project is powered by [sqlc Cloud](https://dashboard.sqlc.dev). Sign up for [free](https://dashboard.sqlc.dev) today. The following GitHub Workflow configuration runs [sqlc push](push.md) on every push to `main`. Create an auth token via the @@ -125,9 +124,8 @@ jobs: ### verify -```{note} -Verify database migrations is powered by [sqlc Cloud](https://dashboard.sqlc.dev). Sign up for [free](https://dashboard.sqlc.dev) today. -``` +> [!NOTE] +> Verify database migrations is powered by [sqlc Cloud](https://dashboard.sqlc.dev). Sign up for [free](https://dashboard.sqlc.dev) today. ```yaml name: sqlc diff --git a/docs/howto/embedding.md b/docs/howto/embedding.md index 5ea8cbbc0a..d9e95ba392 100644 --- a/docs/howto/embedding.md +++ b/docs/howto/embedding.md @@ -1,4 +1,4 @@ -#### Embedding structs +# Embedding structs Embedding allows you to reuse existing model structs in more queries, resulting in less manual serialization work. First, imagine we have the following schema diff --git a/docs/howto/managed-databases.md b/docs/howto/managed-databases.md index 745524ea5f..6b208164a2 100644 --- a/docs/howto/managed-databases.md +++ b/docs/howto/managed-databases.md @@ -79,9 +79,9 @@ sql: With managed databases configured, `sqlc vet` will automatically create a hosted ephemeral database with your schema and use that database when running lint rules that require a -database connection, e.g. any [rule relying on `EXPLAIN ...` output](vet.md#rules-using-explain-output). +database connection, e.g. any [rule relying on `EXPLAIN ...` output](vet.md#rules-using-explain--output). -If you don't yet have any vet rules, the [built-in sqlc/db-prepare rule](vet.md#sqlc-db-prepare) +If you don't yet have any vet rules, the [built-in sqlc/db-prepare rule](vet.md#sqlcdb-prepare) is a good place to start. It prepares each of your queries against the database to ensure the query is valid. Here's a minimal working configuration: diff --git a/docs/howto/named_parameters.md b/docs/howto/named_parameters.md index 0837c70c15..b09b969429 100644 --- a/docs/howto/named_parameters.md +++ b/docs/howto/named_parameters.md @@ -46,9 +46,8 @@ type UpdateAuthorNameParams struct { If the `sqlc.arg()` syntax is too verbose for your taste, you can use the `@` operator as a shortcut. -```{note} -The `@` operator as a shortcut for `sqlc.arg()` is not supported in MySQL. -``` +> [!NOTE] +> The `@` operator as a shortcut for `sqlc.arg()` is not supported in MySQL. ```sql -- name: UpsertAuthorName :one diff --git a/docs/howto/overrides.md b/docs/howto/overrides.md index 8cdcbde6a2..b525994bbf 100644 --- a/docs/howto/overrides.md +++ b/docs/howto/overrides.md @@ -1,8 +1,7 @@ # Overriding types -:::{note} -Type overrides and field renaming are only fully-supported for Go. -::: +> [!NOTE] +> Type overrides and field renaming are only fully-supported for Go. In many cases it's useful to tell `sqlc` explicitly what Go type you want it to use for a query input or output. For instance, by default when you use @@ -39,11 +38,10 @@ sql: go_type: "time.Time" ``` -:::{tip} - A single `db_type` override configuration applies to either nullable or non-nullable - columns, but not both. If you want the same Go type to override regardless of - nullability, you'll need to configure two overrides: one with `nullable: true` and one without. -::: +> [!TIP] +> A single `db_type` override configuration applies to either nullable or non-nullable +> columns, but not both. If you want the same Go type to override regardless of +> nullability, you'll need to configure two overrides: one with `nullable: true` and one without. ## The `overrides` list @@ -54,7 +52,7 @@ Each element in the `overrides` list has the following keys: - `column`: - A column name to override. The value should be of the form `table.column` but you can also specify `schema.table.column` or `catalog.schema.table.column`. `column` and `db_type` are mutually exclusive. - `go_type`: - - The fully-qualified name of a Go type to use in generated code. This is usually a string but can also be [a map](#the-go-type-map) for more complex configurations. + - The fully-qualified name of a Go type to use in generated code. This is usually a string but can also be [a map](#the-go_type-map) for more complex configurations. - `go_struct_tag`: - A reflect-style struct tag to use in generated code, e.g. `a:"b" x:"y,z"`. If you want `json` or `db` tags for all fields, configure `emit_json_tags` or `emit_db_tags` instead. @@ -68,15 +66,13 @@ Each element in the `overrides` list has the following keys: Note that this only applies to `db_type` overrides and has no effect on `column` overrides. Defaults to `false`. -:::{tip} - A single `db_type` override configuration applies to either nullable or non-nullable - columns, but not both. If you want the same Go type to override regardless of nullability, you'll - need to configure two overrides: one with `nullable: true` and one without. -::: +> [!TIP] +> A single `db_type` override configuration applies to either nullable or non-nullable +> columns, but not both. If you want the same Go type to override regardless of nullability, you'll +> need to configure two overrides: one with `nullable: true` and one without. -:::{note} -When generating code, `column` override configurations take precedence over `db_type` configurations. -::: +> [!NOTE] +> When generating code, `column` override configurations take precedence over `db_type` configurations. ### The `go_type` map diff --git a/docs/howto/push.md b/docs/howto/push.md index 337e127513..0da4e074c6 100644 --- a/docs/howto/push.md +++ b/docs/howto/push.md @@ -1,8 +1,7 @@ # `push` - Uploading projects -```{note} -`push` is powered by [sqlc Cloud](https://dashboard.sqlc.dev). Sign up for [free](https://dashboard.sqlc.dev) today. -``` +> [!NOTE] +> `push` is powered by [sqlc Cloud](https://dashboard.sqlc.dev). Sign up for [free](https://dashboard.sqlc.dev) today. *Added in v1.24.0* diff --git a/docs/howto/vet.md b/docs/howto/vet.md index 3f6c6a025b..1bcb089c58 100644 --- a/docs/howto/vet.md +++ b/docs/howto/vet.md @@ -4,7 +4,7 @@ `sqlc vet` runs queries through a set of lint rules. -Rules are defined in the `sqlc` [configuration](../reference/config) file. They +Rules are defined in the `sqlc` [configuration](../reference/config.md) file. They consist of a name, message, and a [Common Expression Language (CEL)](https://github.com/google/cel-spec) expression. Expressions are evaluated using [cel-go](https://github.com/google/cel-go). If an expression evaluates to @@ -48,7 +48,7 @@ each CEL expression has access to the output from running `EXPLAIN ...` on your via the `postgresql.explain` and `mysql.explain` variables. This output is quite complex and depends on the structure of your query but sqlc attempts to parse and provide as much information as it can. See -[Rules using `EXPLAIN ...` output](#rules-using-explain-output) for more information. +[Rules using `EXPLAIN ...` output](#rules-using-explain--output) for more information. Here are a few example rules just using the basic configuration and query information available to the CEL expression environment. While these examples are simplistic, they give you a flavor diff --git a/docs/reference/changelog.md b/docs/reference/changelog.md index fcd7f3f0b0..66016c4841 100644 --- a/docs/reference/changelog.md +++ b/docs/reference/changelog.md @@ -548,7 +548,7 @@ Like upload, `push` should be run when you tag a release of your application. We #### MySQL support in `createdb` -The `createdb` command, added in the last release, now supports MySQL. If you have a cloud project configured, you can use `sqlc createdb` to spin up a new ephemeral database with your schema and print its connection string to standard output. This is useful for integrating with other tools. Read more in the [managed databases](../howto/managed-databases.md#with-other-tools) documentation. +The `createdb` command, added in the last release, now supports MySQL. If you have a cloud project configured, you can use `sqlc createdb` to spin up a new ephemeral database with your schema and print its connection string to standard output. This is useful for integrating with other tools. Read more in the [managed databases](../howto/managed-databases.md) documentation. #### Plugin interface refactor @@ -628,7 +628,7 @@ When you have a cloud project configured, you can use the new `sqlc createdb` command to spin up a new ephemeral database with your schema and print its connection string to standard output. This is useful for integrating with other tools. Read more in the [managed -databases](../howto/managed-databases.md#with-other-tools) documentation. +databases](../howto/managed-databases.md) documentation. #### Support for pgvector @@ -833,7 +833,7 @@ full list. #### Plugin access to environment variables -If you're authoring a [sqlc plugin](../guides/plugins.html), you can now configure +If you're authoring a [sqlc plugin](../guides/plugins.md), you can now configure sqlc to pass your plugin the values of specific environment variables. For example, if your plugin @@ -1254,7 +1254,7 @@ rules: database server. We'll expand this functionality over time, but for now it powers the `sqlc/db-prepare` built-in rule. -When a [database](config.html#database) is configured, the +When a [database](config.md#database) is configured, the `sqlc/db-preapre` rule will attempt to prepare each of your queries against the connected database and report any failures. diff --git a/docs/reference/config.md b/docs/reference/config.md index c88d41d963..15fdb7cfd4 100644 --- a/docs/reference/config.md +++ b/docs/reference/config.md @@ -204,7 +204,7 @@ See [Overriding types](../howto/overrides.md) for an in-depth guide to using typ #### kotlin -> Removed in v1.17.0 and replaced by the [sqlc-gen-kotlin](https://github.com/sqlc-dev/sqlc-gen-kotlin) plugin. Follow the [migration guide](../guides/migrating-to-sqlc-gen-kotlin) to switch. +> Removed in v1.17.0 and replaced by the [sqlc-gen-kotlin](https://github.com/sqlc-dev/sqlc-gen-kotlin) plugin. Follow the [migration guide](../guides/migrating-to-sqlc-gen-kotlin.md) to switch. - `package`: - The package name to use for the generated code. @@ -215,7 +215,7 @@ See [Overriding types](../howto/overrides.md) for an in-depth guide to using typ #### python -> Removed in v1.17.0 and replaced by the [sqlc-gen-python](https://github.com/sqlc-dev/sqlc-gen-python) plugin. Follow the [migration guide](../guides/migrating-to-sqlc-gen-python) to switch. +> Removed in v1.17.0 and replaced by the [sqlc-gen-python](https://github.com/sqlc-dev/sqlc-gen-python) plugin. Follow the [migration guide](../guides/migrating-to-sqlc-gen-python.md) to switch. - `package`: - The package name to use for the generated code. diff --git a/docs/reference/macros.md b/docs/reference/macros.md index 36c7c92420..54d44ed5c9 100644 --- a/docs/reference/macros.md +++ b/docs/reference/macros.md @@ -20,7 +20,7 @@ FROM authors WHERE lower(name) = ?; ``` -See more examples in [Naming parameters](../howto/named_parameters). +See more examples in [Naming parameters](../howto/named_parameters.md). ## `sqlc.embed` @@ -72,7 +72,7 @@ func (q *Queries) GetStudentAndScore(ctx context.Context, id int64) (GetStudentA } ``` -See a full example in [Embedding structs](../howto/embedding). +See a full example in [Embedding structs](../howto/embedding.md). ## `sqlc.narg` @@ -92,7 +92,7 @@ FROM authors WHERE LOWER(name) = ?; ``` -See more examples in [Naming parameters](../howto/named_parameters). +See more examples in [Naming parameters](../howto/named_parameters.md). ## `sqlc.slice` diff --git a/go.mod b/go.mod index 6ba2e207a9..06691e59da 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/sqlc-dev/zetajones v0.1.0 github.com/tetratelabs/wazero v1.12.0 github.com/xeipuuv/gojsonschema v1.2.0 + github.com/yuin/goldmark v1.8.5 golang.org/x/sync v0.22.0 google.golang.org/grpc v1.83.1 google.golang.org/protobuf v1.36.12 diff --git a/go.sum b/go.sum index 67be1abc66..104d4cb9b3 100644 --- a/go.sum +++ b/go.sum @@ -90,6 +90,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= +github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= diff --git a/internal/docs/lint.go b/internal/docs/lint.go new file mode 100644 index 0000000000..58edb91ed0 --- /dev/null +++ b/internal/docs/lint.go @@ -0,0 +1,381 @@ +// 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, "") +} + +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 +} diff --git a/internal/docs/lint_test.go b/internal/docs/lint_test.go new file mode 100644 index 0000000000..dbfa9a55ef --- /dev/null +++ b/internal/docs/lint_test.go @@ -0,0 +1,116 @@ +package docs + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestDocs validates the repository's docs/ directory against the content +// contract. This is a unit test rather than an endtoend case because the +// contract is a property of the documentation source tree, not of anything +// the sqlc CLI does: there is no command whose output could pin it down. +func TestDocs(t *testing.T) { + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("could not locate source file") + } + root := filepath.Join(filepath.Dir(thisFile), "..", "..", "docs") + + problems, err := Lint(root) + if err != nil { + t.Fatal(err) + } + for _, p := range problems { + t.Errorf("%s", p) + } +} + +// TestLintViolations pins down what the linter rejects, using a synthetic +// docs tree that violates each rule of the contract once. +func TestLintViolations(t *testing.T) { + root := t.TempDir() + write := func(name, content string) { + t.Helper() + p := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + write("toc.yaml", ` +index: index.md +sections: + - title: Guides + pages: + - sub/page.md + - sub/page.md + - ghost.md +`) + write("index.md", `# Index + +A [broken link](missing.md) and a [bad anchor](sub/page.md#nope) and +a [bad self anchor](#nowhere) and an [absolute link](/sub/page.md) and +an [escaping link](../outside.md). + +
raw html
+ + + +:::{note} +myst directive +::: + +`+"```{warning}"+` +myst fence +`+"```"+` + +# Second title +`) + write("sub/page.md", "Not a heading first.\n\n# Title\n") + write("orphan.md", "# Orphan\n") + + problems, err := Lint(root) + if err != nil { + t.Fatal(err) + } + want := []string{ + `index.md:3: link target "missing.md" does not exist`, + "index.md:3: anchor #nope not found in sub/page.md", + "index.md:3: anchor #nowhere not found in this page", + `index.md:3: absolute link "/sub/page.md"`, + `index.md:3: link "../outside.md" points outside docs/`, + "index.md:7: raw HTML is not allowed:
raw html
", + "index.md:11: MyST directive syntax is not allowed", + `index.md:16: MyST fenced directive "{warning}" is not allowed`, + "index.md:19: page has more than one level-1 heading", + "sub/page.md:1: page must start with a level-1 heading", + "toc.yaml:1: sub/page.md is listed 2 times", + "toc.yaml:1: ghost.md is listed but does not exist", + "toc.yaml:1: orphan.md is not listed", + } + var got []string + for _, p := range problems { + got = append(got, p.String()) + } + for _, w := range want { + found := false + for _, g := range got { + if strings.Contains(g, w) { + found = true + break + } + } + if !found { + t.Errorf("expected a problem containing %q, got:\n %s", w, strings.Join(got, "\n ")) + } + } + if len(got) != len(want) { + t.Errorf("got %d problems, want %d:\n %s", len(got), len(want), strings.Join(got, "\n ")) + } +}