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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ jobs:
- name: Run tests
run: go test -coverpkg=./internal/... -coverprofile=build/coverage.txt -v -race ./...

- name: Run cmd/landingdocs tests
run: go test ./...
working-directory: cmd/landingdocs

go-mod-tidy:
runs-on: ubuntu-latest
steps:
Expand Down
65 changes: 63 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ on:
tags:
- "v*.*.*"

permissions:
contents: write
permissions: {}

jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
Expand All @@ -27,3 +28,63 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}

sync-docs:
runs-on: ubuntu-latest
needs: release
steps:
- name: Checkout qcloud-cli
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
path: qcloud-cli
persist-credentials: false

- name: Checkout landing_page
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: qdrant/landing_page
path: landing_page
token: ${{ secrets.LANDING_PAGE_PAT }}
persist-credentials: true

- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
version: 2026.3.8

- name: Generate command reference
working-directory: qcloud-cli
run: make docs

- name: Sync docs into landing_page
working-directory: qcloud-cli
run: |
go run ./cmd/landingdocs \
./docs/reference \
../landing_page/qdrant-landing/content/documentation/cloud-cli/reference

- name: Open PR against landing_page
working-directory: landing_page
env:
GH_TOKEN: ${{ secrets.LANDING_PAGE_PAT }}
REF_NAME: ${{ github.ref_name }}
run: |
if git diff --quiet -- qdrant-landing/content/documentation/cloud-cli/reference; then
echo "no changes to sync"
exit 0
fi

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

branch="qcloud-cli-docs-sync-${REF_NAME}"
git checkout -b "$branch"
git add qdrant-landing/content/documentation/cloud-cli/reference
git commit -m "docs: sync qcloud CLI command reference for ${REF_NAME}"
git push -u origin "$branch" --force

gh pr create \
--title "docs: sync qcloud CLI command reference for ${REF_NAME}" \
--body "Auto-generated from [qdrant/qcloud-cli@${REF_NAME}](https://github.com/qdrant/qcloud-cli/releases/tag/${REF_NAME}) by the release workflow." \
--head "$branch" \
--base main \
|| echo "PR already exists for $branch"
191 changes: 191 additions & 0 deletions cmd/landingdocs/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// Command landingdocs converts the cobra-generated docs/reference/*.md
// files into Hugo pages for the landing_page repo's Qdrant Cloud CLI
// reference section.
//
// Usage:
//
// go run ./cmd/landingdocs <src_dir> <dest_dir>
//
// <src_dir> is docs/reference (output of `make docs`)
// <dest_dir> is the landing_page repo's
//
// qdrant-landing/content/documentation/cloud-cli/reference directory
//
// The destination directory is fully regenerated on every run (existing
// generated files are replaced, stale ones removed) so it always mirrors
// the current command tree.
package main

import (
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)

var linkRE = regexp.MustCompile(`\[([^\]]+)\]\((qcloud[\w.-]*)\.md\)`)

func rewriteLinks(text string) string {
return linkRE.ReplaceAllStringFunc(text, func(match string) string {
groups := linkRE.FindStringSubmatch(match)
label, target := groups[1], groups[2]
if target == "qcloud" {
return fmt.Sprintf("[%s](/documentation/cloud-cli/reference/)", label)
}

return fmt.Sprintf("[%s](/documentation/cloud-cli/reference/%s/)", label, target)
})
}

// demoteHeadings turns "## " into "# ", "### " into "## ", etc. so the
// page owns a single H1 title.
func demoteHeadings(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
if strings.HasPrefix(line, "#") {
trimmed := strings.TrimLeft(line, "#")
hashes := len(line) - len(trimmed)
if hashes > 1 {
lines[i] = line[1:]
}
}
}

return strings.Join(lines, "\n")
}

// annotateCodeFences tags every opening ``` fence as bash. Bare ```
// fences don't render as code blocks on the landing page, and cobra
// only emits usage/example/flag blocks, which are all shell-ish.
func annotateCodeFences(text string) string {
lines := strings.Split(text, "\n")
inFence := false
for i, line := range lines {
if strings.TrimSpace(line) == "```" {
if !inFence {
lines[i] = "```bash"
}

inFence = !inFence
}
}

return strings.Join(lines, "\n")
}

type page struct {
title string
description string
body string
}

