The source headers, LICENSE files, and license metadata had drifted apart. Align the entire project to MIT: - Convert every source-file header to the MIT text across all comment styles (Go, TS, TSX, JS, MJS, SQL, CSS, GraphQL, shell), including SPDX-License-Identifier tags - Set the root and cookie-banner LICENSE files to the MIT text with a "MIT License" title line - Switch the package.json license fields, Docker image label, and cookie-banner README to MIT - Update docs and the genmodels header generator accordingly - Normalize copyright lines to a single format (Copyright (c) <year(s)> Probo Inc <hello@probo.com>.): unify the hello@getprobo.com and hello@probo.inc emails to hello@probo.com and the comma-separated years to a hyphenated range Genuine third-party references are intentionally left untouched: the Lucide icon attributions (Lucide is ISC) and the trivy dependency license allowlist. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
786 lines
17 KiB
Go
786 lines
17 KiB
Go
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
// SOFTWARE.
|
|
|
|
package prosemirror
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/yuin/goldmark"
|
|
"github.com/yuin/goldmark/ast"
|
|
goldmarkext "github.com/yuin/goldmark/extension"
|
|
goldmarkast "github.com/yuin/goldmark/extension/ast"
|
|
"github.com/yuin/goldmark/parser"
|
|
"github.com/yuin/goldmark/text"
|
|
)
|
|
|
|
// ParseMarkdown converts a markdown string into a ProseMirror Node tree.
|
|
func ParseMarkdown(markdown string) (Node, error) {
|
|
source := []byte(markdown)
|
|
|
|
md := goldmark.New(
|
|
goldmark.WithExtensions(
|
|
goldmarkext.Strikethrough,
|
|
goldmarkext.Table,
|
|
),
|
|
goldmark.WithParserOptions(parser.WithAutoHeadingID()),
|
|
)
|
|
|
|
doc := md.Parser().Parse(text.NewReader(source))
|
|
|
|
c := &converter{source: source}
|
|
|
|
nodes, err := c.convertChildren(doc)
|
|
if err != nil {
|
|
return Node{}, fmt.Errorf("cannot convert markdown to prosemirror: %w", err)
|
|
}
|
|
|
|
return Node{
|
|
Type: NodeDoc,
|
|
Content: nodes,
|
|
}, nil
|
|
}
|
|
|
|
// normalizeCodeBlockContent strips one trailing newline when it is only the
|
|
// line terminator goldmark attaches to the last content line. That avoids an
|
|
// extra visible blank line in editors (e.g. TipTap) while preserving a
|
|
// trailing blank line in the source, which ends with two newlines.
|
|
func normalizeCodeBlockContent(content string) string {
|
|
if content == "" {
|
|
return content
|
|
}
|
|
|
|
if strings.HasSuffix(content, "\n") && !strings.HasSuffix(content, "\n\n") {
|
|
return strings.TrimSuffix(content, "\n")
|
|
}
|
|
|
|
return content
|
|
}
|
|
|
|
type converter struct {
|
|
source []byte
|
|
marks []Mark
|
|
}
|
|
|
|
func (c *converter) convertChildren(n ast.Node) ([]Node, error) {
|
|
var nodes []Node
|
|
|
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
|
converted, err := c.convertNode(child)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodes = append(nodes, converted...)
|
|
}
|
|
|
|
return nodes, nil
|
|
}
|
|
|
|
// convertInlineChildren walks inline content and merges adjacent RawHTML + Text
|
|
// segments so tags split by goldmark (e.g. <strong>, b, </strong>) form one HTML
|
|
// fragment for sanitization and conversion.
|
|
func (c *converter) convertInlineChildren(n ast.Node) ([]Node, error) {
|
|
var nodes []Node
|
|
|
|
for ch := n.FirstChild(); ch != nil; {
|
|
if ch.Kind() == ast.KindRawHTML {
|
|
run, next := c.collectRawHTMLRun(ch)
|
|
|
|
inodes, err := convertProseMirrorFromInlineHTML(run)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodes = append(nodes, prependOuterMarks(copyMarks(c.marks), inodes)...)
|
|
ch = next
|
|
|
|
continue
|
|
}
|
|
|
|
converted, err := c.convertNode(ch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
nodes = append(nodes, converted...)
|
|
ch = ch.NextSibling()
|
|
}
|
|
|
|
return nodes, nil
|
|
}
|
|
|
|
// collectRawHTMLRun concatenates a leading RawHTML node and following Text/String
|
|
// and RawHTML siblings until a different node kind is seen. next is the first
|
|
// sibling not consumed (or nil).
|
|
func (c *converter) collectRawHTMLRun(start ast.Node) (run string, next ast.Node) {
|
|
var buf bytes.Buffer
|
|
|
|
ch := start
|
|
for ch != nil {
|
|
switch ch.Kind() {
|
|
case ast.KindRawHTML:
|
|
raw := ch.(*ast.RawHTML)
|
|
for i := 0; i < raw.Segments.Len(); i++ {
|
|
seg := raw.Segments.At(i)
|
|
buf.Write(seg.Value(c.source))
|
|
}
|
|
case ast.KindText:
|
|
t := ch.(*ast.Text)
|
|
buf.Write(t.Segment.Value(c.source))
|
|
|
|
if t.SoftLineBreak() {
|
|
buf.WriteByte(' ')
|
|
}
|
|
case ast.KindString:
|
|
buf.Write(ch.(*ast.String).Value)
|
|
default:
|
|
return buf.String(), ch
|
|
}
|
|
|
|
ch = ch.NextSibling()
|
|
}
|
|
|
|
return buf.String(), nil
|
|
}
|
|
|
|
func (c *converter) convertNode(n ast.Node) ([]Node, error) {
|
|
switch n.Kind() {
|
|
case ast.KindHeading:
|
|
return c.convertHeading(n.(*ast.Heading))
|
|
case ast.KindParagraph:
|
|
return c.convertParagraph(n)
|
|
case ast.KindBlockquote:
|
|
return c.convertBlockquote(n)
|
|
case ast.KindFencedCodeBlock:
|
|
return c.convertFencedCodeBlock(n.(*ast.FencedCodeBlock))
|
|
case ast.KindCodeBlock:
|
|
return c.convertCodeBlock(n.(*ast.CodeBlock))
|
|
case ast.KindList:
|
|
return c.convertList(n.(*ast.List))
|
|
case ast.KindListItem:
|
|
return c.convertListItem(n)
|
|
case ast.KindThematicBreak:
|
|
return []Node{{Type: NodeHorizontalRule}}, nil
|
|
case ast.KindImage:
|
|
return c.convertImage(n.(*ast.Image))
|
|
case ast.KindTextBlock:
|
|
return c.convertParagraph(n)
|
|
case ast.KindText:
|
|
return c.convertText(n.(*ast.Text))
|
|
case ast.KindString:
|
|
return c.convertString(n.(*ast.String))
|
|
case ast.KindEmphasis:
|
|
return c.convertEmphasis(n.(*ast.Emphasis))
|
|
case ast.KindCodeSpan:
|
|
return c.convertCodeSpan(n)
|
|
case ast.KindLink:
|
|
return c.convertLink(n.(*ast.Link))
|
|
case ast.KindAutoLink:
|
|
return c.convertAutoLink(n.(*ast.AutoLink))
|
|
case ast.KindRawHTML:
|
|
return c.convertRawHTML(n)
|
|
case ast.KindHTMLBlock:
|
|
return c.convertHTMLBlock(n.(*ast.HTMLBlock))
|
|
default:
|
|
switch n.Kind() {
|
|
case goldmarkast.KindStrikethrough:
|
|
return c.convertStrikethrough(n)
|
|
case goldmarkast.KindTable:
|
|
return c.convertTable(n)
|
|
case goldmarkast.KindTableHeader:
|
|
return c.convertTableHeaderRow(n.(*goldmarkast.TableHeader))
|
|
case goldmarkast.KindTableRow:
|
|
return c.convertTableDataRow(n)
|
|
case goldmarkast.KindTableCell:
|
|
return nil, fmt.Errorf("cannot convert table cell outside of a table row")
|
|
}
|
|
|
|
return nil, fmt.Errorf("cannot convert markdown node of kind %s", n.Kind())
|
|
}
|
|
}
|
|
|
|
func (c *converter) convertHeading(n *ast.Heading) ([]Node, error) {
|
|
children, err := c.convertInlineChildren(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
attrs, err := json.Marshal(HeadingAttrs{Level: n.Level})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal heading attrs: %w", err)
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeHeading,
|
|
Content: children,
|
|
Attrs: attrs,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertParagraph(n ast.Node) ([]Node, error) {
|
|
children, err := c.convertInlineChildren(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeParagraph,
|
|
Content: children,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertBlockquote(n ast.Node) ([]Node, error) {
|
|
children, err := c.convertChildren(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeBlockquote,
|
|
Content: children,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertFencedCodeBlock(n *ast.FencedCodeBlock) ([]Node, error) {
|
|
var buf bytes.Buffer
|
|
|
|
for i := 0; i < n.Lines().Len(); i++ {
|
|
line := n.Lines().At(i)
|
|
buf.Write(line.Value(c.source))
|
|
}
|
|
|
|
content := normalizeCodeBlockContent(buf.String())
|
|
|
|
var lang *string
|
|
|
|
if n.Language(c.source) != nil {
|
|
l := string(n.Language(c.source))
|
|
lang = &l
|
|
}
|
|
|
|
attrs, err := json.Marshal(CodeBlockAttrs{Language: lang})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal code block attrs: %w", err)
|
|
}
|
|
|
|
var textNodes []Node
|
|
if content != "" {
|
|
textNodes = []Node{{
|
|
Type: NodeText,
|
|
Text: &content,
|
|
}}
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeCodeBlock,
|
|
Content: textNodes,
|
|
Attrs: attrs,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertCodeBlock(n *ast.CodeBlock) ([]Node, error) {
|
|
var buf bytes.Buffer
|
|
|
|
for i := 0; i < n.Lines().Len(); i++ {
|
|
line := n.Lines().At(i)
|
|
buf.Write(line.Value(c.source))
|
|
}
|
|
|
|
content := normalizeCodeBlockContent(buf.String())
|
|
|
|
attrs, err := json.Marshal(CodeBlockAttrs{Language: nil})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal code block attrs: %w", err)
|
|
}
|
|
|
|
var textNodes []Node
|
|
if content != "" {
|
|
textNodes = []Node{{
|
|
Type: NodeText,
|
|
Text: &content,
|
|
}}
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeCodeBlock,
|
|
Content: textNodes,
|
|
Attrs: attrs,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertList(n *ast.List) ([]Node, error) {
|
|
children, err := c.convertChildren(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if n.IsOrdered() {
|
|
attrs, err := json.Marshal(OrderedListAttrs{Start: n.Start})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal ordered list attrs: %w", err)
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeOrderedList,
|
|
Content: children,
|
|
Attrs: attrs,
|
|
}}, nil
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeBulletList,
|
|
Content: children,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertListItem(n ast.Node) ([]Node, error) {
|
|
children, err := c.convertChildren(n)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeListItem,
|
|
Content: children,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertImage(n *ast.Image) ([]Node, error) {
|
|
imgAttrs := ImageAttrs{
|
|
Src: string(n.Destination),
|
|
}
|
|
|
|
if n.Title != nil {
|
|
t := string(n.Title)
|
|
imgAttrs.Title = &t
|
|
}
|
|
|
|
if alt := c.extractText(n); alt != "" {
|
|
imgAttrs.Alt = &alt
|
|
}
|
|
|
|
attrs, err := json.Marshal(imgAttrs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal image attrs: %w", err)
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeImage,
|
|
Attrs: attrs,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertText(n *ast.Text) ([]Node, error) {
|
|
content := string(n.Segment.Value(c.source))
|
|
if n.SoftLineBreak() {
|
|
content += " "
|
|
}
|
|
|
|
if content == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
nodes := []Node{{
|
|
Type: NodeText,
|
|
Text: &content,
|
|
Marks: copyMarks(c.marks),
|
|
}}
|
|
|
|
if n.HardLineBreak() {
|
|
nodes = append(nodes, Node{Type: NodeHardBreak})
|
|
}
|
|
|
|
return nodes, nil
|
|
}
|
|
|
|
func (c *converter) convertString(n *ast.String) ([]Node, error) {
|
|
content := string(n.Value)
|
|
if content == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
return []Node{{
|
|
Type: NodeText,
|
|
Text: &content,
|
|
Marks: copyMarks(c.marks),
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertEmphasis(n *ast.Emphasis) ([]Node, error) {
|
|
var mark Mark
|
|
if n.Level == 2 {
|
|
mark = Mark{Type: MarkStrong}
|
|
} else {
|
|
mark = Mark{Type: MarkEm}
|
|
}
|
|
|
|
c.marks = append(c.marks, mark)
|
|
children, err := c.convertInlineChildren(n)
|
|
c.marks = c.marks[:len(c.marks)-1]
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return children, nil
|
|
}
|
|
|
|
func (c *converter) convertCodeSpan(n ast.Node) ([]Node, error) {
|
|
var buf bytes.Buffer
|
|
|
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
|
if t, ok := child.(*ast.Text); ok {
|
|
buf.Write(t.Segment.Value(c.source))
|
|
}
|
|
}
|
|
|
|
content := buf.String()
|
|
if content == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
marks := copyMarks(c.marks)
|
|
marks = append(marks, Mark{Type: MarkCode})
|
|
|
|
return []Node{{
|
|
Type: NodeText,
|
|
Text: &content,
|
|
Marks: marks,
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertLink(n *ast.Link) ([]Node, error) {
|
|
linkAttrs := LinkAttrs{
|
|
Href: safeLinkHref(string(n.Destination)),
|
|
}
|
|
|
|
if n.Title != nil {
|
|
t := string(n.Title)
|
|
linkAttrs.Title = &t
|
|
}
|
|
|
|
attrs, err := json.Marshal(linkAttrs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
|
|
}
|
|
|
|
c.marks = append(c.marks, Mark{Type: MarkLink, Attrs: attrs})
|
|
children, err := c.convertInlineChildren(n)
|
|
c.marks = c.marks[:len(c.marks)-1]
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return children, nil
|
|
}
|
|
|
|
func (c *converter) convertAutoLink(n *ast.AutoLink) ([]Node, error) {
|
|
url := string(n.URL(c.source))
|
|
|
|
linkAttrs := LinkAttrs{Href: safeLinkHref(url)}
|
|
|
|
attrs, err := json.Marshal(linkAttrs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal link attrs: %w", err)
|
|
}
|
|
|
|
label := string(n.Label(c.source))
|
|
|
|
return []Node{{
|
|
Type: NodeText,
|
|
Text: &label,
|
|
Marks: append(copyMarks(c.marks), Mark{Type: MarkLink, Attrs: attrs}),
|
|
}}, nil
|
|
}
|
|
|
|
func (c *converter) convertRawHTML(n ast.Node) ([]Node, error) {
|
|
raw, ok := n.(*ast.RawHTML)
|
|
if !ok {
|
|
return nil, fmt.Errorf("cannot convert raw html: unexpected node type %T", n)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
|
|
for i := 0; i < raw.Segments.Len(); i++ {
|
|
seg := raw.Segments.At(i)
|
|
buf.Write(seg.Value(c.source))
|
|
}
|
|
|
|
run := buf.String()
|
|
if run == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
nodes, err := convertProseMirrorFromInlineHTML(run)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return prependOuterMarks(copyMarks(c.marks), nodes), nil
|
|
}
|
|
|
|
func (c *converter) convertHTMLBlock(n *ast.HTMLBlock) ([]Node, error) {
|
|
var buf bytes.Buffer
|
|
|
|
for i := 0; i < n.Lines().Len(); i++ {
|
|
line := n.Lines().At(i)
|
|
buf.Write(line.Value(c.source))
|
|
}
|
|
|
|
if n.HasClosure() {
|
|
buf.Write(n.ClosureLine.Value(c.source))
|
|
}
|
|
|
|
raw := buf.String()
|
|
if strings.TrimSpace(raw) == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
return convertProseMirrorFromHTMLBlock(raw)
|
|
}
|
|
|
|
func (c *converter) convertStrikethrough(n ast.Node) ([]Node, error) {
|
|
c.marks = append(c.marks, Mark{Type: MarkStrike})
|
|
children, err := c.convertInlineChildren(n)
|
|
c.marks = c.marks[:len(c.marks)-1]
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return children, nil
|
|
}
|
|
|
|
func (c *converter) convertTable(n ast.Node) ([]Node, error) {
|
|
var rows []Node
|
|
|
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
|
converted, err := c.convertNode(child)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rows = append(rows, converted...)
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return []Node{{Type: NodeTable, Content: rows}}, nil
|
|
}
|
|
|
|
func (c *converter) convertTableHeaderRow(n *goldmarkast.TableHeader) ([]Node, error) {
|
|
cells, err := c.convertTableCells(n, NodeTableHeader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(cells) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return []Node{{Type: NodeTableRow, Content: cells}}, nil
|
|
}
|
|
|
|
func (c *converter) convertTableDataRow(n ast.Node) ([]Node, error) {
|
|
cells, err := c.convertTableCells(n, NodeTableCell)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(cells) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
return []Node{{Type: NodeTableRow, Content: cells}}, nil
|
|
}
|
|
|
|
func (c *converter) convertTableCells(row ast.Node, cellType NodeType) ([]Node, error) {
|
|
var cells []Node
|
|
|
|
for child := row.FirstChild(); child != nil; child = child.NextSibling() {
|
|
if child.Kind() != goldmarkast.KindTableCell {
|
|
continue
|
|
}
|
|
|
|
content, err := c.convertTableCellContent(child)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cellAttrs := TableCellAttrs{
|
|
Colspan: 1,
|
|
Rowspan: 1,
|
|
}
|
|
|
|
attrs, err := json.Marshal(cellAttrs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("cannot marshal table cell attrs: %w", err)
|
|
}
|
|
|
|
cells = append(
|
|
cells,
|
|
Node{
|
|
Type: cellType,
|
|
Attrs: attrs,
|
|
Content: content,
|
|
},
|
|
)
|
|
}
|
|
|
|
return cells, nil
|
|
}
|
|
|
|
func (c *converter) convertTableCellContent(cell ast.Node) ([]Node, error) {
|
|
if c.cellHasBlockHTML(cell) {
|
|
raw := c.collectCellRawContent(cell)
|
|
|
|
nodes, err := convertProseMirrorFromHTMLBlock(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(nodes) > 0 {
|
|
return nodes, nil
|
|
}
|
|
}
|
|
|
|
inlineContent, err := c.convertInlineChildren(cell)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return []Node{{Type: NodeParagraph, Content: inlineContent}}, nil
|
|
}
|
|
|
|
func (c *converter) cellHasBlockHTML(cell ast.Node) bool {
|
|
for ch := cell.FirstChild(); ch != nil; ch = ch.NextSibling() {
|
|
if ch.Kind() != ast.KindRawHTML {
|
|
continue
|
|
}
|
|
|
|
raw := ch.(*ast.RawHTML)
|
|
for i := 0; i < raw.Segments.Len(); i++ {
|
|
seg := raw.Segments.At(i)
|
|
|
|
val := strings.ToLower(string(seg.Value(c.source)))
|
|
if containsBlockOpenTag(val) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func containsBlockOpenTag(s string) bool {
|
|
for _, tag := range []string{
|
|
"<ul", "<ol", "<table", "<blockquote", "<pre", "<div",
|
|
"<h1", "<h2", "<h3", "<h4", "<h5", "<h6", "<hr",
|
|
} {
|
|
if strings.Contains(s, tag) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func (c *converter) collectCellRawContent(cell ast.Node) string {
|
|
var buf bytes.Buffer
|
|
|
|
for ch := cell.FirstChild(); ch != nil; ch = ch.NextSibling() {
|
|
switch ch.Kind() {
|
|
case ast.KindRawHTML:
|
|
raw := ch.(*ast.RawHTML)
|
|
for i := 0; i < raw.Segments.Len(); i++ {
|
|
seg := raw.Segments.At(i)
|
|
buf.Write(seg.Value(c.source))
|
|
}
|
|
case ast.KindText:
|
|
t := ch.(*ast.Text)
|
|
buf.Write(t.Segment.Value(c.source))
|
|
|
|
if t.SoftLineBreak() {
|
|
buf.WriteByte(' ')
|
|
}
|
|
case ast.KindString:
|
|
buf.Write(ch.(*ast.String).Value)
|
|
default:
|
|
buf.WriteString(c.extractText(ch))
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
// extractText recursively collects the text content of all descendant nodes.
|
|
func (c *converter) extractText(n ast.Node) string {
|
|
var buf bytes.Buffer
|
|
|
|
for child := n.FirstChild(); child != nil; child = child.NextSibling() {
|
|
switch child.Kind() {
|
|
case ast.KindText:
|
|
buf.Write(child.(*ast.Text).Segment.Value(c.source))
|
|
case ast.KindString:
|
|
buf.Write(child.(*ast.String).Value)
|
|
default:
|
|
buf.WriteString(c.extractText(child))
|
|
}
|
|
}
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
func copyMarks(marks []Mark) []Mark {
|
|
if len(marks) == 0 {
|
|
return nil
|
|
}
|
|
|
|
cp := make([]Mark, len(marks))
|
|
copy(cp, marks)
|
|
|
|
return cp
|
|
}
|
|
|
|
// prependOuterMarks applies markdown inline context marks (e.g. emphasis around
|
|
// raw HTML) to nodes produced from sanitized HTML. Images are left unchanged.
|
|
func prependOuterMarks(outer []Mark, nodes []Node) []Node {
|
|
if len(outer) == 0 {
|
|
return nodes
|
|
}
|
|
|
|
for i := range nodes {
|
|
if nodes[i].Type == NodeImage {
|
|
continue
|
|
}
|
|
|
|
nodes[i].Marks = append(copyMarks(outer), nodes[i].Marks...)
|
|
}
|
|
|
|
return nodes
|
|
}
|