Go · 4436 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 Team: opts.Resolvers.Team,
63 },
64 }
65 if opts.Repo != nil {
66 xOpts.RepoOwner = opts.Repo.OwnerUsername
67 xOpts.RepoName = opts.Repo.RepoName
68 }
69
70 // gmhtml.WithUnsafe lets raw HTML through the parser → bluemonday
71 // scrubs at the sanitizer pass. Without this, every <details>,
72 // <kbd>, <sup>, etc. that users type would be HTML-escaped before
73 // the sanitizer ever sees them. The strict policy in sanitize.go
74 // is the security boundary.
75 htmlOpts := []renderer.Option{gmhtml.WithXHTML(), gmhtml.WithUnsafe()}
76 if opts.SoftBreakAsBR {
77 htmlOpts = append(htmlOpts, gmhtml.WithHardWraps())
78 }
79
80 gm := goldmark.New(
81 goldmark.WithExtensions(
82 extension.GFM, // tables, strikethrough, autolinks, task list
83 extensions.New(xOpts),
84 ),
85 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
86 goldmark.WithRendererOptions(htmlOpts...),
87 )
88
89 var buf bytes.Buffer
90 if err := gm.Convert(src, &buf); err != nil {
91 return nil, nil, nil, err
92 }
93 clean := sanitizeBytes(buf.Bytes())
94
95 // Convert extension-local types into the public types.
96 if len(xRefs) > 0 {
97 refs = make([]Ref, len(xRefs))
98 for i, r := range xRefs {
99 refs[i] = Ref{
100 Kind: r.Kind,
101 Owner: r.Owner,
102 Repo: r.Repo,
103 Number: r.Number,
104 FullSHA: r.FullSHA,
105 Href: r.Href,
106 }
107 }
108 }
109 if len(xMentions) > 0 {
110 mentions = make([]Mention, len(xMentions))
111 for i, m := range xMentions {
112 mentions[i] = Mention{Username: m.Username, Href: m.Href}
113 }
114 }
115 return clean, refs, mentions, nil
116 }
117
118 // RenderHTML is the back-compat shim for callers that don't need
119 // resolved refs/mentions. SoftBreakAsBR defaults to true (matches
120 // the comment-style legacy behavior of the S17 helper this replaces).
121 //
122 // The signature matches `internal/repos/markdown.RenderHTML` so
123 // existing callers can swap the import path without code changes.
124 // New callers should prefer Render directly.
125 func RenderHTML(src []byte) (string, error) {
126 out, _, _, err := Render(context.Background(), src, Options{SoftBreakAsBR: true})
127 if err != nil {
128 return "", err
129 }
130 return string(out), nil
131 }
132
133 // RenderDocumentHTML renders README-like markdown where source line
134 // wrapping should remain semantic markdown, not comment-style hard
135 // breaks.
136 func RenderDocumentHTML(src []byte) (string, error) {
137 out, _, _, err := Render(context.Background(), src, Options{SoftBreakAsBR: false})
138 if err != nil {
139 return "", err
140 }
141 return string(out), nil
142 }
143