func convert(srcPath string) (page, error) {
raw, err := os.ReadFile(srcPath)
if err != nil {
return page{}, err
}

lines := strings.Split(string(raw), "\n")

titleLine := lines[0]
if !strings.HasPrefix(titleLine, "## ") {
return page{}, fmt.Errorf("unexpected heading in %s: %q", srcPath, titleLine)
}

title := strings.TrimSpace(strings.TrimPrefix(titleLine, "## "))

description := ""
for _, line := range lines[1:] {
if stripped := strings.TrimSpace(line); stripped != "" {
description = stripped
break
}
}

body := strings.Join(lines, "\n")
body = demoteHeadings(body)
body = rewriteLinks(body)
body = annotateCodeFences(body)

return page{title: title, description: description, body: body}, nil
}

func frontmatter(title, description string, weight int) string {
short := description
if len(short) > 120 {
short = strings.TrimSpace(short[:117]) + "..."
}

return fmt.Sprintf(
"---\ntitle: %s\nshort_description: %q\ndescription: %q\nweight: %d\n---\n\n",
title, short, description, weight,
)
}

func run(srcDir, destDir string) error {
if err := os.MkdirAll(destDir, 0o755); err != nil {
return fmt.Errorf("create dest dir: %w", err)
}

existing, err := filepath.Glob(filepath.Join(destDir, "*.md"))
if err != nil {
return err
}

for _, f := range existing {
if err := os.Remove(f); err != nil {
return err
}
}

files, err := filepath.Glob(filepath.Join(srcDir, "*.md"))
if err != nil {
return err
}

if len(files) == 0 {
return fmt.Errorf("no markdown files found in %s", srcDir)
}

sort.Strings(files)

for i, src := range files {
p, err := convert(src)
if err != nil {
return err
}

name := filepath.Base(src)
var destName, fm string
if name == "qcloud.md" {
destName = "_index.md"
fm = frontmatter("Command Reference", p.description, 0)
} else {
destName = name
fm = frontmatter(p.title, p.description, i+1)
}

dest := filepath.Join(destDir, destName)
content := fm + p.body + "\n"
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
return err
}
}

fmt.Printf("wrote %d reference pages to %s\n", len(files), destDir)
return nil
}

func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: landingdocs <src_dir> <dest_dir>")
os.Exit(1)
}

if err := run(os.Args[1], os.Args[2]); err != nil {
log.Fatal(err)
}
}
76 changes: 76 additions & 0 deletions cmd/landingdocs/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

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

var update = flag.Bool("update", false, "update golden files in testdata/golden")

// TestRun_GeneratesGoldenPages runs the converter end-to-end against a
// small fixture command tree (testdata/src) and checks every generated
// page byte-for-byte against its golden file (testdata/golden).
func TestRun_GeneratesGoldenPages(t *testing.T) {
srcDir := filepath.Join("testdata", "src")
goldenDir := filepath.Join("testdata", "golden")
destDir := t.TempDir()

if err := run(srcDir, destDir); err != nil {
t.Fatalf("run() returned error: %v", err)
}

wantFiles := []string{"_index.md", "qcloud_cluster.md", "qcloud_cluster_create.md"}

got, err := filepath.Glob(filepath.Join(destDir, "*.md"))
if err != nil {
t.Fatalf("glob generated files: %v", err)
}

if len(got) != len(wantFiles) {
t.Fatalf("generated %d files, want %d: %v", len(got), len(wantFiles), got)
}

for _, name := range wantFiles {
gotPath := filepath.Join(destDir, name)
goldenPath := filepath.Join(goldenDir, name)

gotContent, err := os.ReadFile(gotPath)
if err != nil {
t.Fatalf("read generated file %s: %v", gotPath, err)
}

if *update {
if err := os.WriteFile(goldenPath, gotContent, 0o644); err != nil {
t.Fatalf("update golden file %s: %v", goldenPath, err)
}

continue
}

wantContent, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden file %s: %v", goldenPath, err)
}

if string(gotContent) != string(wantContent) {
t.Errorf("generated %s does not match golden file %s\n--- got ---\n%s\n--- want ---\n%s",
name, goldenPath, gotContent, wantContent)
}
}
}

// TestRun_EmptySourceDir checks that run() fails loudly rather than
// silently producing an empty reference section, which would otherwise
// go unnoticed until the landing page shipped with a missing command
// tree.
func TestRun_EmptySourceDir(t *testing.T) {
srcDir := t.TempDir()
destDir := t.TempDir()

err := run(srcDir, destDir)
if err == nil {
t.Fatal("run() with an empty source dir returned nil error, want an error")
}
}
21 changes: 21 additions & 0 deletions cmd/landingdocs/testdata/golden/_index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
title: Command Reference
short_description: "Root command"
description: "Root command"
weight: 0
---

# qcloud

Root command

## Options

```bash
-h, --help help for qcloud
```

## SEE ALSO

* [qcloud cluster](/documentation/cloud-cli/reference/qcloud_cluster/) - Manage clusters

Loading