josie / simplegit

// Package render converts repository content for the browser.
package render

import (
	"bytes"
	"fmt"

	"github.com/alecthomas/chroma/v2"
	chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
	"github.com/alecthomas/chroma/v2/lexers"
	"github.com/alecthomas/chroma/v2/styles"
	"github.com/yuin/goldmark"
	"github.com/yuin/goldmark/ast"
	"github.com/yuin/goldmark/renderer"
	"github.com/yuin/goldmark/util"
)

// htmlEscaper replaces goldmark's pass-through rendering of raw HTML
// (block and inline) with escaped text, so READMEs cannot inject script.
type htmlEscaper struct{}

func (htmlEscaper) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
	reg.Register(ast.KindHTMLBlock, escapeHTMLBlock)
	reg.Register(ast.KindRawHTML, escapeRawHTML)
}

func escapeHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		n := node.(*ast.HTMLBlock)
		lines := n.Lines()
		for i := range lines.Len() {
			segment := lines.At(i)
			if _, err := w.Write(util.EscapeHTML(segment.Value(source))); err != nil {
				return ast.WalkStop, err
			}
		}
		if n.HasClosure() {
			if _, err := w.Write(util.EscapeHTML(n.ClosureLine.Value(source))); err != nil {
				return ast.WalkStop, err
			}
		}
	}
	return ast.WalkSkipChildren, nil
}

func escapeRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
	if entering {
		if _, err := w.Write(util.EscapeHTML(node.Text(source))); err != nil {
			return ast.WalkStop, err
		}
	}
	return ast.WalkSkipChildren, nil
}

var markdown = goldmark.New(
	goldmark.WithRendererOptions(
		renderer.WithNodeRenderers(util.Prioritized(htmlEscaper{}, 50)),
	),
)

// Markdown renders markdown source to HTML. Raw HTML in the source is
// escaped rather than passed through.
func Markdown(source []byte) ([]byte, error) {
	var out bytes.Buffer
	if err := markdown.Convert(source, &out); err != nil {
		return nil, fmt.Errorf("render markdown: %w", err)
	}
	return out.Bytes(), nil
}

// highlightStyle inherits chroma's monokai (for complete token coverage)
// and overrides the tokens that matter to the simplegit palette. The class
// names are emitted by the formatter; their rules come from ChromaCSS.
var highlightStyle = func() *chroma.Style {
	style, err := styles.Get("monokai").Builder().
		Add(chroma.Background, "bg:#1e1f3a").
		Add(chroma.Text, "#dcdcf5").
		Add(chroma.Comment, "italic #8f92c4").
		Add(chroma.Keyword, "#a5aef0").
		Add(chroma.KeywordConstant, "#e0af68").
		Add(chroma.KeywordDeclaration, "#a5aef0").
		Add(chroma.KeywordNamespace, "#a5aef0").
		Add(chroma.KeywordType, "#e0af68").
		Add(chroma.Name, "#dcdcf5").
		Add(chroma.NameOther, "#dcdcf5").
		Add(chroma.NameConstant, "#e0af68").
		Add(chroma.NameException, "#e06c75").
		Add(chroma.NameBuiltin, "#e0af68").
		Add(chroma.NameClass, "#7bc275").
		Add(chroma.NameFunction, "#a5aef0").
		Add(chroma.NameDecorator, "#e0af68").
		Add(chroma.NameTag, "#a5aef0").
		Add(chroma.NameAttribute, "#e0af68").
		Add(chroma.String, "#7bc275").
		Add(chroma.LiteralStringEscape, "#e0af68").
		Add(chroma.Number, "#e0af68").
		Add(chroma.Literal, "#7bc275").
		Add(chroma.LiteralDate, "#e0af68").
		Add(chroma.Operator, "#8f92c4").
		Add(chroma.Punctuation, "#8f92c4").
		Add(chroma.GenericDeleted, "#e06c75").
		Add(chroma.GenericInserted, "#7bc275").
		Add(chroma.GenericHeading, "#a5aef0").
		Add(chroma.GenericSubheading, "#a5aef0").
		Add(chroma.GenericEmph, "italic").
		Add(chroma.GenericStrong, "bold").
		Add(chroma.Error, "underline #e06c75").
		Add(chroma.LineHighlight, "bg:#2a2c52").
		Add(chroma.LineNumbers, "#8f92c4").
		Add(chroma.LineNumbersTable, "#8f92c4").
		Build()
	if err != nil {
		panic("render: build highlight style: " + err.Error())
	}
	return style
}()

// highlighter emits CSS classes rather than inline styles, so the palette
// lives in ChromaCSS and can be tuned against design.md.
var highlighter = chromahtml.New(chromahtml.WithClasses(true))

// ChromaCSS returns the stylesheet that styles the classes CodeHTML emits.
func ChromaCSS() ([]byte, error) {
	var buf bytes.Buffer
	if err := highlighter.WriteCSS(&buf, highlightStyle); err != nil {
		return nil, fmt.Errorf("render chroma css: %w", err)
	}
	return buf.Bytes(), nil
}

// CodeHTML syntax-highlights a file by name. handled=false means no lexer
// matched and the caller should fall back to a plain escaped <pre>.
func CodeHTML(filename, content string) (html string, handled bool, err error) {
	lexer := lexers.Match(filename)
	if lexer == nil {
		return "", false, nil
	}
	it, err := lexer.Tokenise(nil, content)
	if err != nil {
		return "", true, fmt.Errorf("tokenise %s: %w", filename, err)
	}
	var buf bytes.Buffer
	if err := highlighter.Format(&buf, highlightStyle, it); err != nil {
		return "", true, fmt.Errorf("highlight %s: %w", filename, err)
	}
	return buf.String(), true, nil
}