mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: surface template README to agent template tools (#26334)
Fixes CODAGT-447. Alternative implementation of https://github.com/coder/coder/pull/26212 and https://github.com/coder/coder/pull/25978 - Adds up to the first 1000 characters of `README.md` (with leading frontmatter stripped) to `chattool.list_templates` output - Adds up to 800 characters of `README.md` to `chattool.read_template`. **Note:** skipping `toolsdk` versions to keep scope small. > 🤖 Generated by Coder Agents
This commit is contained in:
@@ -9,9 +9,22 @@ import (
|
||||
gomarkdown "github.com/gomarkdown/markdown"
|
||||
"github.com/gomarkdown/markdown/html"
|
||||
"github.com/gomarkdown/markdown/parser"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
goldmarkhtml "github.com/yuin/goldmark/renderer/html"
|
||||
xhtml "golang.org/x/net/html"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
// innerTextMarkdown converts Markdown to HTML for InnerTextFromMarkdown. Table
|
||||
// renders cells as text (not pipe-delimited lines); WithUnsafe lets embedded raw
|
||||
// HTML through so its inner text survives. Safe to share: goldmark inits the
|
||||
// parser once via sync.Once, then only reads it.
|
||||
var innerTextMarkdown = goldmark.New(
|
||||
goldmark.WithExtensions(extension.Table),
|
||||
goldmark.WithRendererOptions(goldmarkhtml.WithUnsafe()),
|
||||
)
|
||||
|
||||
var plaintextStyle = ansi.StyleConfig{
|
||||
Document: ansi.StyleBlock{
|
||||
StylePrimitive: ansi.StylePrimitive{},
|
||||
@@ -108,3 +121,56 @@ func HTMLFromMarkdown(markdown string) string {
|
||||
})
|
||||
return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer)))
|
||||
}
|
||||
|
||||
// InnerTextFromMarkdown renders Markdown (including embedded raw HTML) to HTML
|
||||
// and returns its visible text ("innerText"). Block, code-line, and table-cell
|
||||
// boundaries become newlines and intra-line whitespace is collapsed; link text
|
||||
// is kept but URLs, images, and badges are dropped.
|
||||
//
|
||||
// Input is untrusted: a parser panic is recovered and returned as an error.
|
||||
func InnerTextFromMarkdown(markdown string) (out string, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
out, err = "", xerrors.Errorf("render markdown to innertext: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var rendered bytes.Buffer
|
||||
if convErr := innerTextMarkdown.Convert([]byte(markdown), &rendered); convErr != nil {
|
||||
return "", xerrors.Errorf("convert markdown to html: %w", convErr)
|
||||
}
|
||||
|
||||
z := xhtml.NewTokenizer(&rendered)
|
||||
var b strings.Builder
|
||||
// script and style are raw-text elements: their body is the single text token
|
||||
// after the start tag. Skip just that token (not a running depth) so a stray
|
||||
// </script> or unterminated tag can't swallow the rest of the document.
|
||||
skipNextText := false
|
||||
for {
|
||||
if z.Next() == xhtml.ErrorToken {
|
||||
break // includes io.EOF
|
||||
}
|
||||
switch tok := z.Token(); tok.Type {
|
||||
case xhtml.StartTagToken:
|
||||
skipNextText = tok.Data == "script" || tok.Data == "style"
|
||||
case xhtml.TextToken:
|
||||
if skipNextText {
|
||||
skipNextText = false
|
||||
continue
|
||||
}
|
||||
_, _ = b.WriteString(tok.Data)
|
||||
default:
|
||||
skipNextText = false
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse intra-line whitespace but keep newlines so code lines, table
|
||||
// cells, and block boundaries stay on separate lines; drop blank lines.
|
||||
var lines []string
|
||||
for _, line := range strings.Split(b.String(), "\n") {
|
||||
if f := strings.Join(strings.Fields(line), " "); f != "" {
|
||||
lines = append(lines, f)
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
@@ -87,3 +87,59 @@ func TestHTML(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInnerTextFromMarkdown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"LinkTextKeptUrlDropped", "Use [Coder](https://coder.com/docs) now.", "Use Coder now."},
|
||||
{"ImageDropped", "# T\n\n\n\nBody.", "T\nBody."},
|
||||
{"BadgeDropped", "[](https://discord.gg/x)\n\nReal.", "Real."},
|
||||
{"CodeBlockLinesKept", "Intro.\n\n```sh\nnpm install\nnpm run dev\n```\n\nOutro.", "Intro.\nnpm install\nnpm run dev\nOutro."},
|
||||
{"TableCellsKept", "Before.\n\n| env | required |\n|---|---|\n| FOO | yes |\n\nAfter.", "Before.\nenv\nrequired\nFOO\nyes\nAfter."},
|
||||
{"HtmlInnerTextKept", "<p>Important: needs GPU.</p>", "Important: needs GPU."},
|
||||
{
|
||||
// Markdown nested inside a block-level HTML wrapper must still be
|
||||
// parsed (CommonMark terminates the HTML block at the blank line):
|
||||
// nav links collapse to text, badges drop. Regresses the gomarkdown
|
||||
// behavior that leaked raw badge markdown with URLs.
|
||||
"MarkdownInsideHtmlBlock",
|
||||
"<div align=\"center\">\n <img src=\"logo.png\" alt=\"Logo\">\n</div>\n\n" +
|
||||
"[Docs](https://x.com/docs) | [Why](https://x.com/why)\n\n" +
|
||||
"[](https://x.com)\n\nReal prose.",
|
||||
"Docs | Why\nReal prose.",
|
||||
},
|
||||
{"ScriptDropped", "Before.\n\n<script>alert('x')</script>\n\nAfter.", "Before.\nAfter."},
|
||||
// An empty-body <script src=...> must not leave the skip armed and eat the
|
||||
// next text run (guards the skipNextText reset on non-text tokens).
|
||||
{"ScriptSrcEmptyBody", "Before.\n\n<script src=\"x.js\"></script>\n\nAfter.", "Before.\nAfter."},
|
||||
{"StyleDropped", "Before.\n\n<style>.x{color:red}</style>\n\nAfter.", "Before.\nAfter."},
|
||||
// A bare </script> in prose must not underflow the skip and swallow what
|
||||
// follows.
|
||||
{"BareScriptCloseNoUnderflow", "Before.\n\n</script>\n\nAfter.", "Before.\nAfter."},
|
||||
// An unterminated raw-text element is, per the HTML spec, a single run to
|
||||
// EOF, so the remainder is unavoidably consumed; it must not error.
|
||||
{"UnterminatedScriptEatsRest", "Intro.\n\n<script>\nvar x = 1;\n\nMore prose.", "Intro."},
|
||||
{"EmphasisAndCodeSpanFlattened", "Run `make` for **speed**.", "Run make for speed."},
|
||||
{"HeadingParagraphOrder", "# Title\n\nLead.\n\n## Prereq\n\nDetail.", "Title\nLead.\nPrereq\nDetail."},
|
||||
// Straight ASCII punctuation must stay ASCII (goldmark applies no
|
||||
// Typographer), and existing smart punctuation passes through unchanged.
|
||||
// The smart characters are \u-escaped so the docs linter does not rewrite
|
||||
// them back to ASCII in source.
|
||||
{"PunctuationNotRewritten", "Range 10\u201420, \"q\", ... and smart \u201cq\u201d \u2014 \u2026", "Range 10\u201420, \"q\", ... and smart \u201cq\u201d \u2014 \u2026"},
|
||||
{"EmptyReturnsEmpty", "", ""},
|
||||
{"WhitespaceReturnsEmpty", " \n\t\n", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := render.InnerTextFromMarkdown(tt.input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user