diff --git a/.github/workflows/comment-sandbox.yaml b/.github/workflows/comment-sandbox.yaml new file mode 100644 index 0000000..3e067eb --- /dev/null +++ b/.github/workflows/comment-sandbox.yaml @@ -0,0 +1,137 @@ +name: Comment Sandbox + +on: + workflow_dispatch: + inputs: + target_number: + description: "Issue or PR number to update when apply=true" + required: false + default: "5" + anchor: + description: "Hidden pipekit anchor to render/select/update" + required: true + default: "pipekit-comment-sandbox" + apply: + description: "Create or update the comment on the target issue/PR" + required: true + type: boolean + default: false + +permissions: + contents: read + issues: write + pull-requests: read + +jobs: + sandbox: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Build pipekit + run: make build + + - name: Render sandbox comment + env: + TARGET_NUMBER: ${{ inputs.target_number }} + ANCHOR: ${{ inputs.anchor }} + run: | + cat > sandbox-body.md < sandbox-data.yaml <> sandbox-body.md + ./dist/pipekit comment render \ + --anchor "${ANCHOR}" \ + --body-file sandbox-body.md \ + --output sandbox-comment.md + + - name: Inspect rendered comment + run: | + ./dist/pipekit comment inspect sandbox-comment.md > sandbox-inspect.json + ./dist/pipekit assert file-exists sandbox-inspect.json sandbox-comment.md + ./dist/pipekit parse extract-block sandbox-comment.md --language yaml --index 0 --content-only \ + > sandbox-block.yaml + test -s sandbox-block.yaml + + - name: Exercise select and amend locally + env: + ANCHOR: ${{ inputs.anchor }} + run: | + cat > comments.json < selected-id.txt + test "$(cat selected-id.txt)" = "1002" + + ./dist/pipekit comment select comments.json --anchor "${ANCHOR}" --format body > selected-body.md + printf '## amended sandbox body\n\nupdated locally\n' > amended-body.md + ./dist/pipekit comment amend selected-body.md \ + --anchor "${ANCHOR}" \ + --body-file amended-body.md \ + --output amended-comment.md + ./dist/pipekit comment inspect amended-comment.md > amended-inspect.json + + - name: Upsert sandbox issue or PR comment + if: ${{ inputs.apply }} + env: + GH_TOKEN: ${{ github.token }} + TARGET_NUMBER: ${{ inputs.target_number }} + ANCHOR: ${{ inputs.anchor }} + run: | + if [ -z "${TARGET_NUMBER}" ]; then + echo "target_number is required when apply=true" >&2 + exit 1 + fi + + gh api "repos/${GITHUB_REPOSITORY}/issues/${TARGET_NUMBER}/comments" > remote-comments.json + + ./dist/pipekit comment payload sandbox-comment.md --output comment-payload.json + + if ./dist/pipekit comment select remote-comments.json --anchor "${ANCHOR}" --format id > remote-comment-id.txt; then + gh api \ + --method PATCH \ + "repos/${GITHUB_REPOSITORY}/issues/comments/$(cat remote-comment-id.txt)" \ + --input comment-payload.json + else + gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/issues/${TARGET_NUMBER}/comments" \ + --input comment-payload.json + fi diff --git a/README.md b/README.md index 825b966..0abfc08 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ -# pipekit -
+ Pipekit — CI/CD pipeline toolkit

