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