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
parent ee7e7ecb74
commit ba4779fc87
9 changed files with 2705 additions and 32 deletions
+31
View File
@@ -168,6 +168,37 @@ superseded by the canonical content guidelines.
## Code Examples
### Configuration examples: prefer environment variables
When showing how to configure `coder server` in admin or setup
documentation, lead with the environment variable form. Production Coder
deployments are typically run as a system service, container, or Helm
chart, all of which set configuration through environment variables (for
example, Helm `values.yaml` for Kubernetes or `/etc/coder.d/coder.env` for
a system service). Showing the CLI flag form first forces operators to
mentally translate every example.
Show the equivalent CLI flag only when the example is invoking
`coder server` directly (for local development or one-off runs), or as a
supporting note. Point readers at the
[configuration reference](../../docs/admin/setup/configuration-reference.md)
for the full mapping between environment variables, flags, and YAML keys.
````markdown
```sh
# Preferred for admin/setup docs:
CODER_UPDATE_CHECK=false
```
````
CLI flag form, reserved for ad-hoc invocations:
````markdown
```sh
coder server --update-check=false
```
````
### Command Examples
````markdown
+3
View File
@@ -38,5 +38,8 @@ ignorePatterns:
- pattern: "merriam-webster.com"
# npmjs.com returns 403 from GitHub runner IPs
- pattern: "npmjs.com"
# AWS Bedrock base URL appears as an illustrative <region> placeholder in
# the generated configuration reference, not as a live link.
- pattern: "bedrock-runtime"
aliveStatusCodes:
- 200
+16
View File
@@ -73,6 +73,7 @@ endif
docs/manifest.json \
docs/admin/integrations/prometheus.md \
docs/admin/security/audit-logs.md \
docs/admin/setup/configuration-reference.md \
docs/reference/cli/index.md \
coderd/apidoc/swagger.json \
coderd/rbac/object_gen.go \
@@ -153,6 +154,12 @@ _gen/bin/clidocgen: $(CLIDOCGEN_INPUTS) | _gen
@mkdir -p _gen/bin
go build -o $@ ./scripts/clidocgen
# configdocgen reflects over codersdk.DeploymentValues to produce the
# configuration reference page.
_gen/bin/configdocgen: $(wildcard scripts/configdocgen/*.go) $(wildcard codersdk/*.go) | _gen
@mkdir -p _gen/bin
go build -o $@ ./scripts/configdocgen
_gen/bin/dbdump: $(wildcard coderd/database/gen/dump/*.go) $(DBDUMP_INPUTS) | _gen
@mkdir -p _gen/bin
go build -o $@ ./coderd/database/gen/dump
@@ -1002,6 +1009,7 @@ GEN_FILES := \
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
docs/install/releases/feature-stages.md \
docs/admin/setup/configuration-reference.md \
coderd/apidoc/swagger.json \
docs/manifest.json \
provisioner/terraform/testdata/version \
@@ -1100,6 +1108,7 @@ gen/mark-fresh:
docs/reference/cli/index.md \
docs/admin/security/audit-logs.md \
docs/install/releases/feature-stages.md \
docs/admin/setup/configuration-reference.md \
coderd/apidoc/swagger.json \
docs/manifest.json \
site/e2e/provisionerGenerated.ts \
@@ -1346,6 +1355,13 @@ docs/install/releases/feature-stages.md: \
pnpm exec markdown-table-formatter "$$tmpfile" && \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
docs/admin/setup/configuration-reference.md: node_modules/.installed $(wildcard scripts/configdocgen/*.go) $(wildcard codersdk/*.go) _gen/bin/configdocgen | _gen
tmpdir=$$(mktemp -d -p _gen) && tmpfile=$$(realpath "$$tmpdir")/$(notdir $@) && \
_gen/bin/configdocgen --out="$$tmpfile" && \
pnpm exec markdownlint-cli2 --fix "$$tmpfile" && \
pnpm exec markdown-table-formatter "$$tmpfile" && \
mv "$$tmpfile" "$@" && rm -rf "$$tmpdir"
coderd/apidoc/.gen: \
node_modules/.installed \
scripts/apidocgen/node_modules/.installed \
File diff suppressed because it is too large Load Diff
+5
View File
@@ -4,6 +4,11 @@ Coder server's primary configuration is done via environment variables. For a
full list of the options, run `coder server --help` or see our
[CLI documentation](../../reference/cli/server.md).
> [!TIP]
> Need to look up an exact environment variable, CLI flag, or YAML key for a
> setting? See the [configuration reference](./configuration-reference.md) for
> a searchable list of every option.
## Access URL
`CODER_ACCESS_URL` is required if you are not using the tunnel. Set this to the
+39 -32
View File
@@ -85,37 +85,13 @@ CODER_OAUTH2_GITHUB_DEFAULT_PROVIDER_ENABLE=false
## Step 2: Configure Coder with the OAuth credentials
Go to your Coder host and run the following command to start up the Coder server:
Coder server reads these settings from environment variables. Set them
wherever your deployment manages environment variables. For example, use
Helm `values.yaml` for Kubernetes or `/etc/coder.d/coder.env` for a system
service.
```sh
coder server --oauth2-github-allow-signups=true --oauth2-github-allowed-orgs="your-org" --oauth2-github-client-id="8d1...e05" --oauth2-github-client-secret="57ebc9...02c24c"
```
> [!NOTE]
> For GitHub Enterprise support, specify the `--oauth2-github-enterprise-base-url` flag.
Alternatively, if you are running Coder as a system service, you can achieve the
same result as the command above by adding the following environment variables
to the `/etc/coder.d/coder.env` file:
```sh
CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true
CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org"
CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05"
CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c"
```
> [!TIP]
> To allow everyone to sign up using GitHub, set:
>
> ```shell
> CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true
> ```
Once complete, run `sudo service coder restart` to reboot Coder.
If deploying Coder via Helm, you can set the above environment variables in the
`values.yaml` file as such:
**Kubernetes (Helm):** set the variables under `coder.env` in your
`values.yaml`:
```yaml
coder:
@@ -129,17 +105,48 @@ coder:
# If setting allowed orgs, comment out CODER_OAUTH2_GITHUB_ALLOW_EVERYONE and its value
- name: CODER_OAUTH2_GITHUB_ALLOWED_ORGS
value: "your-org"
# If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and it's value
# If allowing everyone, comment out CODER_OAUTH2_GITHUB_ALLOWED_ORGS and its value
#- name: CODER_OAUTH2_GITHUB_ALLOW_EVERYONE
# value: "true"
```
To upgrade Coder, run:
Then apply the change with `helm upgrade`:
```sh
helm upgrade <release-name> coder-v2/coder -n <namespace> -f values.yaml
```
**System service:** add the variables to `/etc/coder.d/coder.env`:
```sh
CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS=true
CODER_OAUTH2_GITHUB_ALLOWED_ORGS="your-org"
CODER_OAUTH2_GITHUB_CLIENT_ID="8d1...e05"
CODER_OAUTH2_GITHUB_CLIENT_SECRET="57ebc9...02c24c"
```
Then restart Coder with `sudo service coder restart`.
> [!TIP]
> To allow everyone to sign up using GitHub, set:
>
> ```shell
> CODER_OAUTH2_GITHUB_ALLOW_EVERYONE=true
> ```
For GitHub Enterprise support, also set
`CODER_OAUTH2_GITHUB_ENTERPRISE_BASE_URL`.
> [!NOTE]
> Every option above also has an equivalent CLI flag (for example,
> `CODER_OAUTH2_GITHUB_CLIENT_ID` becomes `--oauth2-github-client-id`).
> CLI flags are convenient for ad-hoc invocations of `coder server` during
> local development. For production deployments, prefer environment
> variables so the configuration lives with the container, Helm chart, or
> service unit that manages Coder. See the
> [configuration reference](../setup/configuration-reference.md) for the
> full mapping between environment variables and flags.
We recommend requiring and auditing MFA usage for all users in your GitHub organizations.
This can be enforced from the organization settings page in the **Authentication security** sidebar tab.
+5
View File
@@ -423,6 +423,11 @@
"path": "./admin/setup/appearance.md",
"state": ["premium"]
},
{
"title": "Configuration Reference",
"description": "Searchable list of every Coder server setting with its environment variable, CLI flag, and YAML key",
"path": "./admin/setup/configuration-reference.md"
},
{
"title": "Telemetry",
"description": "Learn what usage telemetry Coder collects, why it is collected, and how to opt out.",
+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")
}
}