feat: articles format option with markdown new default

This commit is contained in:
fsylvain committed 2026-10-04 19:04:12 +02:00
1 parent 68401d66e0
commit 7e9bd9ba30
61 files changed
+2269 -243

No files matched your search

+365
View File
@@ -0,0 +1,365 @@
package markdown
import (
"net/url"
"strconv"
"strings"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
)
const (
markBold = "bold"
markItalic = "italic"
markLink = "link"
markCode = "code"
codeLanguagePrefix = "language-"
)
type mark struct {
kind string
href string
}
type inline struct {
text string
marks []mark
}
type blockKind int
const (
kindHeading blockKind = iota
kindParagraph
kindList
kindBlockquote
kindCode
kindRule
kindImage
)
type block struct {
kind blockKind
level int
inlines []inline
ordered bool
items [][]block
children []block
language string
text string
src string
alt string
align string
}
var transparentBlocks = map[string]bool{
"div": true, "section": true, "article": true, "header": true, "footer": true,
"main": true, "aside": true, "nav": true, "figure": true, "figcaption": true,
"table": true, "thead": true, "tbody": true, "tfoot": true, "tr": true,
"td": true, "th": true, "dl": true, "dt": true, "dd": true, "details": true,
"summary": true, "li": true,
}
var skippedElements = map[string]bool{
"script": true, "style": true, "template": true, "head": true,
"noscript": true, "iframe": true, "object": true, "embed": true,
}
var alignments = map[string]bool{"center": true, "right": true, "justify": true}
var allowedLinkSchemes = map[string]bool{
"": true, "http": true, "https": true, "mailto": true, "tel": true,
}
func parseBlocks(source string) ([]block, error) {
context := &html.Node{Type: html.ElementNode, DataAtom: atom.Body, Data: "body"}
nodes, err := html.ParseFragment(strings.NewReader(source), context)
if err != nil {
return nil, err
}
collector := newCollector(false, paragraphBlock)
collector.walkAll(nodes, nil)
return collector.finish(false), nil
}
func paragraphBlock(inlines []inline) block {
return block{kind: kindParagraph, inlines: inlines}
}
func headingBlock(level int) func([]inline) block {
return func(inlines []inline) block {
return block{kind: kindHeading, level: level, inlines: inlines}
}
}
type collector struct {
out []block
current []inline
keepBlank bool
build func([]inline) block
}
func newCollector(keepBlank bool, build func([]inline) block) *collector {
return &collector{keepBlank: keepBlank, build: build}
}
func (c *collector) walkAll(nodes []*html.Node, marks []mark) {
for _, node := range nodes {
c.walk(node, marks)
}
}
func (c *collector) walkChildren(parent *html.Node, marks []mark) {
for child := parent.FirstChild; child != nil; child = child.NextSibling {
c.walk(child, marks)
}
}
func (c *collector) addText(text string, marks []mark) {
if text == "" {
return
}
c.current = appendInline(c.current, inline{text: text, marks: marks})
}
func (c *collector) flush() {
if len(c.current) == 0 {
return
}
if c.keepBlank || hasVisibleText(c.current) {
c.out = append(c.out, c.build(c.current))
}
c.current = nil
}
func (c *collector) finish(emitEmpty bool) []block {
hadBlocks := len(c.out) > 0
c.flush()
if emitEmpty && !hadBlocks && len(c.out) == 0 {
c.out = append(c.out, c.build(nil))
}
return c.out
}
func (c *collector) walk(node *html.Node, marks []mark) {
switch node.Type {
case html.TextNode:
c.addText(node.Data, marks)
return
case html.ElementNode:
default:
return
}
tag := node.Data
if skippedElements[tag] {
return
}
switch tag {
case "br":
c.addText("\n", marks)
case "strong", "b":
c.walkChildren(node, withMark(marks, mark{kind: markBold}))
case "em", "i":
c.walkChildren(node, withMark(marks, mark{kind: markItalic}))
case "code":
c.walkChildren(node, withMark(marks, mark{kind: markCode}))
case "a":
href := safeHref(attr(node, "href"))
if href == "" {
c.walkChildren(node, marks)
return
}
c.walkChildren(node, withMark(marks, mark{kind: markLink, href: href}))
case "img":
c.flush()
if src := attr(node, "src"); src != "" {
c.out = append(c.out, block{kind: kindImage, src: src, alt: attr(node, "alt")})
}
case "hr":
c.flush()
c.out = append(c.out, block{kind: kindRule})
case "p":
c.flush()
c.out = append(c.out, parseTextBlock(node, paragraphBlock)...)
case "h1", "h2", "h3", "h4", "h5", "h6":
c.flush()
level, _ := strconv.Atoi(tag[1:])
c.out = append(c.out, parseTextBlock(node, headingBlock(level))...)
case "ul", "ol":
c.flush()
c.out = append(c.out, parseList(node))
case "blockquote":
c.flush()
c.out = append(c.out, block{kind: kindBlockquote, children: parseContainer(node)})
case "pre":
c.flush()
c.out = append(c.out, parseCodeBlock(node))
default:
if transparentBlocks[tag] {
c.flush()
c.walkChildren(node, marks)
c.flush()
return
}
c.walkChildren(node, marks)
}
}
func parseTextBlock(node *html.Node, build func([]inline) block) []block {
inner := newCollector(true, build)
inner.walkChildren(node, nil)
blocks := inner.finish(true)
if align := alignFromStyle(attr(node, "style")); align != "" {
for index := range blocks {
if blocks[index].kind == kindParagraph || blocks[index].kind == kindHeading {
blocks[index].align = align
}
}
}
return blocks
}
func alignFromStyle(style string) string {
for _, declaration := range strings.Split(style, ";") {
name, value, found := strings.Cut(declaration, ":")
if !found || strings.ToLower(strings.TrimSpace(name)) != "text-align" {
continue
}
if align := strings.ToLower(strings.TrimSpace(value)); alignments[align] {
return align
}
}
return ""
}
func parseContainer(node *html.Node) []block {
inner := newCollector(false, paragraphBlock)
inner.walkChildren(node, nil)
return inner.finish(false)
}
func parseList(node *html.Node) block {
list := block{kind: kindList, ordered: node.Data == "ol"}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "li" {
list.items = append(list.items, parseContainer(child))
}
}
return list
}
func parseCodeBlock(node *html.Node) block {
code := block{kind: kindCode, text: strings.TrimSuffix(textContent(node), "\n")}
for child := node.FirstChild; child != nil; child = child.NextSibling {
if child.Type == html.ElementNode && child.Data == "code" {
code.language = languageFromClass(attr(child, "class"))
break
}
}
return code
}
func languageFromClass(class string) string {
for _, name := range strings.Fields(class) {
if strings.HasPrefix(name, codeLanguagePrefix) {
return strings.TrimPrefix(name, codeLanguagePrefix)
}
}
return ""
}
func textContent(node *html.Node) string {
var builder strings.Builder
var visit func(*html.Node)
visit = func(n *html.Node) {
switch {
case n.Type == html.TextNode:
builder.WriteString(n.Data)
case n.Type == html.ElementNode && skippedElements[n.Data]:
return
case n.Type == html.ElementNode && n.Data == "br":
builder.WriteString("\n")
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
visit(child)
}
}
visit(node)
return builder.String()
}
func attr(node *html.Node, name string) string {
for _, a := range node.Attr {
if a.Key == name {
return a.Val
}
}
return ""
}
func safeHref(href string) string {
trimmed := strings.TrimSpace(href)
parsed, err := url.Parse(trimmed)
if err != nil || !allowedLinkSchemes[strings.ToLower(parsed.Scheme)] {
return ""
}
return trimmed
}
func withMark(marks []mark, added mark) []mark {
for _, existing := range marks {
if existing.kind == added.kind {
return marks
}
}
next := make([]mark, 0, len(marks)+1)
next = append(next, marks...)
return append(next, added)
}
func hasVisibleText(inlines []inline) bool {
for _, item := range inlines {
if strings.TrimSpace(item.text) != "" {
return true
}
}
return false
}
func sameMarks(a, b []mark) bool {
if len(a) != len(b) {
return false
}
for index := range a {
if a[index] != b[index] {
return false
}
}
return true
}
func appendInline(inlines []inline, next inline) []inline {
if last := len(inlines) - 1; last >= 0 && sameMarks(inlines[last].marks, next.marks) {
inlines[last].text += next.text
return inlines
}
return append(inlines, next)
}
func withoutCodeMarks(inlines []inline) []inline {
merged := make([]inline, 0, len(inlines))
for _, item := range inlines {
kept := make([]mark, 0, len(item.marks))
for _, m := range item.marks {
if m.kind != markCode {
kept = append(kept, m)
}
}
merged = appendInline(merged, inline{text: item.text, marks: kept})
}
return merged
}
@@ -0,0 +1,33 @@
package markdown
import (
domain "RenewCMS/internal/domain/article"
)
type Converter struct{}
var _ domain.BodyRenderer = &Converter{}
func NewConverter() *Converter {
return &Converter{}
}
func (c *Converter) Render(html string, format domain.BodyFormat) (string, error) {
switch format {
case domain.BodyFormatHTML:
return html, nil
case domain.BodyFormatMarkdown:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderMarkdown(blocks), nil
case domain.BodyFormatJSON:
blocks, err := parseBlocks(html)
if err != nil {
return "", err
}
return renderJSON(blocks)
}
return "", domain.ErrUnsupportedBodyFormat
}
@@ -0,0 +1,111 @@
package markdown
import (
"bytes"
"encoding/json"
"strings"
)
type jsonMark struct {
Type string `json:"type"`
Href string `json:"href,omitempty"`
}
type jsonText struct {
Type string `json:"type"`
Text string `json:"text"`
Marks []jsonMark `json:"marks"`
}
type jsonHeading struct {
Type string `json:"type"`
Level int `json:"level"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonParagraph struct {
Type string `json:"type"`
Align string `json:"align,omitempty"`
Children []jsonText `json:"children"`
}
type jsonList struct {
Type string `json:"type"`
Ordered bool `json:"ordered"`
Items [][]any `json:"items"`
}
type jsonBlockquote struct {
Type string `json:"type"`
Children []any `json:"children"`
}
type jsonCode struct {
Type string `json:"type"`
Language string `json:"language"`
Text string `json:"text"`
}
type jsonRule struct {
Type string `json:"type"`
}
type jsonImage struct {
Type string `json:"type"`
Src string `json:"src"`
Alt string `json:"alt"`
}
func renderJSON(blocks []block) (string, error) {
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(toJSONBlocks(blocks)); err != nil {
return "", err
}
return strings.TrimSuffix(buffer.String(), "\n"), nil
}
func toJSONBlocks(blocks []block) []any {
result := make([]any, 0, len(blocks))
for _, b := range blocks {
result = append(result, toJSONBlock(b))
}
return result
}
func toJSONBlock(b block) any {
switch b.kind {
case kindHeading:
return jsonHeading{Type: "heading", Level: b.level, Align: b.align, Children: toJSONTexts(b.inlines)}
case kindParagraph:
return jsonParagraph{Type: "paragraph", Align: b.align, Children: toJSONTexts(b.inlines)}
case kindList:
items := make([][]any, 0, len(b.items))
for _, item := range b.items {
items = append(items, toJSONBlocks(item))
}
return jsonList{Type: "list", Ordered: b.ordered, Items: items}
case kindBlockquote:
return jsonBlockquote{Type: "blockquote", Children: toJSONBlocks(b.children)}
case kindCode:
return jsonCode{Type: "code", Language: b.language, Text: b.text}
case kindImage:
return jsonImage{Type: "image", Src: b.src, Alt: b.alt}
}
return jsonRule{Type: "hr"}
}
func toJSONTexts(inlines []inline) []jsonText {
plain := withoutCodeMarks(inlines)
texts := make([]jsonText, 0, len(plain))
for _, item := range plain {
marks := make([]jsonMark, 0, len(item.marks))
for _, m := range item.marks {
marks = append(marks, jsonMark{Type: m.kind, Href: m.href})
}
texts = append(texts, jsonText{Type: "text", Text: item.text, Marks: marks})
}
return texts
}
@@ -0,0 +1,213 @@
package markdown
import (
"regexp"
"strconv"
"strings"
)
const (
blockSeparator = "\n\n"
hardBreak = "\\\n"
bulletMarker = "- "
codeFence = "```"
alignOpen = `<div style="text-align: `
alignOpenEnd = `">`
alignClose = "</div>"
)
var textEscaper = strings.NewReplacer(
"\\", "\\\\", "*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]", "<", "\\<",
)
var (
numberedLineStart = regexp.MustCompile(`^(\d+)([.)])(\s|$)`)
markerLineStart = regexp.MustCompile(`^(>|#{1,6}(\s|$)|[+-](\s|$|-)|=+\s*$)`)
)
var hrefEscaper = strings.NewReplacer(" ", "%20", "(", "%28", ")", "%29", "<", "%3C", ">", "%3E")
func renderMarkdown(blocks []block) string {
return renderBlocks(blocks, blockSeparator)
}
func renderBlocks(blocks []block, separator string) string {
parts := make([]string, 0, len(blocks))
for _, b := range blocks {
if rendered := renderBlock(b); rendered != "" {
parts = append(parts, rendered)
}
}
return strings.Join(parts, separator)
}
func renderBlock(b block) string {
rendered := renderPlainBlock(b)
if b.align == "" || rendered == "" {
return rendered
}
return alignOpen + b.align + alignOpenEnd + blockSeparator + rendered + blockSeparator + alignClose
}
func renderPlainBlock(b block) string {
switch b.kind {
case kindHeading:
return strings.Repeat("#", b.level) + " " + renderInlines(b.inlines)
case kindParagraph:
return escapeLineStarts(renderInlines(b.inlines))
case kindList:
return renderList(b)
case kindBlockquote:
return prefixLines(renderBlocks(b.children, blockSeparator), "> ", ">")
case kindCode:
return renderCode(b)
case kindImage:
return "![" + escapeAlt(b.alt) + "](" + hrefEscaper.Replace(b.src) + ")"
}
return "---"
}
func escapeLineStarts(text string) string {
lines := strings.Split(text, "\n")
for index, line := range lines {
line = numberedLineStart.ReplaceAllString(line, `$1\$2$3`)
if markerLineStart.MatchString(line) {
line = `\` + line
}
lines[index] = line
}
return strings.Join(lines, "\n")
}
func renderCode(b block) string {
fence := codeFence
for strings.Contains(b.text, fence) {
fence += "`"
}
return fence + b.language + "\n" + b.text + "\n" + fence
}
func escapeAlt(alt string) string {
return strings.NewReplacer("[", "\\[", "]", "\\]", "\n", " ").Replace(alt)
}
func renderList(b block) string {
lines := make([]string, 0, len(b.items))
for index, item := range b.items {
marker := bulletMarker
if b.ordered {
marker = strconv.Itoa(index+1) + ". "
}
content := renderBlocks(item, itemSeparator(item))
lines = append(lines, marker+indentContinuation(content, len(marker)))
}
return strings.Join(lines, "\n")
}
func itemSeparator(item []block) string {
textBlocks := 0
for _, b := range item {
if b.kind != kindList {
textBlocks++
}
}
if textBlocks > 1 {
return blockSeparator
}
return "\n"
}
func indentContinuation(content string, width int) string {
indent := strings.Repeat(" ", width)
lines := strings.Split(content, "\n")
for index := 1; index < len(lines); index++ {
if lines[index] != "" {
lines[index] = indent + lines[index]
}
}
return strings.Join(lines, "\n")
}
func prefixLines(content, prefix, emptyPrefix string) string {
lines := strings.Split(content, "\n")
for index, line := range lines {
if line == "" {
lines[index] = emptyPrefix
continue
}
lines[index] = prefix + line
}
return strings.Join(lines, "\n")
}
func renderInlines(inlines []inline) string {
var builder strings.Builder
for _, item := range inlines {
builder.WriteString(renderInline(item))
}
return builder.String()
}
func renderInline(item inline) string {
isCode := hasMarkKind(item.marks, markCode)
lines := strings.Split(item.text, "\n")
for index, line := range lines {
lines[index] = wrapMarks(item.marks, line, isCode)
}
return strings.Join(lines, hardBreak)
}
func hasMarkKind(marks []mark, kind string) bool {
for _, m := range marks {
if m.kind == kind {
return true
}
}
return false
}
func wrapMarks(marks []mark, text string, isCode bool) string {
core := strings.TrimSpace(text)
if core == "" {
return text
}
start := strings.Index(text, core)
leading, trailing := text[:start], text[start+len(core):]
var wrapped string
if isCode {
wrapped = inlineCode(core)
} else {
wrapped = escapeText(core)
}
for index := len(marks) - 1; index >= 0; index-- {
wrapped = applyMark(marks[index], wrapped)
}
return leading + wrapped + trailing
}
func inlineCode(text string) string {
fence := "`"
for strings.Contains(text, fence) {
fence += "`"
}
if strings.HasPrefix(text, "`") || strings.HasSuffix(text, "`") {
return fence + " " + text + " " + fence
}
return fence + text + fence
}
func applyMark(m mark, text string) string {
switch m.kind {
case markBold:
return "**" + text + "**"
case markItalic:
return "*" + text + "*"
case markLink:
return "[" + text + "](" + hrefEscaper.Replace(m.href) + ")"
}
return text
}
func escapeText(text string) string {
return textEscaper.Replace(text)
}