From 8deba3627f2e6eb683fe5384a336a72e7587e246 Mon Sep 17 00:00:00 2001 From: Sergey Filkin Date: Sat, 18 Apr 2026 11:47:18 +0300 Subject: [PATCH] phase2: markdown converter with goldmark, chroma, and ASCII-translit anchors --- internal/converter/anchor.go | 123 +++++++ internal/converter/converter.go | 170 ++++++++++ internal/converter/converter_test.go | 162 +++++++++ internal/converter/raw_html.go | 45 +++ internal/converter/slugger.go | 26 ++ internal/converter/testdata/autolinks.html | 297 +++++++++++++++++ internal/converter/testdata/autolinks.md | 1 + internal/converter/testdata/basic.html | 298 +++++++++++++++++ internal/converter/testdata/basic.md | 3 + internal/converter/testdata/emoji-inline.html | 297 +++++++++++++++++ internal/converter/testdata/emoji-inline.md | 1 + .../testdata/fenced-code-highlighted.html | 303 +++++++++++++++++ .../testdata/fenced-code-highlighted.md | 9 + internal/converter/testdata/footnote.html | 305 +++++++++++++++++ internal/converter/testdata/footnote.md | 3 + .../converter/testdata/headings-autolink.html | 297 +++++++++++++++++ .../converter/testdata/headings-autolink.md | 1 + .../testdata/headings-collisions.html | 300 +++++++++++++++++ .../converter/testdata/headings-collisions.md | 7 + .../converter/testdata/headings-cyrillic.html | 299 +++++++++++++++++ .../converter/testdata/headings-cyrillic.md | 5 + .../converter/testdata/headings-emoji.html | 297 +++++++++++++++++ internal/converter/testdata/headings-emoji.md | 1 + .../converter/testdata/headings-image.html | 297 +++++++++++++++++ internal/converter/testdata/headings-image.md | 1 + .../testdata/headings-inline-link.html | 297 +++++++++++++++++ .../testdata/headings-inline-link.md | 1 + .../testdata/headings-inlinecode.html | 297 +++++++++++++++++ .../converter/testdata/headings-inlinecode.md | 1 + internal/converter/testdata/raw-html.html | 297 +++++++++++++++++ internal/converter/testdata/raw-html.md | 1 + .../converter/testdata/strikethrough.html | 297 +++++++++++++++++ internal/converter/testdata/strikethrough.md | 1 + internal/converter/testdata/tables.html | 314 ++++++++++++++++++ internal/converter/testdata/tables.md | 4 + internal/converter/testdata/tasklist.html | 300 +++++++++++++++++ internal/converter/testdata/tasklist.md | 2 + web/template/document.html | 296 +++++++++++++++++ web/template/embed.go | 6 + 39 files changed, 5662 insertions(+) create mode 100644 internal/converter/anchor.go create mode 100644 internal/converter/converter.go create mode 100644 internal/converter/converter_test.go create mode 100644 internal/converter/raw_html.go create mode 100644 internal/converter/slugger.go create mode 100644 internal/converter/testdata/autolinks.html create mode 100644 internal/converter/testdata/autolinks.md create mode 100644 internal/converter/testdata/basic.html create mode 100644 internal/converter/testdata/basic.md create mode 100644 internal/converter/testdata/emoji-inline.html create mode 100644 internal/converter/testdata/emoji-inline.md create mode 100644 internal/converter/testdata/fenced-code-highlighted.html create mode 100644 internal/converter/testdata/fenced-code-highlighted.md create mode 100644 internal/converter/testdata/footnote.html create mode 100644 internal/converter/testdata/footnote.md create mode 100644 internal/converter/testdata/headings-autolink.html create mode 100644 internal/converter/testdata/headings-autolink.md create mode 100644 internal/converter/testdata/headings-collisions.html create mode 100644 internal/converter/testdata/headings-collisions.md create mode 100644 internal/converter/testdata/headings-cyrillic.html create mode 100644 internal/converter/testdata/headings-cyrillic.md create mode 100644 internal/converter/testdata/headings-emoji.html create mode 100644 internal/converter/testdata/headings-emoji.md create mode 100644 internal/converter/testdata/headings-image.html create mode 100644 internal/converter/testdata/headings-image.md create mode 100644 internal/converter/testdata/headings-inline-link.html create mode 100644 internal/converter/testdata/headings-inline-link.md create mode 100644 internal/converter/testdata/headings-inlinecode.html create mode 100644 internal/converter/testdata/headings-inlinecode.md create mode 100644 internal/converter/testdata/raw-html.html create mode 100644 internal/converter/testdata/raw-html.md create mode 100644 internal/converter/testdata/strikethrough.html create mode 100644 internal/converter/testdata/strikethrough.md create mode 100644 internal/converter/testdata/tables.html create mode 100644 internal/converter/testdata/tables.md create mode 100644 internal/converter/testdata/tasklist.html create mode 100644 internal/converter/testdata/tasklist.md create mode 100644 web/template/document.html create mode 100644 web/template/embed.go diff --git a/internal/converter/anchor.go b/internal/converter/anchor.go new file mode 100644 index 0000000..9f6539f --- /dev/null +++ b/internal/converter/anchor.go @@ -0,0 +1,123 @@ +package converter + +import ( + "strings" + + "github.com/yuin/goldmark" + emojiast "github.com/yuin/goldmark-emoji/ast" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +type anchorExtension struct{} + +func (e *anchorExtension) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithASTTransformers( + util.Prioritized(&anchorTransformer{}, 900), + )) +} + +type anchorTransformer struct{} + +func (t *anchorTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + src := reader.Source() + used := map[string]int{} + + _ = pc + + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + h, ok := n.(*ast.Heading) + if !ok { + return ast.WalkContinue, nil + } + + slug := translitSlug(extractHeadingText(h, src), used) + h.SetAttributeString("id", []byte(slug)) + + link := ast.NewLink() + link.Destination = []byte("#" + slug) + link.SetAttributeString("class", []byte("heading-anchor")) + link.SetAttributeString("aria-hidden", []byte("true")) + link.AppendChild(link, ast.NewString([]byte("#"))) + + if first := h.FirstChild(); first != nil { + h.InsertBefore(h, first, link) + } else { + h.AppendChild(h, link) + } + + return ast.WalkSkipChildren, nil + }) +} + +func extractHeadingText(h *ast.Heading, src []byte) string { + var b strings.Builder + + _ = ast.Walk(h, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + switch v := n.(type) { + case *ast.Link: + if isHeadingAnchor(v) { + return ast.WalkSkipChildren, nil + } + case *ast.Text: + b.Write(v.Segment.Value(src)) + if v.HardLineBreak() || v.SoftLineBreak() { + b.WriteByte(' ') + } + case *ast.String: + b.Write(v.Value) + case *ast.CodeSpan: + for child := v.FirstChild(); child != nil; child = child.NextSibling() { + switch c := child.(type) { + case *ast.Text: + b.Write(c.Segment.Value(src)) + case *ast.String: + b.Write(c.Value) + } + } + return ast.WalkSkipChildren, nil + case *ast.AutoLink: + b.Write(v.Label(src)) + return ast.WalkSkipChildren, nil + case *emojiast.Emoji: + if v.Value != nil && len(v.Value.Unicode) > 0 { + b.WriteString(string(v.Value.Unicode)) + } else if len(v.ShortName) > 0 { + b.WriteByte(':') + b.Write(v.ShortName) + b.WriteByte(':') + } + return ast.WalkSkipChildren, nil + } + + return ast.WalkContinue, nil + }) + + return strings.TrimSpace(b.String()) +} + +func isHeadingAnchor(link *ast.Link) bool { + attr, ok := link.AttributeString("class") + if !ok { + return false + } + + switch value := attr.(type) { + case []byte: + return string(value) == "heading-anchor" + case string: + return value == "heading-anchor" + default: + return false + } +} diff --git a/internal/converter/converter.go b/internal/converter/converter.go new file mode 100644 index 0000000..3a5c4f8 --- /dev/null +++ b/internal/converter/converter.go @@ -0,0 +1,170 @@ +package converter + +import ( + "bytes" + "fmt" + "html/template" + "io/fs" + "strings" + "sync" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/yuin/goldmark" + emoji "github.com/yuin/goldmark-emoji" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +const documentLang = "ru" + +type Result struct { + HTML []byte + Title string +} + +type Converter struct { + md goldmark.Markdown + tmpl *template.Template + bufferPool sync.Pool +} + +type templateData struct { + Lang string + Title string + Body template.HTML + ShowTitle bool +} + +func New(templateFS fs.FS) (*Converter, error) { + tmpl, err := template.ParseFS(templateFS, "document.html") + if err != nil { + return nil, err + } + + return &Converter{ + md: goldmark.New( + goldmark.WithExtensions( + extension.GFM, + extension.Footnote, + emoji.Emoji, + highlighting.NewHighlighting( + highlighting.WithStyle("github"), + highlighting.WithFormatOptions(chromahtml.WithClasses(false)), + ), + &anchorExtension{}, + ), + goldmark.WithRendererOptions( + renderer.WithNodeRenderers( + util.Prioritized(&escapedRawHTMLRenderer{}, 999), + ), + ), + ), + tmpl: tmpl, + bufferPool: sync.Pool{ + New: func() any { + return new(bytes.Buffer) + }, + }, + }, nil +} + +func (c *Converter) Convert(md []byte, fallbackTitle string) (Result, error) { + body, title, hasH1, err := c.render(md) + if err != nil { + return Result{}, err + } + + if title == "" { + title = fallbackTitle + } + + buf := c.getBuffer() + defer c.putBuffer(buf) + + data := templateData{ + Lang: documentLang, + Title: title, + Body: template.HTML(body), + ShowTitle: !hasH1 && title != "", + } + + if err := c.tmpl.Execute(buf, data); err != nil { + return Result{}, err + } + + return Result{ + HTML: append([]byte(nil), buf.Bytes()...), + Title: title, + }, nil +} + +func (c *Converter) RenderBody(md []byte) ([]byte, string, error) { + body, title, _, err := c.render(md) + if err != nil { + return nil, "", err + } + return body, title, nil +} + +func (c *Converter) render(md []byte) ([]byte, string, bool, error) { + root := c.md.Parser().Parse(text.NewReader(md)) + doc, ok := root.(*ast.Document) + if !ok { + return nil, "", false, fmt.Errorf("expected *ast.Document, got %T", root) + } + title, hasH1 := extractDocumentTitle(doc, md) + + buf := c.getBuffer() + defer c.putBuffer(buf) + + if err := c.md.Renderer().Render(buf, md, doc); err != nil { + return nil, "", false, err + } + + return append([]byte(nil), buf.Bytes()...), title, hasH1, nil +} + +func extractDocumentTitle(doc *ast.Document, src []byte) (string, bool) { + var ( + title string + hasH1 bool + ) + + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + h, ok := n.(*ast.Heading) + if !ok { + return ast.WalkContinue, nil + } + + if h.Level == 1 { + hasH1 = true + } + + if title == "" { + title = strings.TrimSpace(extractHeadingText(h, src)) + } + + return ast.WalkContinue, nil + }) + + return title, hasH1 +} + +func (c *Converter) getBuffer() *bytes.Buffer { + buf := c.bufferPool.Get().(*bytes.Buffer) + buf.Reset() + return buf +} + +func (c *Converter) putBuffer(buf *bytes.Buffer) { + buf.Reset() + c.bufferPool.Put(buf) +} diff --git a/internal/converter/converter_test.go b/internal/converter/converter_test.go new file mode 100644 index 0000000..cabb783 --- /dev/null +++ b/internal/converter/converter_test.go @@ -0,0 +1,162 @@ +package converter + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/fserg/md-to-html/web/template" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func TestGolden(t *testing.T) { + c := newTestConverter(t) + update := os.Getenv("UPDATE_GOLDEN") == "1" + + entries, err := os.ReadDir("testdata") + if err != nil { + t.Fatal(err) + } + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".md") { + continue + } + + t.Run(name, func(t *testing.T) { + md, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + + wantPath := filepath.Join("testdata", strings.TrimSuffix(name, ".md")+".html") + got, err := c.Convert(md, "Document") + if err != nil { + t.Fatal(err) + } + + for _, forbidden := range []string{"http://", "https://", "cdn.", "googleapis.com"} { + if bytes.Contains(got.HTML, []byte(forbidden)) { + t.Fatalf("generated HTML contains forbidden external resource marker %q", forbidden) + } + } + + if update { + if err := os.WriteFile(wantPath, got.HTML, 0o644); err != nil { + t.Fatal(err) + } + return + } + + want, err := os.ReadFile(wantPath) + if err != nil { + t.Fatalf("missing golden %s; run UPDATE_GOLDEN=1", wantPath) + } + + if !bytes.Equal(got.HTML, want) { + t.Errorf("mismatch: run UPDATE_GOLDEN=1 go test ./internal/converter/... to refresh") + } + }) + } +} + +func TestTranslitSlug(t *testing.T) { + tests := []struct { + name string + in string + want string + used map[string]int + }{ + {name: "cyrillic", in: "Установка", want: "ustanovka", used: map[string]int{}}, + {name: "collision first", in: "Install", want: "install", used: map[string]int{}}, + {name: "collision second", in: "Install", want: "install-1", used: map[string]int{"install": 1}}, + {name: "cyrillic translit", in: "Сетап", want: "setap", used: map[string]int{}}, + {name: "empty fallback", in: "!!!", want: "section", used: map[string]int{}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := translitSlug(tt.in, tt.used) + if got != tt.want { + t.Fatalf("translitSlug(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestExtractHeadingText(t *testing.T) { + c := newTestConverter(t) + src := []byte("## [API](https://example.com) `go fmt` https://example.com :rocket:\n") + doc := c.md.Parser().Parse(text.NewReader(src)) + + var heading *ast.Heading + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if h, ok := n.(*ast.Heading); ok { + heading = h + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + + if heading == nil { + t.Fatal("heading not found") + } + + got := extractHeadingText(heading, src) + want := "API go fmt https://example.com 🚀" + if got != want { + t.Fatalf("extractHeadingText() = %q, want %q", got, want) + } +} + +func TestConvertTitleFromFirstHeading(t *testing.T) { + c := newTestConverter(t) + + result, err := c.Convert([]byte("# Hello\n\nParagraph"), "fallback") + if err != nil { + t.Fatal(err) + } + + if result.Title != "Hello" { + t.Fatalf("result.Title = %q, want %q", result.Title, "Hello") + } + + if !bytes.Contains(result.HTML, []byte("Hello")) { + t.Fatalf("expected HTML title to contain Hello") + } +} + +func TestConvertTitleFallback(t *testing.T) { + c := newTestConverter(t) + + result, err := c.Convert([]byte("Paragraph only"), "fallback") + if err != nil { + t.Fatal(err) + } + + if result.Title != "fallback" { + t.Fatalf("result.Title = %q, want %q", result.Title, "fallback") + } + + if !bytes.Contains(result.HTML, []byte("

fallback

")) { + t.Fatalf("expected fallback h1 to be injected") + } +} + +func newTestConverter(t *testing.T) *Converter { + t.Helper() + + c, err := New(webtemplate.FS) + if err != nil { + t.Fatal(err) + } + + return c +} diff --git a/internal/converter/raw_html.go b/internal/converter/raw_html.go new file mode 100644 index 0000000..42ce8df --- /dev/null +++ b/internal/converter/raw_html.go @@ -0,0 +1,45 @@ +package converter + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" +) + +type escapedRawHTMLRenderer struct{} + +func (r *escapedRawHTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock) + reg.Register(ast.KindRawHTML, r.renderRawHTML) +} + +func (r *escapedRawHTMLRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.HTMLBlock) + if entering { + for i := 0; i < n.Lines().Len(); i++ { + line := n.Lines().At(i) + _, _ = w.Write(util.EscapeHTML(line.Value(source))) + } + return ast.WalkContinue, nil + } + + if n.HasClosure() { + _, _ = w.Write(util.EscapeHTML(n.ClosureLine.Value(source))) + } + + return ast.WalkContinue, nil +} + +func (r *escapedRawHTMLRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkSkipChildren, nil + } + + n := node.(*ast.RawHTML) + for i := 0; i < n.Segments.Len(); i++ { + segment := n.Segments.At(i) + _, _ = w.Write(util.EscapeHTML(segment.Value(source))) + } + + return ast.WalkSkipChildren, nil +} diff --git a/internal/converter/slugger.go b/internal/converter/slugger.go new file mode 100644 index 0000000..4d7a050 --- /dev/null +++ b/internal/converter/slugger.go @@ -0,0 +1,26 @@ +package converter + +import ( + "fmt" + "regexp" + "strings" + + "github.com/mozillazg/go-unidecode" +) + +var slugRe = regexp.MustCompile(`[^a-z0-9]+`) + +func translitSlug(s string, used map[string]int) string { + t := strings.ToLower(unidecode.Unidecode(s)) + t = slugRe.ReplaceAllString(t, "-") + t = strings.Trim(t, "-") + if t == "" { + t = "section" + } + if n, ok := used[t]; ok && n > 0 { + used[t] = n + 1 + return fmt.Sprintf("%s-%d", t, n) + } + used[t] = 1 + return t +} diff --git a/internal/converter/testdata/autolinks.html b/internal/converter/testdata/autolinks.html new file mode 100644 index 0000000..4fc9043 --- /dev/null +++ b/internal/converter/testdata/autolinks.html @@ -0,0 +1,297 @@ + + + + + + Document + + + +
+

Document

+

Contact dev@example.test for details.

+ +
+ + diff --git a/internal/converter/testdata/autolinks.md b/internal/converter/testdata/autolinks.md new file mode 100644 index 0000000..7bf3dd2 --- /dev/null +++ b/internal/converter/testdata/autolinks.md @@ -0,0 +1 @@ +Contact for details. diff --git a/internal/converter/testdata/basic.html b/internal/converter/testdata/basic.html new file mode 100644 index 0000000..abbdd47 --- /dev/null +++ b/internal/converter/testdata/basic.html @@ -0,0 +1,298 @@ + + + + + + Basic Example + + + +
+ +

#Basic Example

+

Simple paragraph with bold, italic, and docs.

+ +
+ + diff --git a/internal/converter/testdata/basic.md b/internal/converter/testdata/basic.md new file mode 100644 index 0000000..69e5f3e --- /dev/null +++ b/internal/converter/testdata/basic.md @@ -0,0 +1,3 @@ +# Basic Example + +Simple paragraph with **bold**, *italic*, and [docs](/docs). diff --git a/internal/converter/testdata/emoji-inline.html b/internal/converter/testdata/emoji-inline.html new file mode 100644 index 0000000..056112b --- /dev/null +++ b/internal/converter/testdata/emoji-inline.html @@ -0,0 +1,297 @@ + + + + + + Document + + + +
+

Document

+

Ready to launch 🚀 today.

+ +
+ + diff --git a/internal/converter/testdata/emoji-inline.md b/internal/converter/testdata/emoji-inline.md new file mode 100644 index 0000000..a1a0ede --- /dev/null +++ b/internal/converter/testdata/emoji-inline.md @@ -0,0 +1 @@ +Ready to launch :rocket: today. diff --git a/internal/converter/testdata/fenced-code-highlighted.html b/internal/converter/testdata/fenced-code-highlighted.html new file mode 100644 index 0000000..b84fa68 --- /dev/null +++ b/internal/converter/testdata/fenced-code-highlighted.html @@ -0,0 +1,303 @@ + + + + + + Document + + + +
+

Document

+
package main
+
+import "fmt"
+
+func main() {
+	fmt.Println("hello")
+}
+
+
+ + diff --git a/internal/converter/testdata/fenced-code-highlighted.md b/internal/converter/testdata/fenced-code-highlighted.md new file mode 100644 index 0000000..ca57f18 --- /dev/null +++ b/internal/converter/testdata/fenced-code-highlighted.md @@ -0,0 +1,9 @@ +```go +package main + +import "fmt" + +func main() { + fmt.Println("hello") +} +``` diff --git a/internal/converter/testdata/footnote.html b/internal/converter/testdata/footnote.html new file mode 100644 index 0000000..87c0fa3 --- /dev/null +++ b/internal/converter/testdata/footnote.html @@ -0,0 +1,305 @@ + + + + + + Document + + + +
+

Document

+

Footnote text.1

+
+
+
    +
  1. +

    Extra details. ↩︎

    +
  2. +
+
+ +
+ + diff --git a/internal/converter/testdata/footnote.md b/internal/converter/testdata/footnote.md new file mode 100644 index 0000000..cbf48f3 --- /dev/null +++ b/internal/converter/testdata/footnote.md @@ -0,0 +1,3 @@ +Footnote text.[^1] + +[^1]: Extra details. diff --git a/internal/converter/testdata/headings-autolink.html b/internal/converter/testdata/headings-autolink.html new file mode 100644 index 0000000..c113c1a --- /dev/null +++ b/internal/converter/testdata/headings-autolink.html @@ -0,0 +1,297 @@ + + + + + + dev@example.test + + + +
+

dev@example.test

+

#dev@example.test

+ +
+ + diff --git a/internal/converter/testdata/headings-autolink.md b/internal/converter/testdata/headings-autolink.md new file mode 100644 index 0000000..b337a63 --- /dev/null +++ b/internal/converter/testdata/headings-autolink.md @@ -0,0 +1 @@ +## diff --git a/internal/converter/testdata/headings-collisions.html b/internal/converter/testdata/headings-collisions.html new file mode 100644 index 0000000..cfb04da --- /dev/null +++ b/internal/converter/testdata/headings-collisions.html @@ -0,0 +1,300 @@ + + + + + + Install + + + +
+

Install

+

#Install

+

#Install

+

#Setup

+

#Сетап

+ +
+ + diff --git a/internal/converter/testdata/headings-collisions.md b/internal/converter/testdata/headings-collisions.md new file mode 100644 index 0000000..1846e5b --- /dev/null +++ b/internal/converter/testdata/headings-collisions.md @@ -0,0 +1,7 @@ +## Install + +## Install + +## Setup + +## Сетап diff --git a/internal/converter/testdata/headings-cyrillic.html b/internal/converter/testdata/headings-cyrillic.html new file mode 100644 index 0000000..2db11b6 --- /dev/null +++ b/internal/converter/testdata/headings-cyrillic.html @@ -0,0 +1,299 @@ + + + + + + Привет + + + +
+ +

#Привет

+

#Установка

+

#Быстрый старт

+ +
+ + diff --git a/internal/converter/testdata/headings-cyrillic.md b/internal/converter/testdata/headings-cyrillic.md new file mode 100644 index 0000000..7a8ee55 --- /dev/null +++ b/internal/converter/testdata/headings-cyrillic.md @@ -0,0 +1,5 @@ +# Привет + +## Установка + +### Быстрый старт diff --git a/internal/converter/testdata/headings-emoji.html b/internal/converter/testdata/headings-emoji.html new file mode 100644 index 0000000..efb714d --- /dev/null +++ b/internal/converter/testdata/headings-emoji.html @@ -0,0 +1,297 @@ + + + + + + 🚀 Launch + + + +
+

🚀 Launch

+

#🚀 Launch

+ +
+ + diff --git a/internal/converter/testdata/headings-emoji.md b/internal/converter/testdata/headings-emoji.md new file mode 100644 index 0000000..86d82ad --- /dev/null +++ b/internal/converter/testdata/headings-emoji.md @@ -0,0 +1 @@ +## :rocket: Launch diff --git a/internal/converter/testdata/headings-image.html b/internal/converter/testdata/headings-image.html new file mode 100644 index 0000000..3b5dfee --- /dev/null +++ b/internal/converter/testdata/headings-image.html @@ -0,0 +1,297 @@ + + + + + + alt Title + + + +
+

alt Title

+

#alt Title

+ +
+ + diff --git a/internal/converter/testdata/headings-image.md b/internal/converter/testdata/headings-image.md new file mode 100644 index 0000000..73cf50a --- /dev/null +++ b/internal/converter/testdata/headings-image.md @@ -0,0 +1 @@ +## ![alt](image.png) Title diff --git a/internal/converter/testdata/headings-inline-link.html b/internal/converter/testdata/headings-inline-link.html new file mode 100644 index 0000000..4fafdbf --- /dev/null +++ b/internal/converter/testdata/headings-inline-link.html @@ -0,0 +1,297 @@ + + + + + + API + + + +
+

API

+

#API

+ +
+ + diff --git a/internal/converter/testdata/headings-inline-link.md b/internal/converter/testdata/headings-inline-link.md new file mode 100644 index 0000000..814fd50 --- /dev/null +++ b/internal/converter/testdata/headings-inline-link.md @@ -0,0 +1 @@ +## [API](/api) diff --git a/internal/converter/testdata/headings-inlinecode.html b/internal/converter/testdata/headings-inlinecode.html new file mode 100644 index 0000000..3e76b0f --- /dev/null +++ b/internal/converter/testdata/headings-inlinecode.html @@ -0,0 +1,297 @@ + + + + + + Using go fmt + + + +
+

Using go fmt

+

#Using go fmt

+ +
+ + diff --git a/internal/converter/testdata/headings-inlinecode.md b/internal/converter/testdata/headings-inlinecode.md new file mode 100644 index 0000000..41802df --- /dev/null +++ b/internal/converter/testdata/headings-inlinecode.md @@ -0,0 +1 @@ +## Using `go fmt` diff --git a/internal/converter/testdata/raw-html.html b/internal/converter/testdata/raw-html.html new file mode 100644 index 0000000..ea44a5d --- /dev/null +++ b/internal/converter/testdata/raw-html.html @@ -0,0 +1,297 @@ + + + + + + Document + + + +
+

Document

+ <script>alert(1)</script> + +
+ + diff --git a/internal/converter/testdata/raw-html.md b/internal/converter/testdata/raw-html.md new file mode 100644 index 0000000..5fb1872 --- /dev/null +++ b/internal/converter/testdata/raw-html.md @@ -0,0 +1 @@ + diff --git a/internal/converter/testdata/strikethrough.html b/internal/converter/testdata/strikethrough.html new file mode 100644 index 0000000..04f75b2 --- /dev/null +++ b/internal/converter/testdata/strikethrough.html @@ -0,0 +1,297 @@ + + + + + + Document + + + +
+

Document

+

Use old new output.

+ +
+ + diff --git a/internal/converter/testdata/strikethrough.md b/internal/converter/testdata/strikethrough.md new file mode 100644 index 0000000..e6c880e --- /dev/null +++ b/internal/converter/testdata/strikethrough.md @@ -0,0 +1 @@ +Use ~~old~~ new output. diff --git a/internal/converter/testdata/tables.html b/internal/converter/testdata/tables.html new file mode 100644 index 0000000..e497902 --- /dev/null +++ b/internal/converter/testdata/tables.html @@ -0,0 +1,314 @@ + + + + + + Document + + + +
+

Document

+ + + + + + + + + + + + + + + + + +
NameValue
Alpha1
Beta2
+ +
+ + diff --git a/internal/converter/testdata/tables.md b/internal/converter/testdata/tables.md new file mode 100644 index 0000000..f898fa9 --- /dev/null +++ b/internal/converter/testdata/tables.md @@ -0,0 +1,4 @@ +| Name | Value | +| --- | --- | +| Alpha | 1 | +| Beta | 2 | diff --git a/internal/converter/testdata/tasklist.html b/internal/converter/testdata/tasklist.html new file mode 100644 index 0000000..f4d81e9 --- /dev/null +++ b/internal/converter/testdata/tasklist.html @@ -0,0 +1,300 @@ + + + + + + Document + + + +
+

Document

+
    +
  • Ship phase 2
  • +
  • Review output
  • +
+ +
+ + diff --git a/internal/converter/testdata/tasklist.md b/internal/converter/testdata/tasklist.md new file mode 100644 index 0000000..ac101b1 --- /dev/null +++ b/internal/converter/testdata/tasklist.md @@ -0,0 +1,2 @@ +- [x] Ship phase 2 +- [ ] Review output diff --git a/web/template/document.html b/web/template/document.html new file mode 100644 index 0000000..7da35cd --- /dev/null +++ b/web/template/document.html @@ -0,0 +1,296 @@ + + + + + + {{.Title}} + + + +
+ {{if .ShowTitle}}

{{.Title}}

{{end}} + {{.Body}} +
+ + diff --git a/web/template/embed.go b/web/template/embed.go new file mode 100644 index 0000000..f252d14 --- /dev/null +++ b/web/template/embed.go @@ -0,0 +1,6 @@ +package webtemplate + +import "embed" + +//go:embed document.html +var FS embed.FS