Go Version OS Support @@ -84,6 +83,7 @@ More end-to-end recipes → **[docs/EXAMPLES.md](docs/EXAMPLES.md)** | `changelog` | Generate release notes from git commit ranges | [↗](docs/COMMANDS.md#changelog) | | `config` | Resolve env-specific config maps; map branches to environments | [↗](docs/COMMANDS.md#config) | | `parse` | Pull fenced code blocks / YAML / frontmatter out of issue bodies, PR comments, markdown | [↗](docs/COMMANDS.md#parse) | +| `comment` | Render, inspect, select, and amend hidden-anchor PR comments | [↗](docs/COMMANDS.md#comment) | | `json` / `yaml` | Get / set / del / deep-merge / convert / pretty / table on JSON, YAML, TOML, CSV | [↗](docs/COMMANDS.md#json) | | `render` | Render Go templates with a sprig-like FuncMap and stacked `--values` files | [↗](docs/COMMANDS.md#render) | | `exec` | Unified retry + mask + tee + timeout command runner | [↗](docs/COMMANDS.md#exec) | diff --git a/actions/comment.go b/actions/comment.go new file mode 100644 index 0000000..0c8d32a --- /dev/null +++ b/actions/comment.go @@ -0,0 +1,208 @@ +package actions + +import ( + "encoding/json" + "fmt" + "io" + "os" + + "github.com/AxeForging/pipekit/services" + + "github.com/urfave/cli" +) + +// CommentCommand returns the markdown comment command group. +func CommentCommand() cli.Command { + return cli.Command{ + Name: "comment", + Usage: "render, inspect, and amend anchored markdown comments", + Subcommands: []cli.Command{ + { + Name: "anchor", + Usage: "print a hidden pipekit anchor marker", + Action: func(c *cli.Context) error { + name, err := firstArgOrErr(c, "anchor name") + if err != nil { + return err + } + marker, err := services.AnchorMarker(name) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + fmt.Println(marker) + return nil + }, + }, + { + Name: "fence", + Usage: "render stdin or a file as a fenced markdown code block", + Flags: []cli.Flag{ + cli.StringFlag{Name: "language, l", Usage: "code fence language tag"}, + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + }, + Action: func(c *cli.Context) error { + body, err := readInputFileOrStdin(c) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + return writeCommentOutput(c, services.RenderCodeFence(c.String("language"), string(body))) + }, + }, + { + Name: "render", + Usage: "render a markdown comment body with a hidden anchor", + Flags: []cli.Flag{ + cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true}, + cli.StringFlag{Name: "body-file", Usage: "read visible markdown body from file"}, + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + }, + Action: func(c *cli.Context) error { + body, err := readCommentBody(c) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + out, err := services.RenderAnchoredComment(c.String("anchor"), body) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + return writeCommentOutput(c, out) + }, + }, + { + Name: "payload", + Usage: "render stdin or a file as a GitHub comment API payload", + Flags: []cli.Flag{ + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + }, + Action: func(c *cli.Context) error { + body, err := readInputFileOrStdin(c) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + out, err := services.GitHubCommentPayload(string(body)) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + return writeCommentOutput(c, out+"\n") + }, + }, + { + Name: "amend", + Usage: "replace the visible body after a hidden anchor", + Flags: []cli.Flag{ + cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true}, + cli.StringFlag{Name: "body-file", Usage: "read replacement markdown body from file", Required: true}, + cli.StringFlag{Name: "output, o", Usage: "write output to this file"}, + }, + Action: func(c *cli.Context) error { + existing, err := readInputFileOrStdin(c) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + body, err := os.ReadFile(c.String("body-file")) + if err != nil { + return cli.NewExitError(fmt.Sprintf("reading body file: %v", err), 1) + } + out, err := services.AmendAnchoredComment(string(existing), c.String("anchor"), string(body)) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + return writeCommentOutput(c, out) + }, + }, + { + Name: "inspect", + Usage: "inspect anchors and fenced blocks in markdown or GitHub comments JSON", + Action: func(c *cli.Context) error { + r, err := readerFromArgOrStdin(c) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + defer r.Close() + comments, err := services.InspectComments(r) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + return encodeCommentJSON(comments) + }, + }, + { + Name: "select", + Usage: "select the first GitHub comment JSON item containing an anchor", + Flags: []cli.Flag{ + cli.StringFlag{Name: "anchor, a", Usage: "hidden anchor name", Required: true}, + cli.StringFlag{Name: "format, f", Value: "json", Usage: "output format: json, id, body, url"}, + }, + Action: func(c *cli.Context) error { + r, err := readerFromArgOrStdin(c) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + defer r.Close() + comments, err := services.InspectComments(r) + if err != nil { + return cli.NewExitError(err.Error(), 1) + } + comment, ok := services.SelectAnchoredComment(comments, c.String("anchor")) + if !ok { + return cli.NewExitError("no matching anchored comment found", 1) + } + switch c.String("format") { + case "json", "": + return encodeCommentJSON(comment) + case "id": + fmt.Println(comment.ID) + case "body": + fmt.Print(comment.Body) + case "url": + fmt.Println(comment.URL) + default: + return cli.NewExitError("unsupported format: use json, id, body, or url", 1) + } + return nil + }, + }, + }, + } +} + +func readCommentBody(c *cli.Context) (string, error) { + if path := c.String("body-file"); path != "" { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("reading body file: %w", err) + } + return string(data), nil + } + data, err := readBytesFromArgOrStdin(c) + if err != nil { + return "", err + } + return string(data), nil +} + +func readInputFileOrStdin(c *cli.Context) ([]byte, error) { + r, err := readerFromArgOrStdin(c) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +func writeCommentOutput(c *cli.Context, content string) error { + if path := c.String("output"); path != "" { + return os.WriteFile(path, []byte(content), 0644) + } + fmt.Print(content) + return nil +} + +func encodeCommentJSON(v interface{}) error { + data, err := json.Marshal(v) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 13362b1..a755665 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -845,6 +845,112 @@ Supports both `--- ... ---` (YAML) and `+++ ... +++` (TOML) delimiters. Common i --- +## comment + +Render and manage markdown comments with hidden anchors. This is useful for PR bot comments where the visible body should be readable, but automation needs a stable marker to find and update the right comment later. + +Hidden anchors use HTML comments, so GitHub keeps them in the API body but does not render them: + +```md + +``` + +

+comment render — create an anchored comment body + +```sh +pipekit comment render --anchor preview --body-file preview.md > comment.md + +printf '## Preview\n\nReady\n' \ + | pipekit comment render --anchor preview +``` + +| Flag | Description | +|---|---| +| `--anchor, -a` | Hidden anchor name | +| `--body-file` | Read visible markdown body from a file | +| `--output, -o` | Write output to a file | + +
+ +
+comment fence — render safe fenced code blocks + +```sh +pipekit comment fence --language yaml values.yaml + +cat script.js | pipekit comment fence --language js +``` + +The fence is automatically lengthened when the content itself contains triple backticks. + +
+ +
+comment inspect — read anchors and code blocks + +```sh +pipekit comment inspect comment.md + +gh api repos/OWNER/REPO/issues/123/comments \ + | pipekit comment inspect +``` + +Outputs JSON with comment metadata, hidden anchors, and fenced code blocks. + +
+ +
+comment payload — create a GitHub comment API payload + +```sh +pipekit comment payload comment.md > payload.json + +gh api \ + --method POST \ + repos/OWNER/REPO/issues/123/comments \ + --input payload.json +``` + +Outputs JSON in the shape expected by GitHub's issue comments API: + +```json +{"body":"...markdown..."} +``` + +
+ +
+comment select — select a comment by anchor + +```sh +gh api repos/OWNER/REPO/issues/123/comments \ + | pipekit comment select --anchor preview --format id + +gh api repos/OWNER/REPO/issues/123/comments \ + | pipekit comment select --anchor preview --format body > existing.md +``` + +| Flag | Description | +|---|---| +| `--anchor, -a` | Hidden anchor to search for | +| `--format, -f` | `json`, `id`, `body`, or `url` | + +
+ +
+comment amend — replace visible content after an anchor + +```sh +pipekit comment amend existing.md --anchor preview --body-file preview.md > updated.md +``` + +If the input does not contain the anchor, a fresh anchored comment is created. + +
+ +--- + ## json Read, query, mutate, deep-merge, convert, and pretty-print JSON. The `yaml` command is identical except the default format for stdin is YAML — both share the same subcommands and per-file decoding still uses the file's extension (`.json`, `.yaml`, `.toml`, `.csv`). diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index b91ac57..1d13c7d 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -280,6 +280,40 @@ image: ghcr.io/me/app:v1.2.3 …sets `ENV=production`, `REPLICAS=3`, `IMAGE=ghcr.io/me/app:v1.2.3`. +### Updatable PR comments + +Render a readable PR comment with a hidden anchor, then let `gh` create or update the matching comment. + +```yaml +- name: Build preview body + run: | + { + echo "## Preview deployment" + echo + echo "URL: https://preview.example.com" + echo + pipekit comment fence --language yaml preview.yaml + } > preview-body.md + pipekit comment render --anchor preview-deploy --body-file preview-body.md > preview-comment.md + +- name: Upsert PR comment + env: + GH_TOKEN: ${{ github.token }} + run: | + gh api repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + > comments.json + + if pipekit comment select comments.json --anchor preview-deploy --format id > comment-id.txt; then + pipekit comment payload preview-comment.md > payload.json + gh api \ + --method PATCH \ + repos/${{ github.repository }}/issues/comments/$(cat comment-id.txt) \ + --input payload.json + else + gh pr comment ${{ github.event.pull_request.number }} --body-file preview-comment.md + fi +``` + ### Preview deployments Generate a clean, k8s-friendly slug for the preview environment per branch. diff --git a/docs/assets/pipekit-logo.png b/docs/assets/pipekit-logo.png new file mode 100644 index 0000000..cc79c06 Binary files /dev/null and b/docs/assets/pipekit-logo.png differ diff --git a/docs/assets/pipekit-wordmark.png b/docs/assets/pipekit-wordmark.png new file mode 100644 index 0000000..83a0090 Binary files /dev/null and b/docs/assets/pipekit-wordmark.png differ diff --git a/main.go b/main.go index 1934e29..06975b2 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func main() { actions.ChangelogCommand(), actions.ConfigCommand(), actions.ParseCommand(), + actions.CommentCommand(), actions.JSONCommand(), actions.YAMLCommand(), actions.RenderCommand(), diff --git a/services/comment_service.go b/services/comment_service.go new file mode 100644 index 0000000..90014a9 --- /dev/null +++ b/services/comment_service.go @@ -0,0 +1,231 @@ +package services + +import ( + "encoding/json" + "fmt" + "io" + "regexp" + "strconv" + "strings" +) + +// HiddenAnchor represents a non-rendered HTML marker in markdown. +type HiddenAnchor struct { + Name string `json:"name"` + Marker string `json:"marker"` + Start int `json:"start"` + End int `json:"end"` +} + +// CommentInspection is a structured view of a markdown or GitHub comment body. +type CommentInspection struct { + ID string `json:"id,omitempty"` + URL string `json:"url,omitempty"` + Author string `json:"author,omitempty"` + Body string `json:"body"` + Anchors []HiddenAnchor `json:"anchors,omitempty"` + Blocks []CodeBlock `json:"blocks,omitempty"` +} + +var hiddenAnchorRegex = regexp.MustCompile(``) + +// AnchorMarker returns the hidden markdown marker for an anchor name. +func AnchorMarker(name string) (string, error) { + name = strings.TrimSpace(name) + if name == "" { + return "", fmt.Errorf("anchor name required") + } + if strings.Contains(name, "--") || strings.ContainsAny(name, "<>\r\n\t ") { + return "", fmt.Errorf("invalid anchor name %q", name) + } + return fmt.Sprintf("", name), nil +} + +// FindHiddenAnchors extracts pipekit hidden anchors from a markdown body. +func FindHiddenAnchors(body string) []HiddenAnchor { + matches := hiddenAnchorRegex.FindAllStringSubmatchIndex(body, -1) + anchors := make([]HiddenAnchor, 0, len(matches)) + for _, match := range matches { + anchors = append(anchors, HiddenAnchor{ + Name: body[match[2]:match[3]], + Marker: body[match[0]:match[1]], + Start: match[0], + End: match[1], + }) + } + return anchors +} + +// RenderAnchoredComment creates a markdown comment body with a hidden anchor. +func RenderAnchoredComment(anchor, body string) (string, error) { + marker, err := AnchorMarker(anchor) + if err != nil { + return "", err + } + return marker + "\n\n" + strings.TrimRight(body, "\n") + "\n", nil +} + +// GitHubCommentPayload returns a JSON object suitable for the issue comments API. +func GitHubCommentPayload(body string) (string, error) { + data, err := json.Marshal(map[string]string{"body": body}) + if err != nil { + return "", fmt.Errorf("marshaling comment payload: %w", err) + } + return string(data), nil +} + +// AmendAnchoredComment replaces everything after a hidden anchor with body. +// If the anchor is not present, it returns a new anchored comment. +func AmendAnchoredComment(existing, anchor, body string) (string, error) { + marker, err := AnchorMarker(anchor) + if err != nil { + return "", err + } + for _, found := range FindHiddenAnchors(existing) { + if found.Name == anchor { + prefix := strings.TrimRight(existing[:found.End], "\n") + return prefix + "\n\n" + strings.TrimRight(body, "\n") + "\n", nil + } + } + return marker + "\n\n" + strings.TrimRight(body, "\n") + "\n", nil +} + +// RenderCodeFence renders markdown code using a fence long enough for content. +func RenderCodeFence(language, content string) string { + fence := strings.Repeat("`", maxBacktickRun(content)+1) + if len(fence) < 3 { + fence = "```" + } + var b strings.Builder + b.WriteString(fence) + b.WriteString(strings.TrimSpace(language)) + b.WriteByte('\n') + b.WriteString(strings.TrimRight(content, "\n")) + b.WriteByte('\n') + b.WriteString(fence) + b.WriteByte('\n') + return b.String() +} + +func maxBacktickRun(s string) int { + maxRun, run := 0, 0 + for _, r := range s { + if r == '`' { + run++ + if run > maxRun { + maxRun = run + } + continue + } + run = 0 + } + return maxRun +} + +// InspectMarkdownComment returns anchors and fenced code blocks from markdown. +func InspectMarkdownComment(body string) (CommentInspection, error) { + blocks, err := ExtractCodeBlocks(strings.NewReader(body), "") + if err != nil { + return CommentInspection{}, err + } + return CommentInspection{ + Body: body, + Anchors: FindHiddenAnchors(body), + Blocks: blocks, + }, nil +} + +// InspectComments reads either a GitHub comments JSON array/object or raw markdown. +func InspectComments(r io.Reader) ([]CommentInspection, error) { + data, err := io.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("reading input: %w", err) + } + trimmed := strings.TrimSpace(string(data)) + if strings.HasPrefix(trimmed, "[") { + return inspectGitHubCommentArray(data) + } + if strings.HasPrefix(trimmed, "{") { + item, err := inspectGitHubCommentObject(data) + if err == nil && item.Body != "" { + return []CommentInspection{item}, nil + } + } + item, err := InspectMarkdownComment(string(data)) + if err != nil { + return nil, err + } + return []CommentInspection{item}, nil +} + +// SelectAnchoredComment returns the first inspected comment with anchor. +func SelectAnchoredComment(comments []CommentInspection, anchor string) (CommentInspection, bool) { + for _, comment := range comments { + for _, found := range comment.Anchors { + if found.Name == anchor { + return comment, true + } + } + } + return CommentInspection{}, false +} + +func inspectGitHubCommentArray(data []byte) ([]CommentInspection, error) { + var raw []map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing comments JSON: %w", err) + } + out := make([]CommentInspection, 0, len(raw)) + for _, item := range raw { + inspected, err := inspectGitHubCommentMap(item) + if err != nil { + return nil, err + } + out = append(out, inspected) + } + return out, nil +} + +func inspectGitHubCommentObject(data []byte) (CommentInspection, error) { + var raw map[string]interface{} + if err := json.Unmarshal(data, &raw); err != nil { + return CommentInspection{}, err + } + return inspectGitHubCommentMap(raw) +} + +func inspectGitHubCommentMap(item map[string]interface{}) (CommentInspection, error) { + body, _ := item["body"].(string) + inspected, err := InspectMarkdownComment(body) + if err != nil { + return CommentInspection{}, err + } + inspected.ID = stringifyJSONValue(item["id"]) + inspected.URL = firstString(item, "html_url", "url") + if user, ok := item["user"].(map[string]interface{}); ok { + inspected.Author, _ = user["login"].(string) + } + return inspected, nil +} + +func stringifyJSONValue(v interface{}) string { + switch t := v.(type) { + case string: + return t + case float64: + return strconv.FormatInt(int64(t), 10) + case json.Number: + return t.String() + default: + return "" + } +} + +func firstString(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v, ok := item[key].(string); ok && v != "" { + return v + } + } + return "" +} diff --git a/services/comment_service_test.go b/services/comment_service_test.go new file mode 100644 index 0000000..92b22f3 --- /dev/null +++ b/services/comment_service_test.go @@ -0,0 +1,213 @@ +package services + +import ( + "strings" + "testing" +) + +func TestAnchorMarker(t *testing.T) { + got, err := AnchorMarker("preview/deploy") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("unexpected marker: %q", got) + } +} + +func TestAnchorMarkerRejectsUnsafeNames(t *testing.T) { + for _, name := range []string{"", "bad name", "bad--name", "bad"}` + if _, err := InspectComments(strings.NewReader(input)); err == nil { + t.Fatal("expected malformed JSON error") + } +} + +func TestInspectCommentsGitHubArrayAndSelect(t *testing.T) { + input := "[\n" + + `{"id": 101, "html_url": "https://github.test/1", "user": {"login": "bot"}, "body": "plain"},` + "\n" + + `{"id": 102, "html_url": "https://github.test/2", "user": {"login": "bot"}, "body": "\n\n` + "```js\\nconsole.log(1)\\n```" + `"}` + input += "\n]" + comments, err := InspectComments(strings.NewReader(input)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(comments) != 2 { + t.Fatalf("expected 2 comments, got %d", len(comments)) + } + selected, ok := SelectAnchoredComment(comments, "preview") + if !ok { + t.Fatal("expected selected comment") + } + if selected.ID != "102" || selected.URL != "https://github.test/2" || selected.Author != "bot" { + t.Fatalf("unexpected selected metadata: %#v", selected) + } + if len(selected.Blocks) != 1 || selected.Blocks[0].Language != "js" { + t.Fatalf("unexpected selected blocks: %#v", selected.Blocks) + } +} + +func TestSelectAnchoredCommentReturnsFirstExactMatch(t *testing.T) { + comments := []CommentInspection{ + { + ID: "1", + Anchors: []HiddenAnchor{{Name: "preview-db"}}, + }, + { + ID: "2", + Anchors: []HiddenAnchor{{Name: "preview"}}, + }, + { + ID: "3", + Anchors: []HiddenAnchor{{Name: "preview"}}, + }, + } + selected, ok := SelectAnchoredComment(comments, "preview") + if !ok { + t.Fatal("expected exact match") + } + if selected.ID != "2" { + t.Fatalf("expected first exact match id 2, got %q", selected.ID) + } +} + +func TestSelectAnchoredCommentNoMatch(t *testing.T) { + comments := []CommentInspection{{ID: "1", Anchors: []HiddenAnchor{{Name: "other"}}}} + if selected, ok := SelectAnchoredComment(comments, "preview"); ok { + t.Fatalf("expected no match, got %#v", selected) + } +} diff --git a/services/parse_service.go b/services/parse_service.go index 15d7bd7..3122190 100644 --- a/services/parse_service.go +++ b/services/parse_service.go @@ -17,9 +17,6 @@ type CodeBlock struct { Index int `json:"index"` } -// fencedBlockRegex matches ``` or ~~~ fenced code blocks with optional language identifier. -var fencedBlockRegex = regexp.MustCompile("(?m)^(?:```|~~~)(\\S*)\\s*\\n((?:.|\\n)*?)^(?:```|~~~)\\s*$") - // ExtractCodeBlocks extracts all fenced code blocks from markdown/text input. // If language is non-empty, only blocks matching that language are returned. // Language matching is case-insensitive. @@ -29,30 +26,14 @@ func ExtractCodeBlocks(r io.Reader, language string) ([]CodeBlock, error) { return nil, fmt.Errorf("reading input: %w", err) } - matches := fencedBlockRegex.FindAllSubmatch(data, -1) - if len(matches) == 0 { - return nil, nil - } - + allBlocks := scanCodeBlocks(string(data)) var blocks []CodeBlock - idx := 0 - for _, match := range matches { - lang := strings.TrimSpace(string(match[1])) - content := string(match[2]) - - // Remove trailing newline from content - content = strings.TrimRight(content, "\n") - - if language != "" && !strings.EqualFold(lang, language) { + for _, block := range allBlocks { + if language != "" && !strings.EqualFold(block.Language, language) { continue } - - blocks = append(blocks, CodeBlock{ - Language: lang, - Content: content, - Index: idx, - }) - idx++ + block.Index = len(blocks) + blocks = append(blocks, block) } return blocks, nil @@ -66,23 +47,16 @@ func ExtractAndParseYAML(r io.Reader) ([]map[string]interface{}, error) { return nil, fmt.Errorf("reading input: %w", err) } - matches := fencedBlockRegex.FindAllSubmatch(data, -1) - if len(matches) == 0 { - return nil, nil - } - var results []map[string]interface{} - for _, match := range matches { - lang := strings.ToLower(strings.TrimSpace(string(match[1]))) - content := string(match[2]) - + for _, block := range scanCodeBlocks(string(data)) { + lang := strings.ToLower(strings.TrimSpace(block.Language)) // Only process yaml/yml blocks (or untagged blocks) if lang != "" && lang != "yaml" && lang != "yml" { continue } var parsed map[string]interface{} - if err := yaml.Unmarshal([]byte(content), &parsed); err != nil { + if err := yaml.Unmarshal([]byte(block.Content), &parsed); err != nil { continue // skip blocks that don't parse as YAML } if parsed != nil { @@ -93,6 +67,72 @@ func ExtractAndParseYAML(r io.Reader) ([]map[string]interface{}, error) { return results, nil } +func scanCodeBlocks(input string) []CodeBlock { + lines := strings.Split(input, "\n") + var blocks []CodeBlock + for i := 0; i < len(lines); i++ { + fenceChar, fenceLen, lang, ok := parseOpeningFence(lines[i]) + if !ok { + continue + } + + contentStart := i + 1 + for j := contentStart; j < len(lines); j++ { + if isClosingFence(lines[j], fenceChar, fenceLen) { + content := strings.Join(lines[contentStart:j], "\n") + blocks = append(blocks, CodeBlock{ + Language: lang, + Content: strings.TrimRight(content, "\n"), + Index: len(blocks), + }) + i = j + break + } + } + } + return blocks +} + +func parseOpeningFence(line string) (rune, int, string, bool) { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + return 0, 0, "", false + } + fenceChar := rune(trimmed[0]) + if fenceChar != '`' && fenceChar != '~' { + return 0, 0, "", false + } + fenceLen := countFenceRun(trimmed, fenceChar) + if fenceLen < 3 { + return 0, 0, "", false + } + rest := strings.TrimSpace(trimmed[fenceLen:]) + if strings.ContainsRune(rest, fenceChar) { + return 0, 0, "", false + } + return fenceChar, fenceLen, rest, true +} + +func isClosingFence(line string, fenceChar rune, fenceLen int) bool { + trimmed := strings.TrimLeft(line, " \t") + run := countFenceRun(trimmed, fenceChar) + if run < fenceLen { + return false + } + return strings.TrimSpace(trimmed[run:]) == "" +} + +func countFenceRun(s string, fenceChar rune) int { + count := 0 + for _, r := range s { + if r != fenceChar { + break + } + count++ + } + return count +} + // frontmatterRegex matches a YAML or TOML frontmatter block at the very // start of the input. Supports `---` (YAML) and `+++` (TOML) delimiters. var frontmatterRegex = regexp.MustCompile(`(?s)\A(?:---|\+\+\+)\n(.+?)\n(?:---|\+\+\+)\s*\n?`) @@ -110,7 +150,7 @@ func ExtractFrontmatter(r io.Reader) ([]byte, string, error) { return nil, "", nil } body := data[loc[2]:loc[3]] - delim := string(data[loc[0]:loc[0]+3]) + delim := string(data[loc[0] : loc[0]+3]) if delim == "+++" { return body, "toml", nil } diff --git a/services/parse_service_test.go b/services/parse_service_test.go index 25fdc4f..f469511 100644 --- a/services/parse_service_test.go +++ b/services/parse_service_test.go @@ -70,6 +70,66 @@ func TestExtractCodeBlocks_TildeFence(t *testing.T) { } } +func TestExtractCodeBlocks_LongFenceWithNestedBackticks(t *testing.T) { + input := "````md\nbefore\n```yaml\nname: test\n```\nafter\n````\n" + + blocks, err := ExtractCodeBlocks(strings.NewReader(input), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + if blocks[0].Language != "md" { + t.Errorf("expected md, got %q", blocks[0].Language) + } + if !strings.Contains(blocks[0].Content, "```yaml\nname: test\n```") { + t.Errorf("expected nested fence to be preserved, got %q", blocks[0].Content) + } +} + +func TestExtractCodeBlocks_IndentedFenceAndLongerClosingFence(t *testing.T) { + input := " ```yaml\nname: test\n````\n" + + blocks, err := ExtractCodeBlocks(strings.NewReader(input), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + if blocks[0].Language != "yaml" || blocks[0].Content != "name: test" { + t.Fatalf("unexpected block: %#v", blocks[0]) + } +} + +func TestExtractCodeBlocks_IgnoresUnterminatedFence(t *testing.T) { + input := "before\n```yaml\nname: test\n" + + blocks, err := ExtractCodeBlocks(strings.NewReader(input), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(blocks) != 0 { + t.Fatalf("expected unterminated fence to be ignored, got %#v", blocks) + } +} + +func TestExtractCodeBlocks_IgnoresShortFenceBeforeValidBlock(t *testing.T) { + input := "``yaml\nignored\n``\n```yaml\nname: test\n```\n" + + blocks, err := ExtractCodeBlocks(strings.NewReader(input), "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(blocks) != 1 { + t.Fatalf("expected only valid block, got %#v", blocks) + } + if blocks[0].Language != "yaml" || blocks[0].Content != "name: test" { + t.Fatalf("unexpected block: %#v", blocks[0]) + } +} + func TestExtractCodeBlocks_NoLanguage(t *testing.T) { input := "```\nplain text\n```\n"