diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go index 9b47c3e8..0b72bf0d 100644 --- a/cmd/docs/docs.go +++ b/cmd/docs/docs.go @@ -15,10 +15,14 @@ package docs import ( + "context" + "encoding/json" "fmt" "net/url" + "path/filepath" "strings" + "github.com/slackapi/slack-cli/internal/search" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/slacktrace" @@ -27,6 +31,9 @@ import ( ) var searchMode bool +var outputFormat string +var searchLimit int +var searchOffset int func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ @@ -43,8 +50,16 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { Command: "docs --search \"Block Kit\"", }, { - Meaning: "Open Slack docs search page", - Command: "docs --search", + Meaning: "Search and get JSON results", + Command: "docs --search \"Block Kit\" --output=json", + }, + { + Meaning: "Search with custom limit", + Command: "docs --search \"Block Kit\" --output=json --limit=50", + }, + { + Meaning: "Search with pagination", + Command: "docs --search \"Block Kit\" --output=json --limit=20 --offset=20", }, }), RunE: func(cmd *cobra.Command, args []string) error { @@ -52,17 +67,43 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { }, } - cmd.Flags().BoolVar(&searchMode, "search", false, "open Slack docs search page or search with query") + cmd.Flags().BoolVar(&searchMode, "search", false, "search Slack docs with optional query") + cmd.Flags().StringVar(&outputFormat, "output", "browser", "output format: browser, json") + cmd.Flags().IntVar(&searchLimit, "limit", 20, "maximum number of results to return") + cmd.Flags().IntVar(&searchOffset, "offset", 0, "number of results to skip (for pagination)") return cmd } -// runDocsCommand opens Slack developer docs in the browser +// DocsOutput represents the structured output for --json mode +type DocsOutput struct { + URL string `json:"url"` + Query string `json:"query,omitempty"` + Type string `json:"type"` // "homepage", "search", or "search_with_query" +} + +// ProgrammaticSearchOutput represents the output from local docs search +type ProgrammaticSearchOutput = search.SearchResponse + +// findDocsRepo tries to locate the docs repository +func findDocsRepo() string { + return search.FindDocsRepo() +} + +// runProgrammaticSearch executes the local search +func runProgrammaticSearch(query string, docsPath string) (*ProgrammaticSearchOutput, error) { + contentDir := filepath.Join(docsPath, "content") + return search.SearchDocs(query, "", searchLimit, searchOffset, contentDir) +} + +// runDocsCommand opens Slack developer docs in the browser or performs programmatic search func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { ctx := cmd.Context() var docsURL string var sectionText string + var query string + var docType string // Validate: if there are arguments, --search flag must be used if len(args) > 0 && !cmd.Flags().Changed("search") { @@ -75,22 +116,58 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st if cmd.Flags().Changed("search") { if len(args) > 0 { - // --search "query" (space-separated) - join all args as the query - query := strings.Join(args, " ") + query = strings.Join(args, " ") + + // Check output format + if outputFormat == "json" { + return runProgrammaticSearchCommand(clients, ctx, query) + } + + // Default browser search encodedQuery := url.QueryEscape(query) docsURL = fmt.Sprintf("https://docs.slack.dev/search/?q=%s", encodedQuery) sectionText = "Docs Search" + docType = "search_with_query" } else { // --search (no argument) - open search page docsURL = "https://docs.slack.dev/search/" sectionText = "Docs Search" + docType = "search" } } else { // No search flag: default homepage docsURL = "https://docs.slack.dev" sectionText = "Docs Open" + docType = "homepage" } + // Handle JSON output mode (for browser-based results only) + if outputFormat == "json" && !cmd.Flags().Changed("search") { + output := DocsOutput{ + URL: docsURL, + Query: query, + Type: docType, + } + + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return slackerror.New(slackerror.ErrDocsJSONEncodeFailed) + } + + fmt.Println(string(jsonBytes)) + + // Still print trace for analytics + if cmd.Flags().Changed("search") { + traceValue := query + clients.IO.PrintTrace(ctx, slacktrace.DocsSearchSuccess, traceValue) + } else { + clients.IO.PrintTrace(ctx, slacktrace.DocsSuccess) + } + + return nil + } + + // Standard browser-opening mode clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "books", Text: sectionText, @@ -113,3 +190,32 @@ func runDocsCommand(clients *shared.ClientFactory, cmd *cobra.Command, args []st return nil } + +// runProgrammaticSearchCommand handles local documentation search +func runProgrammaticSearchCommand(clients *shared.ClientFactory, ctx context.Context, query string) error { + // Find the docs repository + docsPath := findDocsRepo() + if docsPath == "" { + clients.IO.PrintError(ctx, "❌ Docs repository not found") + clients.IO.PrintInfo(ctx, false, "💡 Make sure the docs repository is cloned alongside slack-cli") + clients.IO.PrintInfo(ctx, false, " Expected structure:") + clients.IO.PrintInfo(ctx, false, " ├── slack-cli/") + clients.IO.PrintInfo(ctx, false, " └── docs/") + return fmt.Errorf("docs repository not found") + } + + // Run the search + results, err := runProgrammaticSearch(query, docsPath) + if err != nil { + clients.IO.PrintError(ctx, "❌ Search failed: %v", err) + return err + } + + // Always output JSON for programmatic search + jsonBytes, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("failed to encode JSON: %w", err) + } + fmt.Println(string(jsonBytes)) + return nil +} diff --git a/internal/search/search.go b/internal/search/search.go new file mode 100644 index 00000000..4bda2d49 --- /dev/null +++ b/internal/search/search.go @@ -0,0 +1,466 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package search + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +const SiteURL = "https://docs.slack.dev" + +// SearchResult represents a single search result +type SearchResult struct { + Title string `json:"title"` + URL string `json:"url"` + Snippet string `json:"snippet"` + Type string `json:"type"` + Score int `json:"-"` // Used for sorting, not exported to JSON +} + +// SearchResponse represents the complete search response +type SearchResponse struct { + Query string `json:"query"` + Filter string `json:"filter"` + Results []SearchResult `json:"results"` + Total int `json:"total"` + Showing int `json:"showing"` + Pagination *PaginationInfo `json:"pagination,omitempty"` +} + +// PaginationInfo provides pagination metadata +type PaginationInfo struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Page int `json:"page"` // 1-based page number + HasNext bool `json:"has_next"` + HasPrevious bool `json:"has_previous"` +} + +// FrontMatter represents the YAML frontmatter in markdown files +type FrontMatter struct { + Title string + Unlisted bool +} + +// parseFrontMatter extracts frontmatter from markdown content +func parseFrontMatter(content string) (*FrontMatter, string) { + // Check if content starts with frontmatter + if !strings.HasPrefix(content, "---\n") { + return &FrontMatter{}, content + } + + // Find the closing --- + lines := strings.Split(content, "\n") + var endIndex int + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + endIndex = i + break + } + } + + if endIndex == 0 { + return &FrontMatter{}, content + } + + // Parse frontmatter lines + fm := &FrontMatter{} + for i := 1; i < endIndex; i++ { + line := strings.TrimSpace(lines[i]) + if strings.HasPrefix(line, "title:") { + title := strings.TrimSpace(strings.TrimPrefix(line, "title:")) + // Remove quotes if present + title = strings.Trim(title, `"'`) + fm.Title = title + } else if strings.HasPrefix(line, "unlisted:") { + unlisted := strings.TrimSpace(strings.TrimPrefix(line, "unlisted:")) + fm.Unlisted = unlisted == "true" + } + } + + // Return body content (everything after frontmatter) + bodyLines := lines[endIndex+1:] + body := strings.Join(bodyLines, "\n") + return fm, body +} + +// extractTitle attempts to extract title from markdown content +func extractTitle(content string) string { + // Try H1 heading + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + return strings.TrimSpace(strings.TrimPrefix(line, "# ")) + } + } + + // Try HTML h1 + re := regexp.MustCompile(`