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.
+
+
+
+
+
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
+
+
+
+
+
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
+ #
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 @@
+##  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
+
+
+
+
+
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
+
+
+
+| Name |
+Value |
+
+
+
+
+| Alpha |
+1 |
+
+
+| Beta |
+2 |
+
+
+
+
+
+
+
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
+
+
+
+
+
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