Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/go-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ jobs:
# spending CI minutes (and hitting flaky tests) on changes that can't have broken anything it
# tests. A job skipped via `if:` reports as a passing check, so this still satisfies the
# required status check, unlike skipping the workflow itself via a trigger-level path filter.
# '!docs/*/**' (not '!docs/**') so the doc generator and its tests, which live directly under
# docs/, still count as code: paths-filter's some-with-excludes mode makes an exclusion final,
# so a blanket '!docs/**' can't be selectively re-included by a later positive pattern.
changes:
runs-on: ubuntu-latest
permissions:
Expand All @@ -37,7 +40,7 @@ jobs:
filters: |
code:
- '**'
- '!docs/**'
- '!docs/*/**'
- '!**/*.md'
- '!**/*.rst'

Expand Down
56 changes: 31 additions & 25 deletions docs/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package main

import (
"bytes"
"errors"
"fmt"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -51,15 +52,23 @@ var serverCommands stringSlice = []string{"nuts config", "nuts server", "nuts cr

func generateDocs() {
system := cmd.CreateSystem(func() {})
generateServerOptions(system)
generateCLICommands(system)
for fileName, content := range generatedDocFiles(system) {
if err := os.WriteFile(fileName, content, os.ModePerm); err != nil {
panic(err)
}
}
}

func generateCLICommands(system *core.System) {
const targetFile = "docs/pages/deployment/cli-reference.rst"
writer, _ := os.OpenFile(targetFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm)
defer writer.Close()
// generatedDocFiles renders all documentation files that are generated from the code, keyed by their (repo-relative)
// output path. generateDocs writes them to disk; the up-to-date test compares them against the committed files.
func generatedDocFiles(system *core.System) map[string][]byte {
files := renderServerOptions(system)
files["docs/pages/deployment/cli-reference.rst"] = renderCLICommands(system)
return files
}

func renderCLICommands(system *core.System) []byte {
writer := new(bytes.Buffer)
_, _ = writer.WriteString(".. _nuts-cli-reference:" + newline + newline)
writeHeader(writer, "Server CLI Command Reference", 0)

Expand All @@ -73,6 +82,7 @@ func generateCLICommands(system *core.System) {
panic(err)
}
_, _ = io.WriteString(writer, newline)
return writer.Bytes()
}

func writeHeader(writer io.Writer, header string, level int) {
Expand All @@ -81,7 +91,7 @@ func writeHeader(writer io.Writer, header string, level int) {
_, _ = writer.Write([]byte(strings.Repeat(c, len(header)) + newline + newline))
}

func generateServerOptions(system *core.System) {
func renderServerOptions(system *core.System) map[string][]byte {
flags := make(map[string]*pflag.FlagSet)
// Resolve root command flags
globalFlags := core.FlagSet()
Expand Down Expand Up @@ -137,8 +147,10 @@ func generateServerOptions(system *core.System) {
},
}

generatePartitionedConfigOptionsDocs("Server Options", "docs/pages/deployment/server_options.rst", filterFlags(flags, v5FlagsPredicates, true))
generatePartitionedConfigOptionsDocs("did:nuts/gRPC Server Options", "docs/pages/deployment/server_options_didnuts.rst", filterFlags(flags, v5FlagsPredicates, false))
return map[string][]byte{
"docs/pages/deployment/server_options.rst": renderPartitionedConfigOptionsDocs("Server Options", filterFlags(flags, v5FlagsPredicates, true)),
"docs/pages/deployment/server_options_didnuts.rst": renderPartitionedConfigOptionsDocs("did:nuts/gRPC Server Options", filterFlags(flags, v5FlagsPredicates, false)),
}
}

func filterFlags(flags map[string]*pflag.FlagSet, predicates []func(f *pflag.Flag) bool, exclude bool) map[string]*pflag.FlagSet {
Expand Down Expand Up @@ -192,7 +204,7 @@ func extractFlagsForEngine(flagSet *pflag.FlagSet, config interface{}, engineNam
return &result, nil
}

func generatePartitionedConfigOptionsDocs(tableName, fileName string, flags map[string]*pflag.FlagSet) {
func renderPartitionedConfigOptionsDocs(tableName string, flags map[string]*pflag.FlagSet) []byte {
var keys []string
for key := range flags {
keys = append(keys, key)
Expand All @@ -209,7 +221,7 @@ func generatePartitionedConfigOptionsDocs(tableName, fileName string, flags map[
}
values = append(values, flagsToSortedValues(flags[key])...)
}
generateRstTable(tableName, fileName, values)
return renderRstTable(tableName, values)
}

func flagsToSortedValues(flags *pflag.FlagSet) [][]rstValue {
Expand Down Expand Up @@ -241,20 +253,14 @@ func normalizeDefaultValue(f *pflag.Flag) string {
return defValue
}

func generateRstTable(tableName, fileName string, values [][]rstValue) {
optionsFile, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.ModePerm)
if err != nil {
panic(err)
}
defer optionsFile.Close()
optionsFile.WriteString(fmt.Sprintf(".. list-table:: %s\n", tableName))
optionsFile.WriteString(" :widths: 20 30 50\n")
optionsFile.WriteString(" :class: options-table\n")
optionsFile.WriteString(" :header-rows: 1\n\n")
printRstTable(vals("Key", "Default", "Description"), values, optionsFile)
if err := optionsFile.Sync(); err != nil {
panic(err)
}
func renderRstTable(tableName string, values [][]rstValue) []byte {
buffer := new(bytes.Buffer)
fmt.Fprintf(buffer, ".. list-table:: %s\n", tableName)
buffer.WriteString(" :widths: 20 30 50\n")
buffer.WriteString(" :class: options-table\n")
buffer.WriteString(" :header-rows: 1\n\n")
printRstTable(vals("Key", "Default", "Description"), values, buffer)
return buffer.Bytes()
}

// KeyList sorts keys alphabetically, with any nested content at the end.
Expand Down
42 changes: 42 additions & 0 deletions docs/generate_docs_uptodate_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2026 Nuts community
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
*/

package main

import (
"os"
"path/filepath"
"testing"

"github.com/nuts-foundation/nuts-node/cmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestGeneratedDocsAreUpToDate fails when the generated documentation differs from what is committed,
// so a contributor who changed a config option or CLI command but forgot to run `make cli-docs` is caught by CI.
func TestGeneratedDocsAreUpToDate(t *testing.T) {
system := cmd.CreateSystem(func() {})
for fileName, expected := range generatedDocFiles(system) {
// generatedDocFiles keys are repo-relative paths (docs/...); tests run from the docs/ package directory.
committed, err := os.ReadFile(filepath.Join("..", fileName))
require.NoError(t, err)
assert.Equalf(t, string(committed), string(expected),
"%s is out of date; run `make cli-docs` and commit the result", fileName)
}
}
Loading