From ba83bfce8fef63899ee3e66e773edb0e6050eab2 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 23 Sep 2026 06:32:19 -0700 Subject: [PATCH] fix(docs): escape angle-bracket placeholders in generated docs Deploy Docs on main has failed since 2026-09-07 because reference/modules.mdx line 315 has a bare ``, which MDX parses as an unclosed JSX tag. docgen's formatDescription parses option descriptions with goldmark and dropped anything goldmark saw as inline HTML, so "stackpanel.doctor.." came out as "stackpanel.doctor..". d3dd7aa9 put the placeholder back by hand, unescaped. Keep inline HTML as literal text instead: nixos-render-docs renders option descriptions with HTML disabled, so these are placeholders, not tags, and the existing escapeMDX pass writes them as `\`. A full regeneration would still break the build in two places, both in frontmatter, which the docs build parses as YAML rather than MDX: - escapeMDX also rewrote the frontmatter, turning `{` in a quoted module description into `\{`, an invalid YAML escape. It now skips frontmatter. - CLI pages wrote cmd.Short unquoted, and `stack setup`'s "Reconcile the project: report, confirm, apply" is invalid YAML (the checked-in page had been hand-edited to avoid it). CLI descriptions now go through escapeYAMLString, which now also escapes backslashes. Generated pages: only the lines these fixes change are updated (5 reference, 6 CLI), each identical to the fixed generator's output. A full `generate-docs` run is left out because the checked-in pages have drifted from the option definitions (26 files removed, 52 added, 50 changed), which needs its own change. That full output now builds. Refs: stackpanel-thq.23 --- apps/docs/content/docs/cli/caddy/add.mdx | 2 +- apps/docs/content/docs/cli/caddy/remove.mdx | 2 +- apps/docs/content/docs/cli/config/get.mdx | 2 +- apps/docs/content/docs/cli/config/set.mdx | 2 +- apps/docs/content/docs/cli/debug/index.mdx | 2 +- apps/docs/content/docs/cli/setup.mdx | 2 +- apps/docs/content/docs/reference/caddy.mdx | 2 +- apps/docs/content/docs/reference/modules.mdx | 2 +- apps/docs/content/docs/reference/outputs.mdx | 2 +- apps/docs/content/docs/reference/panels.mdx | 2 +- .../docs/reference/variables-backend.mdx | 2 +- apps/stackpanel-go/internal/docgen/cli.go | 16 +++- apps/stackpanel-go/internal/docgen/convert.go | 18 +++-- .../internal/docgen/docgen_test.go | 73 ++++++++++++++++++- 14 files changed, 108 insertions(+), 21 deletions(-) diff --git a/apps/docs/content/docs/cli/caddy/add.mdx b/apps/docs/content/docs/cli/caddy/add.mdx index d41f2894..4e609bc3 100644 --- a/apps/docs/content/docs/cli/caddy/add.mdx +++ b/apps/docs/content/docs/cli/caddy/add.mdx @@ -1,6 +1,6 @@ --- title: stack caddy add -description: Link this project's generated Caddy site(s) into the global proxy +description: "Link this project's generated Caddy site(s) into the global proxy" icon: Terminal --- diff --git a/apps/docs/content/docs/cli/caddy/remove.mdx b/apps/docs/content/docs/cli/caddy/remove.mdx index 23457b38..e6a40b93 100644 --- a/apps/docs/content/docs/cli/caddy/remove.mdx +++ b/apps/docs/content/docs/cli/caddy/remove.mdx @@ -1,6 +1,6 @@ --- title: stack caddy remove -description: Unlink this project's Caddy site(s) from the global proxy +description: "Unlink this project's Caddy site(s) from the global proxy" icon: Terminal --- diff --git a/apps/docs/content/docs/cli/config/get.mdx b/apps/docs/content/docs/cli/config/get.mdx index 1c7d9fec..90accfae 100644 --- a/apps/docs/content/docs/cli/config/get.mdx +++ b/apps/docs/content/docs/cli/config/get.mdx @@ -1,6 +1,6 @@ --- title: stack config get -description: Get a configuration value by dot-path +description: "Get a configuration value by dot-path" icon: Terminal --- diff --git a/apps/docs/content/docs/cli/config/set.mdx b/apps/docs/content/docs/cli/config/set.mdx index fe9248e3..3cfda835 100644 --- a/apps/docs/content/docs/cli/config/set.mdx +++ b/apps/docs/content/docs/cli/config/set.mdx @@ -1,6 +1,6 @@ --- title: stack config set -description: Set a configuration value by dot-path +description: "Set a configuration value by dot-path" icon: Terminal --- diff --git a/apps/docs/content/docs/cli/debug/index.mdx b/apps/docs/content/docs/cli/debug/index.mdx index 5ded9b83..f116ca74 100644 --- a/apps/docs/content/docs/cli/debug/index.mdx +++ b/apps/docs/content/docs/cli/debug/index.mdx @@ -1,6 +1,6 @@ --- title: stack debug -description: Run stackpanel from source via 'go run' +description: "Run stackpanel from source via 'go run'" icon: Terminal --- diff --git a/apps/docs/content/docs/cli/setup.mdx b/apps/docs/content/docs/cli/setup.mdx index 82b6ccdf..2097d3a3 100644 --- a/apps/docs/content/docs/cli/setup.mdx +++ b/apps/docs/content/docs/cli/setup.mdx @@ -1,6 +1,6 @@ --- title: stack setup -description: Reconcile the project - report, confirm, apply +description: "Reconcile the project: report, confirm, apply" icon: Terminal --- diff --git a/apps/docs/content/docs/reference/caddy.mdx b/apps/docs/content/docs/reference/caddy.mdx index 3962fbbf..77c0f2b8 100644 --- a/apps/docs/content/docs/reference/caddy.mdx +++ b/apps/docs/content/docs/reference/caddy.mdx @@ -69,7 +69,7 @@ Stable port for Caddy. If null, computed from project-name. Top-level domain for virtual hosts. -Virtual hosts use the format: .. For example, with project "myapp" and tld "localhost": +Virtual hosts use the format: \.\.\ For example, with project "myapp" and tld "localhost": Change to "lan" or "local" for custom DNS setups. diff --git a/apps/docs/content/docs/reference/modules.mdx b/apps/docs/content/docs/reference/modules.mdx index 0dc1b7d8..875e8cca 100644 --- a/apps/docs/content/docs/reference/modules.mdx +++ b/apps/docs/content/docs/reference/modules.mdx @@ -312,7 +312,7 @@ Flake URL (e.g., "github:author/my-module") ## `modules..healthcheckModule` -Name of the doctor module that provides health checks for this module. This links to stackpanel.doctor.. +Name of the doctor module that provides health checks for this module. This links to stackpanel.doctor.\. | Property | Value | |----------|-------| diff --git a/apps/docs/content/docs/reference/outputs.mdx b/apps/docs/content/docs/reference/outputs.mdx index 3332c4b2..16b0c78e 100644 --- a/apps/docs/content/docs/reference/outputs.mdx +++ b/apps/docs/content/docs/reference/outputs.mdx @@ -49,7 +49,7 @@ Output derivations to expose. These will be available as: -Example: stackpanel.outputs = \{ my-docs = pkgs.runCommand "docs" \{\} "..."; my-script = pkgs.writeShellScriptBin "foo" "..."; # Grouped packages (available via nix run .#scripts.) scripts = \{ foo = pkgs.writeShellScriptBin "foo" "..."; \}; \}; +Example: stackpanel.outputs = \{ my-docs = pkgs.runCommand "docs" \{\} "..."; my-script = pkgs.writeShellScriptBin "foo" "..."; # Grouped packages (available via nix run .#scripts.\) scripts = \{ foo = pkgs.writeShellScriptBin "foo" "..."; \}; \}; | Property | Value | |----------|-------| diff --git a/apps/docs/content/docs/reference/panels.mdx b/apps/docs/content/docs/reference/panels.mdx index ab57987f..885c563c 100644 --- a/apps/docs/content/docs/reference/panels.mdx +++ b/apps/docs/content/docs/reference/panels.mdx @@ -187,7 +187,7 @@ Help text shown below the field ## `panels..fields.*.editPath` -Dot-separated path within the app's config for writes. E.g., "go.mainPackage" tells the agent to patch apps..go.mainPackage in the data file. +Dot-separated path within the app's config for writes. E.g., "go.mainPackage" tells the agent to patch apps.\.go.mainPackage in the data file. | Property | Value | |----------|-------| diff --git a/apps/docs/content/docs/reference/variables-backend.mdx b/apps/docs/content/docs/reference/variables-backend.mdx index 4e072373..7f4545cb 100644 --- a/apps/docs/content/docs/reference/variables-backend.mdx +++ b/apps/docs/content/docs/reference/variables-backend.mdx @@ -23,7 +23,7 @@ Secret storage backend. This is the single source of truth that controls how sec Chamber service prefix. The full chamber service path is: \{service-prefix\}/\{env\} -For example, with prefix "darkmatter/stackpanel" and a variable /dev/DATABASE_URL: chamber write darkmatter/stackpanel/dev DATABASE_URL chamber exec darkmatter/stackpanel/dev -- +For example, with prefix "darkmatter/stackpanel" and a variable /dev/DATABASE_URL: chamber write darkmatter/stackpanel/dev DATABASE_URL \ chamber exec darkmatter/stackpanel/dev -- \ Defaults to "\{owner\}/\{repo\}" when project owner/repo are configured, otherwise falls back to the project name. diff --git a/apps/stackpanel-go/internal/docgen/cli.go b/apps/stackpanel-go/internal/docgen/cli.go index 8a120f12..b1bcf05b 100644 --- a/apps/stackpanel-go/internal/docgen/cli.go +++ b/apps/stackpanel-go/internal/docgen/cli.go @@ -18,7 +18,7 @@ import ( // Cobra command data. type CLICommandView struct { Title string - Description string + Description string // YAML-escaped, frontmatter only Long string Usage string Aliases []string @@ -140,11 +140,21 @@ func GenerateCLIDocs(rootCmd *cobra.Command, outputDir string) error { // escapeMDX escapes characters that MDX would interpret as JSX ({, }, <, >). // Content inside fenced code blocks and inline code spans is left untouched // since MDX treats those as literal text. Already-escaped sequences (preceded -// by backslash) are also preserved to avoid double-escaping. +// by backslash) are also preserved to avoid double-escaping. Leading YAML +// frontmatter is copied verbatim: it is parsed as YAML, not MDX, and `\{` or +// `\<` inside a double-quoted YAML string is an invalid escape sequence. func escapeMDX(text string) string { var result strings.Builder result.Grow(len(text)) + if rest, ok := strings.CutPrefix(text, "---\n"); ok { + if end := strings.Index(rest, "\n---\n"); end != -1 { + frontmatterLen := len("---\n") + end + len("\n---\n") + result.WriteString(text[:frontmatterLen]) + text = text[frontmatterLen:] + } + } + lines := strings.Split(text, "\n") inFencedBlock := false @@ -275,7 +285,7 @@ func generateCommandDocs( // Create view view := CLICommandView{ Title: fmt.Sprintf("%s %s", cliName, pathPrefix), - Description: cmd.Short, + Description: escapeYAMLString(cmd.Short), Long: long, Usage: usage, Aliases: cmd.Aliases, diff --git a/apps/stackpanel-go/internal/docgen/convert.go b/apps/stackpanel-go/internal/docgen/convert.go index 57c2fe51..06495159 100644 --- a/apps/stackpanel-go/internal/docgen/convert.go +++ b/apps/stackpanel-go/internal/docgen/convert.go @@ -51,6 +51,14 @@ func mdNodeText(n ast.Node, source []byte) string { buf.Write(t.Segment.Value(source)) } } + case *ast.RawHTML: + // Keep inline HTML as literal text. Option descriptions are written + // for renderers with HTML disabled (nixos-render-docs), so `` + // is a placeholder, not a tag. escapeMDX escapes it on write. + for i := 0; i < v.Segments.Len(); i++ { + seg := v.Segments.At(i) + buf.Write(seg.Value(source)) + } default: // Recurse for other inline nodes (emphasis, strong, links, etc.) buf.WriteString(mdNodeText(c, source)) @@ -314,16 +322,16 @@ func concatenateDocsToMdx(docs []ParsedDoc) string { func escapeYAMLString(s string) string { needsQuotes := strings.ContainsAny(s, `:{}[]&*#?|-<>=!%@\'"`) if needsQuotes { - escaped := strings.ReplaceAll(s, `"`, `\"`) + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) return `"` + escaped + `"` } return s } -// formatDescription sanitizes an option description that may contain docbook/XML -// remnants from nixosOptionsDoc. Parses as markdown via goldmark and extracts -// clean text, which transparently drops any inline HTML tags while preserving -// code blocks and list structure. +// formatDescription flattens an option description from nixosOptionsDoc +// (CommonMark) into text for a reference page by parsing it with goldmark. +// Angle-bracket placeholders such as `` survive as literal text (see +// mdNodeText); the page-level escapeMDX pass makes them safe for MDX. func formatDescription(desc string) string { if desc == "" { return "_No description provided._" diff --git a/apps/stackpanel-go/internal/docgen/docgen_test.go b/apps/stackpanel-go/internal/docgen/docgen_test.go index 0d7f8b67..f44298fb 100644 --- a/apps/stackpanel-go/internal/docgen/docgen_test.go +++ b/apps/stackpanel-go/internal/docgen/docgen_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/spf13/cobra" + "gopkg.in/yaml.v3" ) func TestModuleFromDeclaration(t *testing.T) { @@ -307,9 +308,9 @@ func TestFormatDescription(t *testing.T) { expected: "Enable the service", }, { - name: "with xml tags", + name: "tag-like text is kept literally, not stripped", input: "Enable foo service", - expected: "Enable foo service", + expected: "Enable foo service", }, { name: "with whitespace", @@ -875,6 +876,74 @@ Example: } } +// TestGenerateOptionsDocsEscapesPlaceholders covers stackpanel-thq.23: an option +// description containing `` must reach the written page escaped, not +// dropped (the old output) and not bare (which breaks the MDX build). +func TestGenerateOptionsDocsEscapesPlaceholders(t *testing.T) { + dir := t.TempDir() + optionsPath := filepath.Join(dir, "options.json") + optionsJSON := `{ + "stackpanel.modules..healthcheckModule": { + "declarations": [], + "description": "Name of the doctor module that provides health checks for this module.\nThis links to stackpanel.doctor..\n", + "loc": ["stackpanel", "modules", "", "healthcheckModule"], + "readOnly": false, + "type": "null or string" + } +}` + if err := os.WriteFile(optionsPath, []byte(optionsJSON), 0o644); err != nil { + t.Fatal(err) + } + + outDir := filepath.Join(dir, "reference") + if err := generateOptionsDocs(optionsPath, outDir, filepath.Join(dir, "internal")); err != nil { + t.Fatalf("generateOptionsDocs failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(outDir, "modules.mdx")) + if err != nil { + t.Fatalf("failed to read modules.mdx: %v", err) + } + s := string(content) + + if !strings.Contains(s, `This links to stackpanel.doctor.\.`) { + t.Errorf("expected the placeholder escaped in the description, got:\n%s", s) + } + // Inline code is literal in MDX, so the heading must stay unescaped. + if !strings.Contains(s, "## `modules..healthcheckModule`") { + t.Errorf("expected the option heading unchanged, got:\n%s", s) + } +} + +// TestGenerateCLIDocsFrontmatterIsValidYAML: a command's Short lands in the +// page frontmatter, which the docs build parses as YAML, not MDX. +func TestGenerateCLIDocsFrontmatterIsValidYAML(t *testing.T) { + outputDir := t.TempDir() + short := `Reconcile the project: apply {changes} under C:\work` + rootCmd := &cobra.Command{Use: "testcli", Short: "Test CLI"} + rootCmd.AddCommand(&cobra.Command{Use: "setup", Short: short}) + + if err := GenerateCLIDocs(rootCmd, outputDir); err != nil { + t.Fatalf("GenerateCLIDocs failed: %v", err) + } + content, err := os.ReadFile(filepath.Join(outputDir, "setup.mdx")) + if err != nil { + t.Fatalf("failed to read setup.mdx: %v", err) + } + + parts := strings.SplitN(string(content), "---\n", 3) + if len(parts) != 3 { + t.Fatalf("expected a frontmatter block, got:\n%s", content) + } + var fm struct{ Description string } + if err := yaml.Unmarshal([]byte(parts[1]), &fm); err != nil { + t.Fatalf("frontmatter is not valid YAML: %v\n%s", err, parts[1]) + } + if fm.Description != short { + t.Errorf("description = %q, want %q", fm.Description, short) + } +} + func TestIsSeparatorLine(t *testing.T) { tests := []struct { input string