docs: lead with env vars in admin docs and add configuration reference (#26824)

## What & why

Admin/setup docs lead with `coder server --flag` examples, but most
operators configure Coder through `CODER_*` environment variables
(system service, container, or Helm chart). There is no single page
mapping a setting to its env var, CLI flag, YAML key, and default, so
searching the docs for an env var name such as `CODER_PG_CONNECTION_URL`
returns nothing.

This adds a generated configuration reference and begins shifting admin
docs to lead with the environment-variable form.

## Changes

- **Generated configuration reference**
(`docs/admin/setup/configuration-reference.md`): a searchable,
per-setting list of every visible deployment option. Each option is a
heading (grouped and nested by serpent group) followed by its
description and the environment variable, CLI flag, YAML key, and
default that apply to it. Generated from `codersdk.DeploymentValues` so
it stays in sync.
- **Generator + `make gen` wiring** (`scripts/configdocgen/`): new
binary plus a Makefile target and `GEN_FILES` entry, mirroring the
existing `clidocgen` / `auditdocgen` pattern. Output is host-independent
(same env normalization as `clidocgen`).
- **Demo conversion** (`docs/admin/users/github-auth.md`): inverted to
lead with the `/etc/coder.d/coder.env` env-var form; the CLI-flag form
becomes a closing note that links to the reference. H2 slugs preserved.
- **Style guide** (`.claude/docs/DOCS_STYLE_GUIDE.md`): documents the
env-var-first convention for admin/setup docs.
- **Navigation**: manifest entry under Administration → Setup, plus a
TIP callout on the setup index.

## Risk

Docs + gen pipeline only; no runtime change. The page is regenerated by
`make gen`; the `gen` and `check-docs` CI checks pass.

## Follow-up

Several other admin pages still lead with flag walls. Recommend sweeping
them incrementally in separate PRs rather than expanding scope here.

<details>
<summary>Implementation notes (provenance, conflict resolution,
verification)</summary>

- Continues prior work by @aslilac and @bpmct from the
`kayla/docs-env-vars-first` branch. Both original commits are
cherry-picked here with authorship preserved.
- Rebased onto current `main`. Resolved two `Makefile` conflicts where
`main` had since added the `feature-stages.md` gen target at the same
locations; kept both targets (union) in `GEN_FILES`, `gen/mark-fresh`,
and the recipe block.
- The original branch's checked-in page predated recent
`codersdk.DeploymentValues` changes, so it was **regenerated** against
current `main` (adds `CODER_SCIM_USE_LEGACY`, the `Networking / Cluster`
section with `CODER_CLUSTER_HOST`, `CODER_BOUNDARY_LOG_RETENTION`, and
the AI Gateway description rename). The `gen` CI check enforces this
stays current.
- Fixed flag-link anchors for short-form flags (`--config`,
`--log-filter`): the generator derives the anchor from `FlagShorthand`
to match `clidocgen`'s heading (e.g. `#-l---log-filter`).
- `linkspector` ignores the AWS Bedrock base URL that appears as an
illustrative `<region>` placeholder in an option description, consistent
with the existing `openai.com` ignore patterns.

</details>

<details>
<summary>Configuration reference layout (2026-07-08 update)</summary>

Reworked the reference from a wide table into a nested, per-setting list
so it fits without horizontal scrolling and stops repeating the group
name in every heading:

- **List, not table.** Each option renders as a heading, its
description, and a bullet list of only the configuration methods that
apply to it (non-applicable methods are omitted instead of shown as
`-`).
- **Nested sections.** Sections nest by the serpent group hierarchy, so
`Email / Email Authentication` becomes `Email` (h2) with an `Email
authentication` (h3) subsection instead of a redundant flat title.
- **Shorter, sentence-case headings.** The redundant group prefix is
stripped from each option name and the remainder is lowercased to
sentence case, preserving acronyms and mixed-case tokens (`URL`, `TLS`,
`OAuth2`, `GitHub`) plus a small proper-noun allowlist (`Coder`,
`Terraform`, `Honeycomb`, `Anthropic`, `Bedrock`, ...). Example: `AI
Gateway Send Actor Headers` becomes `Send actor headers`.
- **Deprecated options** sort to the end of each section and lead with
an emphasized **Deprecated** marker. Headings stay clean (no
`(deprecated)` suffix) so their anchors remain stable.
- **Section intros** render from a group's `Description` when the source
defines one (e.g. DERP); no hand-maintained prose or links are
introduced.

All transformations run in pure Go at `make gen` time (no AI at
generation time). Generation is idempotent, and `markdownlint` and
`golangci-lint` both pass.

</details>

---

🤖 Opened by Coder Agents on behalf of @nickvigilante. Continues work by
@aslilac and @bpmct.

---------

Co-authored-by: Kayla (via Coder Agents) <kayla@coder.com>
Co-authored-by: Coder Agents <noreply@coder.com>
Co-authored-by: Ben Potter <me@bpmct.net>
This commit is contained in:
Nick Vigilante
2026-08-03 14:33:01 -04:00
committed by GitHub
co-authored by Kayla Coder Agents Ben Potter
parent ee7e7ecb74
commit ba4779fc87
9 changed files with 2705 additions and 32 deletions
+509
View File
@@ -0,0 +1,509 @@
// Command configdocgen generates the Coder server configuration reference at
// docs/admin/setup/configuration-reference.md from codersdk.DeploymentValues.
// It lists every visible deployment option grouped by serpent group. Each
// option is rendered as a heading with its description followed by the
// environment variable, CLI flag, YAML key, and default that apply to it.
// Because the source is DeploymentValues, the page stays in sync as options
// change.
package main
import (
"cmp"
"flag"
"fmt"
"os"
"slices"
"strings"
"unicode"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/scripts/atomicwrite"
"github.com/coder/flog"
"github.com/coder/serpent"
)
const header = `<!-- DO NOT EDIT | GENERATED CONTENT -->
# Configuration reference
Coder server is configured primarily through environment variables. This page
lists every option so you can search by environment variable name, CLI flag, or
YAML key. For first-time setup guidance and worked examples, see
[Configure Control Plane Access](./index.md).
Each option can be set through one or more of the methods below. An option lists
only the methods that apply to it.
- An environment variable (recommended for production deployments running as a
system service, container, or Helm chart).
- A CLI flag passed to ` + "`coder server`" + ` (useful for one-off invocations
and local development).
- A key in a YAML configuration file passed with ` + "`--config`" + `.
For a full description of each option's accepted values and behavior, follow the
flag link into the [` + "`coder server`" + ` CLI reference](../../reference/cli/server.md).
Deprecated options are listed at the end of each section.
`
// generalSection holds options that do not belong to a serpent group.
const generalSection = "General"
// option is the normalized data needed to render one deployment option.
type option struct {
title string // short, sentence-case heading text
env string
flagName string
flagAnchor string
yaml string
defValue string
desc string
deprecated bool
sortKey string // original serpent name, for stable ordering
}
// node is one section of the reference: a serpent group (or the synthetic
// "General" group) with its direct options and any child sections.
type node struct {
name string // raw group name (leaf); sentence-cased at render time
intro string // group description, if any
options []option
children []*node
childIdx map[string]*node
}
func newNode(name string) *node {
return &node{name: name, childIdx: map[string]*node{}}
}
// child returns the named child section, creating it on first use.
func (n *node) child(name string) *node {
if c, ok := n.childIdx[name]; ok {
return c
}
c := newNode(name)
n.childIdx[name] = c
n.children = append(n.children, c)
return c
}
// prepareEnv mirrors scripts/clidocgen so the generated defaults do not
// depend on the generating host. Without it, defaults derived from
// os.UserCacheDir and the config dir embed the local home directory.
func prepareEnv() {
for _, env := range os.Environ() {
if strings.HasPrefix(env, "CODER_") {
name, _, _ := strings.Cut(env, "=")
if err := os.Unsetenv(name); err != nil {
panic(err)
}
}
}
err := os.Setenv("CLIDOCGEN_CACHE_DIRECTORY", "~/.cache")
if err != nil {
panic(err)
}
err = os.Setenv("CLIDOCGEN_CONFIG_DIRECTORY", "~/.config/coderv2")
if err != nil {
panic(err)
}
err = os.Setenv("TMPDIR", "/tmp")
if err != nil {
panic(err)
}
}
func main() {
prepareEnv()
out := flag.String("out", "docs/admin/setup/configuration-reference.md", "path to write the generated reference page")
flag.Parse()
var vals codersdk.DeploymentValues
opts := vals.Options()
root := buildTree(opts)
body := render(root)
content := header + body
content = strings.TrimRight(content, "\n") + "\n"
if err := atomicwrite.File(*out, []byte(content)); err != nil {
flog.Fatalf("write %s: %v", *out, err)
}
flog.Successf("wrote %s", *out)
}
// buildTree groups options into a section tree, skipping hidden options and
// options that have no environment variable, flag, or YAML key (those cannot
// be set by an operator).
func buildTree(opts serpent.OptionSet) *node {
root := newNode("")
for _, opt := range opts {
if opt.Hidden {
continue
}
if opt.Env == "" && opt.Flag == "" && opt.YAML == "" {
continue
}
sec := sectionFor(root, opt.Group)
sec.options = append(sec.options, toOption(opt))
}
sortTree(root)
return root
}
// sectionFor returns the section node for an option's group, creating the
// chain of ancestor sections as needed. Options with no group (or an unnamed
// group) live in the General section.
func sectionFor(root *node, g *serpent.Group) *node {
if g == nil {
return root.child(generalSection)
}
cur := root
for _, ancestor := range g.Ancestry() {
if ancestor.Name == "" {
return root.child(generalSection)
}
cur = cur.child(ancestor.Name)
if cur.intro == "" {
cur.intro = collapse(ancestor.Description)
}
}
return cur
}
func toOption(opt serpent.Option) option {
var flagName, flagAnchor string
if opt.Flag != "" {
flagName = "--" + opt.Flag
// clidocgen renders a flag heading as "### -s, --flag" when it has a
// shorthand and "### --flag" otherwise, so the anchor must include the
// shorthand to match.
flagAnchor = "--" + opt.Flag
if opt.FlagShorthand != "" {
flagAnchor = "-" + opt.FlagShorthand + "---" + opt.Flag
}
}
def := opt.Default
if def == "" && opt.DefaultFn != nil {
// DefaultFn results depend on the host environment, so evaluating them
// here would leak host-specific values. Send the reader to the CLI
// reference for the resolved default instead.
def = "(computed at runtime)"
}
return option{
title: shortTitle(opt),
env: opt.Env,
flagName: flagName,
flagAnchor: flagAnchor,
yaml: opt.YAMLPath(),
defValue: def,
desc: collapse(opt.Description),
deprecated: isDeprecated(opt),
sortKey: opt.Name,
}
}
// isDeprecated reports whether an option is deprecated. serpent tracks
// replacements in UseInstead, and codersdk also marks some options by leading
// the description with "Deprecated".
func isDeprecated(opt serpent.Option) bool {
if len(opt.UseInstead) > 0 {
return true
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(opt.Description)), "deprecated")
}
// sortTree orders sections and their options. General sorts first and
// Dangerous last among top-level sections; every other section is
// alphabetical. Within a section, active options come before deprecated ones,
// each alphabetical by their original name.
func sortTree(n *node) {
slices.SortStableFunc(n.children, func(a, b *node) int {
if c := cmp.Compare(sectionRank(a.name), sectionRank(b.name)); c != 0 {
return c
}
return strings.Compare(a.name, b.name)
})
for _, c := range n.children {
slices.SortStableFunc(c.options, func(a, b option) int {
if a.deprecated != b.deprecated {
if a.deprecated {
return 1
}
return -1
}
return strings.Compare(a.sortKey, b.sortKey)
})
sortTree(c)
}
}
// sectionRank orders top-level sections: General first, Dangerous last
// (regardless of its emoji prefix), everything else alphabetical.
func sectionRank(name string) int {
switch {
case name == generalSection:
return -1
case strings.HasSuffix(name, "Dangerous"):
return 1
default:
return 0
}
}
func render(root *node) string {
var b strings.Builder
for _, sec := range root.children {
renderNode(&b, sec, 2)
}
return b.String()
}
func renderNode(b *strings.Builder, n *node, level int) {
_, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), sentenceCase(n.name))
if n.intro != "" {
_, _ = b.WriteString(n.intro)
_, _ = b.WriteString("\n\n")
}
for _, opt := range n.options {
renderOption(b, opt, level+1)
}
for _, c := range n.children {
renderNode(b, c, level+1)
}
}
func renderOption(b *strings.Builder, opt option, level int) {
_, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), opt.title)
desc := opt.desc
if opt.deprecated {
desc = emphasizeDeprecation(desc)
}
if desc != "" {
_, _ = b.WriteString(desc)
_, _ = b.WriteString("\n\n")
}
if opt.env != "" {
_, _ = fmt.Fprintf(b, "- Environment variable: `%s`\n", opt.env)
}
if opt.flagName != "" {
_, _ = fmt.Fprintf(b, "- CLI flag: [`%s`](../../reference/cli/server.md#%s)\n", opt.flagName, opt.flagAnchor)
}
if opt.yaml != "" {
_, _ = fmt.Fprintf(b, "- YAML key: `%s`\n", opt.yaml)
}
if opt.defValue != "" {
_, _ = fmt.Fprintf(b, "- Default value: `%s`\n", opt.defValue)
}
_, _ = b.WriteString("\n")
}
// emphasizeDeprecation bolds the leading "Deprecated" marker in a description
// so a deprecated option reads clearly. Trailing text is left unbolded so the
// paragraph is not a lone emphasis span (markdownlint MD036).
func emphasizeDeprecation(desc string) string {
const marker = "Deprecated"
if len(desc) >= len(marker) && strings.EqualFold(desc[:len(marker)], marker) {
if strings.TrimSpace(desc[len(marker):]) != "" {
return "**" + desc[:len(marker)] + "**" + desc[len(marker):]
}
return desc
}
if desc == "" {
return "Deprecated."
}
return "**Deprecated.** " + desc
}
// shortTitle strips the redundant group prefix from an option name and returns
// it in sentence case, e.g. "AI Gateway Send Actor Headers" becomes
// "Send actor headers".
func shortTitle(opt serpent.Option) string {
name := opt.Name
if opt.Group != nil {
name = stripGroupPrefix(name, opt.Group)
}
return sentenceCase(name)
}
// stripGroupPrefix removes the group name that many option names repeat. For
// space-prefixed names like "AI Gateway Send Actor Headers" it drops the
// longest matching ancestor chain ("AI Gateway"). For colon-prefixed names
// like "Notifications: Email TLS: StartTLS" it drops every segment up to the
// last ": " once the leading segment belongs to the top-level group. Names
// that do not repeat the group are returned unchanged.
func stripGroupPrefix(name string, g *serpent.Group) string {
ancestry := g.Ancestry()
if len(ancestry) == 0 {
return name
}
names := make([]string, len(ancestry))
for i, a := range ancestry {
names[i] = a.Name
}
if before, _, ok := strings.Cut(name, ": "); ok {
// Only treat the colon as a group separator when the leading segment
// belongs to the top-level group. This avoids mangling meaningful
// colons such as "Health Check Threshold: Database".
if top := normalize(names[0]); top != "" && strings.HasPrefix(normalize(before), top) {
if idx := strings.LastIndex(name, ": "); idx >= 0 {
if rest := strings.TrimSpace(name[idx+len(": "):]); rest != "" {
return rest
}
}
}
}
// Try the longest ancestor suffix chain first (start == 0 is the full
// path) so the most specific prefix wins.
for start := range names {
prefix := strings.Join(names[start:], " ") + " "
if rest, ok := cutFold(name, prefix); ok {
if rest = strings.TrimSpace(rest); rest != "" {
return rest
}
}
}
return name
}
// properNouns lists words that keep their capitalization in sentence case.
// They have ordinary title-case shape, so keepWord's acronym check would not
// otherwise catch them.
var properNouns = map[string]bool{
"anthropic": true,
"bedrock": true,
"claude": true,
"coder": true,
"google": true,
"helm": true,
"honeycomb": true,
"maven": true,
"postgres": true,
"prometheus": true,
"stackdriver": true,
"tailscale": true,
"terraform": true,
"wireguard": true,
}
// featureNames are multi-word names whose exact casing is restored after
// sentence-casing. A name that prefixes another comes after the longer one.
var featureNames = []string{
"AI Gateway Proxy",
"AI Gateway",
"OpenID Connect",
"Template Builder",
}
// sentenceCase lowercases a heading's words after the first, preserving the
// first word, acronyms and mixed-case tokens, proper nouns, and feature names.
func sentenceCase(s string) string {
words := strings.Fields(s)
seenFirst := false
for i, w := range words {
if !seenFirst {
// Keep any leading symbols (e.g. an emoji) and the first real word.
if hasLetter(w) {
seenFirst = true
}
continue
}
if !keepWord(w) {
words[i] = strings.ToLower(w)
}
}
return restoreFeatureNames(strings.Join(words, " "))
}
// restoreFeatureNames rewrites any case-insensitive occurrence of a feature
// name with its canonical casing.
func restoreFeatureNames(s string) string {
for _, name := range featureNames {
s = replaceFold(s, name)
}
return s
}
// replaceFold replaces case-insensitive occurrences of canonical in s with
// canonical's exact casing. It assumes canonical is ASCII, which holds for the
// feature names above.
func replaceFold(s, canonical string) string {
lower := strings.ToLower(canonical)
var b strings.Builder
for {
idx := strings.Index(strings.ToLower(s), lower)
if idx < 0 {
_, _ = b.WriteString(s)
return b.String()
}
_, _ = b.WriteString(s[:idx])
_, _ = b.WriteString(canonical)
s = s[idx+len(canonical):]
}
}
// keepWord reports whether a word must keep its capitalization: proper nouns,
// all-caps or mixed-case acronyms (URL, GitHub), and tokens with digits
// (OAuth2).
func keepWord(w string) bool {
core := strings.Trim(w, "()[]{}:;,.\"'")
if core == "" {
return true
}
if properNouns[strings.ToLower(core)] {
return true
}
for i, r := range core {
if i == 0 {
continue
}
if unicode.IsUpper(r) || unicode.IsDigit(r) {
return true
}
}
return false
}
func hasLetter(s string) bool {
for _, r := range s {
if unicode.IsLetter(r) {
return true
}
}
return false
}
// collapse trims a string and collapses internal runs of whitespace to a
// single space so multi-line source text renders as one paragraph.
func collapse(s string) string {
return strings.Join(strings.Fields(s), " ")
}
// normalize lowercases a string and drops everything but letters and digits,
// so prefixes can be compared regardless of spacing, case, or punctuation.
func normalize(s string) string {
var b strings.Builder
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
_, _ = b.WriteRune(unicode.ToLower(r))
}
}
return b.String()
}
// cutFold trims prefix from s using a case-insensitive comparison, reporting
// whether it was present.
func cutFold(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
return s[len(prefix):], true
}
return s, false
}
+239
View File
@@ -0,0 +1,239 @@
package main
import (
"strings"
"testing"
"github.com/coder/serpent"
)
func TestSentenceCase(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
{"lowercases trailing words", "Send Actor Headers", "Send actor headers"},
{"keeps trailing acronym", "Anthropic Base URL", "Anthropic base URL"},
{"keeps all-caps token", "Allow BYOK", "Allow BYOK"},
{"lowercases ordinary word", "Email Authentication", "Email authentication"},
{"keeps proper noun", "Trace Honeycomb API Key", "Trace Honeycomb API key"},
{"restores OpenID Connect", "OpenID Connect sign in text", "OpenID Connect sign in text"},
{"keeps leading mixed-case token", "SSH Keygen Algorithm", "SSH keygen algorithm"},
{"single lowercase word", "pprof", "pprof"},
{"feature name", "AI Gateway", "AI Gateway"},
{"longer feature name wins", "AI Gateway Proxy", "AI Gateway Proxy"},
{"feature name as whole title", "Template Builder", "Template Builder"},
{"feature name after leading word", "Disable Template Builder", "Disable Template Builder"},
{"leading symbol is not the first word", "⚠️ Dangerous", "⚠️ Dangerous"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := sentenceCase(tc.in); got != tc.want {
t.Errorf("sentenceCase(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestStripGroupPrefix(t *testing.T) {
t.Parallel()
aiGateway := serpent.Group{Name: "AI Gateway"}
email := serpent.Group{Name: "Email"}
emailAuth := serpent.Group{Name: "Email Authentication", Parent: &email}
introspection := serpent.Group{Name: "Introspection"}
healthCheck := serpent.Group{Name: "Health Check", Parent: &introspection}
networking := serpent.Group{Name: "Networking"}
derp := serpent.Group{Name: "DERP", Parent: &networking}
oauth2 := serpent.Group{Name: "OAuth2"}
github := serpent.Group{Name: "GitHub", Parent: &oauth2}
dangerous := serpent.Group{Name: "⚠️ Dangerous"}
cases := []struct {
name string
group *serpent.Group
want string
}{
// Space-prefixed names drop the group path.
{"AI Gateway Send Actor Headers", &aiGateway, "Send Actor Headers"},
{"DERP Config Path", &derp, "Config Path"},
{"OAuth2 GitHub Allow Everyone", &github, "Allow Everyone"},
// Colon-prefixed names drop up to the last ": ".
{"Email Auth: Identity", &emailAuth, "Identity"},
// A meaningful colon that is not a group separator is preserved.
{"Health Check Threshold: Database", &healthCheck, "Threshold: Database"},
// The Dangerous group's emoji name still matches its "DANGEROUS:" prefix.
{"DANGEROUS: Allow Path App Sharing", &dangerous, "Allow Path App Sharing"},
// Names that do not repeat the group are unchanged.
{"Access URL", &networking, "Access URL"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := stripGroupPrefix(tc.name, tc.group); got != tc.want {
t.Errorf("stripGroupPrefix(%q) = %q, want %q", tc.name, got, tc.want)
}
})
}
}
func TestShortTitle(t *testing.T) {
t.Parallel()
aiGateway := serpent.Group{Name: "AI Gateway"}
cases := []struct {
opt serpent.Option
want string
}{
{serpent.Option{Name: "AI Gateway Send Actor Headers", Group: &aiGateway}, "Send actor headers"},
{serpent.Option{Name: "AI Gateway Anthropic Base URL", Group: &aiGateway}, "Anthropic base URL"},
// No group: only sentence case applies.
{serpent.Option{Name: "Cache Directory"}, "Cache directory"},
}
for _, tc := range cases {
t.Run(tc.opt.Name, func(t *testing.T) {
t.Parallel()
if got := shortTitle(tc.opt); got != tc.want {
t.Errorf("shortTitle(%q) = %q, want %q", tc.opt.Name, got, tc.want)
}
})
}
}
func TestIsDeprecated(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opt serpent.Option
want bool
}{
{"description prefix", serpent.Option{Description: "Deprecated: use X instead."}, true},
{"description sentence", serpent.Option{Description: "Deprecated and ignored."}, true},
{"use instead", serpent.Option{UseInstead: []serpent.Option{{Name: "X"}}}, true},
{"active", serpent.Option{Description: "A normal option."}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isDeprecated(tc.opt); got != tc.want {
t.Errorf("isDeprecated(%s) = %v, want %v", tc.name, got, tc.want)
}
})
}
}
func TestEmphasizeDeprecation(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want string
}{
// Description already starts with the marker: only the marker is bolded.
{"marker with sentence", "Deprecated and ignored.", "**Deprecated** and ignored."},
{"marker with colon", "Deprecated: use X.", "**Deprecated**: use X."},
// Description does not start with the marker (the UseInstead path): the
// marker is prepended.
{"no marker", "A normal description.", "**Deprecated.** A normal description."},
{"empty description", "", "Deprecated."},
// A bare marker with no trailing text is left unbolded (markdownlint MD036).
{"bare marker", "Deprecated", "Deprecated"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := emphasizeDeprecation(tc.in); got != tc.want {
t.Errorf("emphasizeDeprecation(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestCollapse(t *testing.T) {
t.Parallel()
if got := collapse("a\n b\tc "); got != "a b c" {
t.Errorf("collapse() = %q, want %q", got, "a b c")
}
}
// TestRenderPipeline exercises buildTree and render end to end: section
// nesting and ordering, option skipping, deprecated sinking, and the per-option
// bullet list (environment variable, CLI flag anchor, YAML key, default).
func TestRenderPipeline(t *testing.T) {
t.Parallel()
email := serpent.Group{Name: "Email", YAML: "email"}
emailAuth := serpent.Group{Name: "Email Authentication", YAML: "emailAuth", Parent: &email}
dangerous := serpent.Group{Name: "⚠️ Dangerous", YAML: "dangerous"}
opts := serpent.OptionSet{
// Hidden options and options with no env/flag/YAML are skipped.
{Name: "Hidden Option", Env: "CODER_HIDDEN", Hidden: true},
{Name: "Unsettable Option"},
// General section (no group).
{Name: "Access URL", Env: "CODER_ACCESS_URL", Flag: "access-url", Default: "https://example.com", Description: "The access URL."},
// A DefaultFn with no static Default renders the computed-at-runtime label.
{Name: "Cache Directory", Env: "CODER_CACHE_DIRECTORY", Flag: "cache-dir", DefaultFn: func() string { return "~/.cache/coder" }, Description: "The cache directory."},
// Deprecated via UseInstead: description does not start with "Deprecated".
{Name: "Email From", Env: "CODER_EMAIL_FROM", Flag: "email-from", YAML: "from", Group: &email, Description: "The sender address.", UseInstead: []serpent.Option{{Name: "Notifications Email From"}}},
// Active option with a flag shorthand.
{Name: "Email Smarthost", Env: "CODER_EMAIL_SMARTHOST", Flag: "email-smarthost", FlagShorthand: "s", YAML: "smarthost", Group: &email, Description: "The SMTP host."},
// Nested child section.
{Name: "Email Authentication Identity", Env: "CODER_EMAIL_AUTH_IDENTITY", YAML: "identity", Group: &emailAuth, Description: "The identity."},
// A Dangerous group sorts last regardless of alphabetical order.
{Name: "DANGEROUS: Allow All Cors", Env: "CODER_DANGEROUS_ALLOW_ALL_CORS", Flag: "dangerous-allow-all-cors", Group: &dangerous, Description: "Allow all cross-origin requests."},
}
got := render(buildTree(opts))
wantContains := []string{
"## General",
"### Access URL",
"- Environment variable: `CODER_ACCESS_URL`",
"- CLI flag: [`--access-url`](../../reference/cli/server.md#--access-url)",
"- Default value: `https://example.com`",
"## Email",
"### Smarthost",
// Flag shorthand is folded into the anchor to match the CLI reference.
"- CLI flag: [`--email-smarthost`](../../reference/cli/server.md#-s---email-smarthost)",
// YAML key is the dotted group path.
"- YAML key: `email.from`",
// Deprecated marker is prepended for the UseInstead path.
"**Deprecated.** The sender address.",
// A DefaultFn with no static Default is labeled, not evaluated.
"- Default value: `(computed at runtime)`",
"### Email authentication",
"#### Identity",
"- YAML key: `email.emailAuth.identity`",
// The Dangerous group renders as its own section.
"## ⚠️ Dangerous",
}
for _, w := range wantContains {
if !strings.Contains(got, w) {
t.Errorf("render() missing %q\n---\n%s", w, got)
}
}
// General (rank -1) sorts before every other top-level section.
if i, j := strings.Index(got, "## General"), strings.Index(got, "## Email"); i < 0 || j < 0 || i > j {
t.Errorf("General should render before Email (got indexes %d, %d)", i, j)
}
// Active options sort before deprecated ones within a section.
if i, j := strings.Index(got, "### Smarthost"), strings.Index(got, "### From"); i < 0 || j < 0 || i > j {
t.Errorf("active option should render before deprecated option (got indexes %d, %d)", i, j)
}
// The Dangerous section sorts last among top-level sections.
if i, j := strings.Index(got, "## Email"), strings.Index(got, "## ⚠️ Dangerous"); i < 0 || j < 0 || i > j {
t.Errorf("Dangerous section should render last (got indexes %d, %d)", i, j)
}
// Hidden and unsettable options never render.
if strings.Contains(got, "Hidden") {
t.Error("hidden option should be skipped")
}
if strings.Contains(got, "Unsettable") {
t.Error("option with no env/flag/YAML should be skipped")
}
}