Go · 4053 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package markdown
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9
10 "github.com/yuin/goldmark"
11 "github.com/yuin/goldmark/extension"
12 "github.com/yuin/goldmark/parser"
13 "github.com/yuin/goldmark/renderer"
14 gmhtml "github.com/yuin/goldmark/renderer/html"
15
16 "github.com/tenseleyFlow/shithub/internal/markdown/extensions"
17 )
18
19 // ErrInputTooLarge is returned when source exceeds MaxRenderInputBytes.
20 // Callers should reject the input at the API layer before reaching
21 // Render; this is the renderer's defensive fallback.
22 var ErrInputTooLarge = errors.New("markdown: source exceeds MaxRenderInputBytes")
23
24 // Render is the canonical markdown entry point. The output bytes
25 // have already been Goldmark-rendered AND bluemonday-sanitized;
26 // callers can wrap them as `template.HTML` and inject into a
27 // template directly.
28 //
29 // `refs` and `mentions` carry the resolved cross-reference/mention
30 // state for downstream consumers (S29 notification fan-out, S21
31 // issue_references index). The order matches first-occurrence in
32 // source.
33 //
34 // A nil ctx is fine; resolvers receive context.Background() in
35 // that case.
36 func Render(ctx context.Context, src []byte, opts Options) (rendered []byte, refs []Ref, mentions []Mention, err error) {
37 if len(src) == 0 {
38 return nil, nil, nil, nil
39 }
40 if len(src) > MaxRenderInputBytes {
41 return nil, nil, nil, ErrInputTooLarge
42 }
43 if ctx == nil {
44 ctx = context.Background()
45 }
46
47 // Build a fresh Goldmark for each render so the per-call
48 // extension Options can plug in without races. The build is
49 // cheap (~µs) and removes any cross-render contamination of
50 // the AST transformer state.
51 xRefs := []extensions.Ref{}
52 xMentions := []extensions.Mention{}
53 xOpts := &extensions.Options{
54 Ctx: ctx,
55 ViewerUserID: opts.ViewerUserID,
56 Refs: &xRefs,
57 Mentions: &xMentions,
58 Resolvers: extensions.Resolvers{
59 User: opts.Resolvers.User,
60 Issue: opts.Resolvers.Issue,
61 Commit: opts.Resolvers.Commit,
62 },
63 }
64 if opts.Repo != nil {
65 xOpts.RepoOwner = opts.Repo.OwnerUsername
66 xOpts.RepoName = opts.Repo.RepoName
67 }
68
69 // gmhtml.WithUnsafe lets raw HTML through the parser → bluemonday
70 // scrubs at the sanitizer pass. Without this, every <details>,
71 // <kbd>, <sup>, etc. that users type would be HTML-escaped before
72 // the sanitizer ever sees them. The strict policy in sanitize.go
73 // is the security boundary.
74 htmlOpts := []renderer.Option{gmhtml.WithXHTML(), gmhtml.WithUnsafe()}
75 if opts.SoftBreakAsBR {
76 htmlOpts = append(htmlOpts, gmhtml.WithHardWraps())
77 }
78
79 gm := goldmark.New(
80 goldmark.WithExtensions(
81 extension.GFM, // tables, strikethrough, autolinks, task list
82 extensions.New(xOpts),
83 ),
84 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
85 goldmark.WithRendererOptions(htmlOpts...),
86 )
87
88 var buf bytes.Buffer
89 if err := gm.Convert(src, &buf); err != nil {
90 return nil, nil, nil, err
91 }
92 clean := sanitizeBytes(buf.Bytes())
93
94 // Convert extension-local types into the public types.
95 if len(xRefs) > 0 {
96 refs = make([]Ref, len(xRefs))
97 for i, r := range xRefs {
98 refs[i] = Ref{
99 Kind: r.Kind,
100 Owner: r.Owner,
101 Repo: r.Repo,
102 Number: r.Number,
103 FullSHA: r.FullSHA,
104 Href: r.Href,
105 }
106 }
107 }
108 if len(xMentions) > 0 {
109 mentions = make([]Mention, len(xMentions))
110 for i, m := range xMentions {
111 mentions[i] = Mention{Username: m.Username, Href: m.Href}
112 }
113 }
114 return clean, refs, mentions, nil
115 }
116
117 // RenderHTML is the back-compat shim for callers that don't need
118 // resolved refs/mentions. SoftBreakAsBR defaults to true (matches
119 // the comment-style legacy behavior of the S17 helper this replaces).
120 //
121 // The signature matches `internal/repos/markdown.RenderHTML` so
122 // existing callers can swap the import path without code changes.
123 // New callers should prefer Render directly.
124 func RenderHTML(src []byte) (string, error) {
125 out, _, _, err := Render(context.Background(), src, Options{SoftBreakAsBR: true})
126 if err != nil {
127 return "", err
128 }
129 return string(out), nil
130 }
131