mirror of
https://github.com/hanzoai/forge.git
synced 2026-10-30 15:00:39 +00:00
Source files keep their SPDX-License-Identifier and Hanzo copyright line; the Gitea, Gogs and LFS Test Server copyrights they used to repeat now live once in NOTICE, alongside LICENSE. The header lint accepts any copyright line above the SPDX line, so it no longer demands an upstream one. Co-authored-by: Hanzo Dev <[email protected]>
303 lines
9.3 KiB
Go
303 lines
9.3 KiB
Go
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package markdown
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"html/template"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/git/modules/htmlutil"
|
|
"github.com/hanzoai/git/modules/log"
|
|
"github.com/hanzoai/git/modules/markup"
|
|
"github.com/hanzoai/git/modules/markup/common"
|
|
"github.com/hanzoai/git/modules/markup/markdown/math"
|
|
"github.com/hanzoai/git/modules/setting"
|
|
gitutil "github.com/hanzoai/git/modules/util"
|
|
|
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
|
"github.com/yuin/goldmark"
|
|
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
|
"github.com/yuin/goldmark/ast"
|
|
"github.com/yuin/goldmark/extension"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/renderer"
|
|
"github.com/yuin/goldmark/renderer/html"
|
|
"github.com/yuin/goldmark/text"
|
|
"github.com/yuin/goldmark/util"
|
|
)
|
|
|
|
var (
|
|
renderContextKey = parser.NewContextKey()
|
|
renderConfigKey = parser.NewContextKey()
|
|
)
|
|
|
|
type limitWriter struct {
|
|
w io.Writer
|
|
sum int64
|
|
limit int64
|
|
}
|
|
|
|
// Write implements the standard Write interface:
|
|
func (l *limitWriter) Write(data []byte) (int, error) {
|
|
leftToWrite := l.limit - l.sum
|
|
if leftToWrite < int64(len(data)) {
|
|
n, err := l.w.Write(data[:leftToWrite])
|
|
l.sum += int64(n)
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
return n, errors.New("rendered content too large - truncating render")
|
|
}
|
|
n, err := l.w.Write(data)
|
|
l.sum += int64(n)
|
|
return n, err
|
|
}
|
|
|
|
// newParserContext creates a parser.Context with the render context set
|
|
func newParserContext(ctx *markup.RenderContext) parser.Context {
|
|
pc := parser.NewContext()
|
|
pc.Set(renderContextKey, ctx)
|
|
return pc
|
|
}
|
|
|
|
type GoldmarkRender struct {
|
|
ctx *markup.RenderContext
|
|
|
|
goldmarkMarkdown goldmark.Markdown
|
|
}
|
|
|
|
func (r *GoldmarkRender) Convert(source []byte, writer io.Writer, opts ...parser.ParseOption) error {
|
|
return r.goldmarkMarkdown.Convert(source, writer, opts...)
|
|
}
|
|
|
|
func (r *GoldmarkRender) highlightingRenderer(w util.BufWriter, c highlighting.CodeBlockContext, entering bool) {
|
|
if entering {
|
|
languageBytes, _ := c.Language()
|
|
languageStr := gitutil.IfZero(string(languageBytes), "text")
|
|
|
|
preClasses := "code-block"
|
|
if languageStr == "mermaid" || languageStr == "math" {
|
|
preClasses += " is-loading"
|
|
}
|
|
|
|
// include language-x class as part of commonmark spec, "chroma" class is used to highlight the code
|
|
// the "display" class is used by "js/markup/math.ts" to render the code element as a block
|
|
// the "math.ts" strictly depends on the structure: <pre class="code-block is-loading"><code class="language-math display">...</code></pre>
|
|
err := r.ctx.RenderInternal.FormatWithSafeAttrs(w, `<div class="code-block-container code-overflow-scroll"><pre class="%s"><code class="chroma language-%s display">`, preClasses, languageStr)
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else {
|
|
_, err := w.WriteString("</code></pre></div>")
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
type goldmarkEmphasisParser struct {
|
|
parser.InlineParser
|
|
}
|
|
|
|
func goldmarkNewEmphasisParser() parser.InlineParser {
|
|
return &goldmarkEmphasisParser{parser.NewEmphasisParser()}
|
|
}
|
|
|
|
func (s *goldmarkEmphasisParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
|
|
line, _ := block.PeekLine()
|
|
if len(line) > 1 && line[0] == '_' {
|
|
// a special trick to avoid parsing emphasis in filenames like "module/__init__.py"
|
|
end := bytes.IndexByte(line[1:], '_')
|
|
mark := bytes.Index(line, []byte("_.py"))
|
|
// check whether the "end" matches "_.py" or "__.py"
|
|
if mark != -1 && (end == mark || end == mark-1) {
|
|
return nil
|
|
}
|
|
}
|
|
return s.InlineParser.Parse(parent, block, pc)
|
|
}
|
|
|
|
func goldmarkDefaultParser() parser.Parser {
|
|
return parser.NewParser(parser.WithBlockParsers(parser.DefaultBlockParsers()...),
|
|
parser.WithInlineParsers([]util.PrioritizedValue{
|
|
util.Prioritized(parser.NewCodeSpanParser(), 100),
|
|
util.Prioritized(parser.NewLinkParser(), 200),
|
|
util.Prioritized(parser.NewAutoLinkParser(), 300),
|
|
util.Prioritized(parser.NewRawHTMLParser(), 400),
|
|
util.Prioritized(goldmarkNewEmphasisParser(), 500),
|
|
}...),
|
|
parser.WithParagraphTransformers(parser.DefaultParagraphTransformers()...),
|
|
)
|
|
}
|
|
|
|
// SpecializedMarkdown sets up the forge-specific markdown extensions
|
|
func SpecializedMarkdown(ctx *markup.RenderContext) *GoldmarkRender {
|
|
// TODO: it could use a pool to cache the renderers to reuse them with different contexts
|
|
// at the moment it is fast enough (see the benchmarks)
|
|
r := &GoldmarkRender{ctx: ctx}
|
|
r.goldmarkMarkdown = goldmark.New(
|
|
goldmark.WithParser(goldmarkDefaultParser()),
|
|
goldmark.WithExtensions(
|
|
extension.NewTable(extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute)),
|
|
extension.Strikethrough,
|
|
extension.TaskList,
|
|
extension.DefinitionList,
|
|
common.FootnoteExtension,
|
|
highlighting.NewHighlighting(
|
|
highlighting.WithFormatOptions(
|
|
chromahtml.WithClasses(true),
|
|
chromahtml.PreventSurroundingPre(true),
|
|
),
|
|
highlighting.WithWrapperRenderer(r.highlightingRenderer),
|
|
),
|
|
math.NewExtension(&ctx.RenderInternal, math.Options{
|
|
Enabled: setting.Markdown.EnableMath,
|
|
ParseInlineDollar: setting.Markdown.MathCodeBlockOptions.ParseInlineDollar,
|
|
ParseInlineParentheses: setting.Markdown.MathCodeBlockOptions.ParseInlineParentheses, // this is a bad syntax "\( ... \)", it conflicts with normal markdown escaping
|
|
ParseBlockDollar: setting.Markdown.MathCodeBlockOptions.ParseBlockDollar,
|
|
ParseBlockSquareBrackets: setting.Markdown.MathCodeBlockOptions.ParseBlockSquareBrackets, // this is a bad syntax "\[ ... \]", it conflicts with normal markdown escaping
|
|
}),
|
|
),
|
|
goldmark.WithParserOptions(
|
|
parser.WithAttribute(),
|
|
parser.WithASTTransformers(util.Prioritized(NewASTTransformer(&ctx.RenderInternal), 10000)),
|
|
),
|
|
goldmark.WithRendererOptions(html.WithUnsafe()),
|
|
)
|
|
|
|
// Override the original Tasklist renderer!
|
|
r.goldmarkMarkdown.Renderer().AddOptions(
|
|
renderer.WithNodeRenderers(util.Prioritized(NewHTMLRenderer(&ctx.RenderInternal), 10)),
|
|
)
|
|
|
|
return r
|
|
}
|
|
|
|
// render calls goldmark render to convert Markdown to HTML
|
|
// NOTE: The output of this method MUST get sanitized separately!!!
|
|
func render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
converter := SpecializedMarkdown(ctx)
|
|
lw := &limitWriter{
|
|
w: output,
|
|
limit: setting.UI.MaxDisplayFileSize * 3,
|
|
}
|
|
|
|
// FIXME: Don't read all to memory, but goldmark doesn't support
|
|
buf, err := io.ReadAll(input)
|
|
if err != nil {
|
|
log.Error("Unable to ReadAll: %v", err)
|
|
return err
|
|
}
|
|
buf = gitutil.NormalizeEOL(buf)
|
|
|
|
// FIXME: should we include a timeout to abort the renderer if it takes too long?
|
|
defer func() {
|
|
err := recover()
|
|
if err == nil {
|
|
return
|
|
}
|
|
|
|
log.Error("Panic in markdown: %v\n%s", err, log.Stack(2))
|
|
escapedHTML := template.HTMLEscapeString(gitutil.UnsafeBytesToString(buf))
|
|
_, _ = output.Write(gitutil.UnsafeStringToBytes(escapedHTML))
|
|
}()
|
|
|
|
pc := newParserContext(ctx)
|
|
|
|
// Preserve original length.
|
|
bufWithMetadataLength := len(buf)
|
|
|
|
rc := &RenderConfig{Meta: markup.RenderMetaAsDetails}
|
|
buf, _ = ExtractMetadataBytes(buf, rc)
|
|
|
|
metaLength := max(bufWithMetadataLength-len(buf), 0)
|
|
rc.metaLength = metaLength
|
|
|
|
pc.Set(renderConfigKey, rc)
|
|
|
|
if err := converter.Convert(buf, lw, parser.WithContext(pc)); err != nil {
|
|
log.Error("Unable to render: %v", err)
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MarkupName describes markup's name
|
|
var MarkupName = "markdown"
|
|
|
|
func init() {
|
|
markup.RegisterRenderer(Renderer{})
|
|
}
|
|
|
|
type Renderer struct{}
|
|
|
|
var _ markup.PostProcessRenderer = (*Renderer)(nil)
|
|
|
|
func (Renderer) Name() string {
|
|
return MarkupName
|
|
}
|
|
|
|
func (Renderer) NeedPostProcess() bool { return true }
|
|
|
|
func (Renderer) FileNamePatterns() []string {
|
|
return setting.Markdown.FileNamePatterns
|
|
}
|
|
|
|
func (Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
|
|
return []setting.MarkupSanitizerRule{}
|
|
}
|
|
|
|
func (Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
return render(ctx, input, output)
|
|
}
|
|
|
|
// Render renders Markdown to HTML with all specific handling stuff.
|
|
func Render(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
ctx.RenderOptions.MarkupType = MarkupName
|
|
return markup.Render(ctx, input, output)
|
|
}
|
|
|
|
// RenderString renders Markdown string to HTML with all specific handling stuff and return string
|
|
func RenderString(ctx *markup.RenderContext, content string) (template.HTML, error) {
|
|
var buf strings.Builder
|
|
if err := Render(ctx, strings.NewReader(content), &buf); err != nil {
|
|
log.Warn("Unable to RenderString: %v, content: %s", err, gitutil.TruncateRunes(content, 200))
|
|
err = nil
|
|
return htmlutil.EscapeString(content), err
|
|
}
|
|
return template.HTML(buf.String()), nil
|
|
}
|
|
|
|
// RenderRaw renders Markdown to HTML without handling special links.
|
|
func RenderRaw(ctx *markup.RenderContext, input io.Reader, output io.Writer) error {
|
|
rd, wr := io.Pipe()
|
|
defer func() {
|
|
_ = rd.Close()
|
|
_ = wr.Close()
|
|
}()
|
|
|
|
go func() {
|
|
if err := render(ctx, input, wr); err != nil {
|
|
_ = wr.CloseWithError(err)
|
|
return
|
|
}
|
|
_ = wr.Close()
|
|
}()
|
|
|
|
return markup.SanitizeReader(rd, "", output)
|
|
}
|
|
|
|
// RenderRawString renders Markdown to HTML without handling special links and return string
|
|
func RenderRawString(ctx *markup.RenderContext, content string) (string, error) {
|
|
var buf strings.Builder
|
|
if err := RenderRaw(ctx, strings.NewReader(content), &buf); err != nil {
|
|
return "", err
|
|
}
|
|
return buf.String(), nil
|
|
}
|