Commit 739fd2cd authored by Claude's avatar Claude

refactor: update markdown parser

- Removed the `nodes` field from the `Memo` interface in `memo_service.ts`.
- Updated the `createBaseMemo` function and the `Memo` message functions to reflect the removal of `nodes`.
- Cleaned up the serialization and deserialization logic accordingly.

chore: remove code-inspector-plugin from Vite configuration

- Deleted the `codeInspectorPlugin` from the Vite configuration in `vite.config.mts`.
- Simplified the plugins array to include only `react` and `tailwindcss`.
parent bfad0708
...@@ -23,7 +23,7 @@ require ( ...@@ -23,7 +23,7 @@ require (
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.20.1 github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
github.com/usememos/gomark v0.0.0-20251021153759-00d1ea6c86f0 github.com/yuin/goldmark v1.7.13
golang.org/x/crypto v0.42.0 golang.org/x/crypto v0.42.0
golang.org/x/mod v0.28.0 golang.org/x/mod v0.28.0
golang.org/x/net v0.43.0 golang.org/x/net v0.43.0
......
...@@ -433,8 +433,6 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM ...@@ -433,8 +433,6 @@ github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVM
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
github.com/usememos/gomark v0.0.0-20251021153759-00d1ea6c86f0 h1:hN+LjlPdqd/6OLYWs5mYYwJ6WUQBKBUreCt1Kg8u5jk=
github.com/usememos/gomark v0.0.0-20251021153759-00d1ea6c86f0/go.mod h1:7CZRoYFQyyljzplOTeyODFR26O+wr0BbnpTWVLGfKJA=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
...@@ -442,6 +440,8 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+ ...@@ -442,6 +440,8 @@ github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg= go.etcd.io/etcd v0.0.0-20191023171146-3cf2f69b5738/go.mod h1:dnLIgRNXwCJa5e+c6mIZCrds/GIG4ncV9HhK5PX7jPg=
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
......
package ast
import (
gast "github.com/yuin/goldmark/ast"
)
// TagNode represents a #tag in the markdown AST.
type TagNode struct {
gast.BaseInline
// Tag name without the # prefix
Tag []byte
}
// KindTag is the NodeKind for TagNode.
var KindTag = gast.NewNodeKind("Tag")
// Kind returns KindTag.
func (*TagNode) Kind() gast.NodeKind {
return KindTag
}
// Dump implements Node.Dump for debugging.
func (n *TagNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, map[string]string{
"Tag": string(n.Tag),
}, nil)
}
package ast
import (
gast "github.com/yuin/goldmark/ast"
)
// WikilinkNode represents [[target]] or [[target?params]] syntax.
type WikilinkNode struct {
gast.BaseInline
// Target is the link destination (e.g., "memos/1", "Hello world", "resources/101")
Target []byte
// Params are optional parameters (e.g., "align=center" from [[target?align=center]])
Params []byte
}
// KindWikilink is the NodeKind for WikilinkNode.
var KindWikilink = gast.NewNodeKind("Wikilink")
// Kind returns KindWikilink.
func (*WikilinkNode) Kind() gast.NodeKind {
return KindWikilink
}
// Dump implements Node.Dump for debugging.
func (n *WikilinkNode) Dump(source []byte, level int) {
gast.DumpHelper(n, source, level, map[string]string{
"Target": string(n.Target),
"Params": string(n.Params),
}, nil)
}
package extensions
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
mparser "github.com/usememos/memos/plugin/markdown/parser"
)
type tagExtension struct{}
// TagExtension is a goldmark extension for #tag syntax
var TagExtension = &tagExtension{}
// Extend extends the goldmark parser with tag support.
func (*tagExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithInlineParsers(
// Priority 200 - run before standard link parser (500)
util.Prioritized(mparser.NewTagParser(), 200),
),
)
}
package extensions
import (
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/util"
mparser "github.com/usememos/memos/plugin/markdown/parser"
)
type wikilinkExtension struct{}
// WikilinkExtension is a goldmark extension for [[...]] wikilink syntax
var WikilinkExtension = &wikilinkExtension{}
// Extend extends the goldmark parser with wikilink support.
func (*wikilinkExtension) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(
parser.WithInlineParsers(
// Priority 199 - run before standard link parser (500) but after tags (200)
util.Prioritized(mparser.NewWikilinkParser(), 199),
),
)
}
This diff is collapsed.
This diff is collapsed.
package parser
import (
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
type tagParser struct{}
// NewTagParser creates a new inline parser for #tag syntax
func NewTagParser() parser.InlineParser {
return &tagParser{}
}
// Trigger returns the characters that trigger this parser.
func (*tagParser) Trigger() []byte {
return []byte{'#'}
}
// Parse parses #tag syntax
func (p *tagParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node {
line, _ := block.PeekLine()
// Must start with #
if len(line) == 0 || line[0] != '#' {
return nil
}
// Check if it's a heading (## or space after #)
if len(line) > 1 {
if line[1] == '#' {
// It's a heading (##), not a tag
return nil
}
if line[1] == ' ' {
// Space after # - heading or just a hash
return nil
}
} else {
// Just a lone #
return nil
}
// Scan tag characters
// Valid: alphanumeric, dash, underscore
tagEnd := 1 // Start after #
for tagEnd < len(line) {
c := line[tagEnd]
isValid := (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '-' || c == '_'
if !isValid {
break
}
tagEnd++
}
// Must have at least one character after #
if tagEnd == 1 {
return nil
}
// Extract tag (without #)
tagName := line[1:tagEnd]
// Make a copy of the tag name
tagCopy := make([]byte, len(tagName))
copy(tagCopy, tagName)
// Advance reader
block.Advance(tagEnd)
// Create node
node := &mast.TagNode{
Tag: tagCopy,
}
return node
}
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
func TestTagParser(t *testing.T) {
tests := []struct {
name string
input string
expectedTag string
shouldParse bool
}{
{
name: "basic tag",
input: "#tag",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag with hyphen",
input: "#work-notes",
expectedTag: "work-notes",
shouldParse: true,
},
{
name: "tag with underscore",
input: "#2024_plans",
expectedTag: "2024_plans",
shouldParse: true,
},
{
name: "numeric tag",
input: "#123",
expectedTag: "123",
shouldParse: true,
},
{
name: "tag followed by space",
input: "#tag ",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag followed by punctuation",
input: "#tag.",
expectedTag: "tag",
shouldParse: true,
},
{
name: "tag in sentence",
input: "#important task",
expectedTag: "important",
shouldParse: true,
},
{
name: "heading (##)",
input: "## Heading",
expectedTag: "",
shouldParse: false,
},
{
name: "space after hash",
input: "# heading",
expectedTag: "",
shouldParse: false,
},
{
name: "lone hash",
input: "#",
expectedTag: "",
shouldParse: false,
},
{
name: "hash with space",
input: "# ",
expectedTag: "",
shouldParse: false,
},
{
name: "special characters",
input: "#tag@special",
expectedTag: "tag",
shouldParse: true, // Stops at @
},
{
name: "mixed case",
input: "#WorkNotes",
expectedTag: "WorkNotes",
shouldParse: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewTagParser()
reader := text.NewReader([]byte(tt.input))
ctx := parser.NewContext()
node := p.Parse(nil, reader, ctx)
if tt.shouldParse {
require.NotNil(t, node, "Expected tag to be parsed")
require.IsType(t, &mast.TagNode{}, node)
tagNode := node.(*mast.TagNode)
assert.Equal(t, tt.expectedTag, string(tagNode.Tag))
} else {
assert.Nil(t, node, "Expected tag NOT to be parsed")
}
})
}
}
func TestTagParser_Trigger(t *testing.T) {
p := NewTagParser()
triggers := p.Trigger()
assert.Equal(t, []byte{'#'}, triggers)
}
func TestTagParser_MultipleTags(t *testing.T) {
// Test that parser correctly handles multiple tags in sequence
input := "#tag1 #tag2"
p := NewTagParser()
reader := text.NewReader([]byte(input))
ctx := parser.NewContext()
// Parse first tag
node1 := p.Parse(nil, reader, ctx)
require.NotNil(t, node1)
tagNode1 := node1.(*mast.TagNode)
assert.Equal(t, "tag1", string(tagNode1.Tag))
// Advance past the space
reader.Advance(1)
// Parse second tag
node2 := p.Parse(nil, reader, ctx)
require.NotNil(t, node2)
tagNode2 := node2.(*mast.TagNode)
assert.Equal(t, "tag2", string(tagNode2.Tag))
}
func TestTagNode_Kind(t *testing.T) {
node := &mast.TagNode{
Tag: []byte("test"),
}
assert.Equal(t, mast.KindTag, node.Kind())
}
func TestTagNode_Dump(t *testing.T) {
node := &mast.TagNode{
Tag: []byte("test"),
}
// Should not panic
assert.NotPanics(t, func() {
node.Dump([]byte("#test"), 0)
})
}
package parser
import (
"bytes"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
type wikilinkParser struct{}
// NewWikilinkParser creates a new inline parser for [[...]] wikilink syntax
func NewWikilinkParser() parser.InlineParser {
return &wikilinkParser{}
}
// Trigger returns the characters that trigger this parser.
func (*wikilinkParser) Trigger() []byte {
return []byte{'['}
}
// Parse parses [[target]] or [[target?params]] wikilink syntax.
func (*wikilinkParser) Parse(parent gast.Node, block text.Reader, pc parser.Context) gast.Node {
line, _ := block.PeekLine()
// Must start with [[
if len(line) < 2 || line[0] != '[' || line[1] != '[' {
return nil
}
// Find closing ]]
closePos := findClosingBrackets(line[2:])
if closePos == -1 {
return nil
}
// Extract content between [[ and ]]
// closePos is relative to line[2:], so actual position is closePos + 2
contentStart := 2
contentEnd := contentStart + closePos
content := line[contentStart:contentEnd]
// Empty content is not allowed
if len(bytes.TrimSpace(content)) == 0 {
return nil
}
// Parse target and parameters
target, params := parseTargetAndParams(content)
// Advance reader position
// +2 for [[, +len(content), +2 for ]]
block.Advance(contentEnd + 2)
// Create AST node
node := &mast.WikilinkNode{
Target: target,
Params: params,
}
return node
}
// findClosingBrackets finds the position of ]] in the byte slice
// Returns -1 if not found
func findClosingBrackets(data []byte) int {
for i := 0; i < len(data)-1; i++ {
if data[i] == ']' && data[i+1] == ']' {
return i
}
}
return -1
}
// parseTargetAndParams splits content on ? to extract target and parameters
func parseTargetAndParams(content []byte) (target []byte, params []byte) {
// Find ? separator
idx := bytes.IndexByte(content, '?')
if idx == -1 {
// No parameters
target = bytes.TrimSpace(content)
return target, nil
}
// Split on ?
target = bytes.TrimSpace(content[:idx])
params = content[idx+1:] // Keep params as-is (don't trim, might have meaningful spaces)
// Make copies to avoid issues with slice sharing
targetCopy := make([]byte, len(target))
copy(targetCopy, target)
var paramsCopy []byte
if len(params) > 0 {
paramsCopy = make([]byte, len(params))
copy(paramsCopy, params)
}
return targetCopy, paramsCopy
}
package parser
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
func TestWikilinkParser(t *testing.T) {
tests := []struct {
name string
input string
expectedTarget string
expectedParams string
shouldParse bool
}{
{
name: "basic wikilink",
input: "[[Hello world]]",
expectedTarget: "Hello world",
expectedParams: "",
shouldParse: true,
},
{
name: "memo wikilink",
input: "[[memos/1]]",
expectedTarget: "memos/1",
expectedParams: "",
shouldParse: true,
},
{
name: "resource wikilink",
input: "[[resources/101]]",
expectedTarget: "resources/101",
expectedParams: "",
shouldParse: true,
},
{
name: "with parameters",
input: "[[resources/101?align=center]]",
expectedTarget: "resources/101",
expectedParams: "align=center",
shouldParse: true,
},
{
name: "multiple parameters",
input: "[[resources/101?align=center&width=300]]",
expectedTarget: "resources/101",
expectedParams: "align=center&width=300",
shouldParse: true,
},
{
name: "inline with text after",
input: "[[resources/101]]111",
expectedTarget: "resources/101",
expectedParams: "",
shouldParse: true,
},
{
name: "whitespace trimmed",
input: "[[ Hello world ]]",
expectedTarget: "Hello world",
expectedParams: "",
shouldParse: true,
},
{
name: "empty content",
input: "[[]]",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "whitespace only",
input: "[[ ]]",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "missing closing brackets",
input: "[[Hello world",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "single bracket",
input: "[Hello]",
expectedTarget: "",
expectedParams: "",
shouldParse: false,
},
{
name: "nested brackets",
input: "[[outer [[inner]] ]]",
expectedTarget: "outer [[inner",
expectedParams: "",
shouldParse: true, // Stops at first ]]
},
{
name: "special characters",
input: "[[Project/2024/Notes]]",
expectedTarget: "Project/2024/Notes",
expectedParams: "",
shouldParse: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := NewWikilinkParser()
reader := text.NewReader([]byte(tt.input))
ctx := parser.NewContext()
node := p.Parse(nil, reader, ctx)
if tt.shouldParse {
require.NotNil(t, node, "Expected wikilink to be parsed")
require.IsType(t, &mast.WikilinkNode{}, node)
wikilinkNode := node.(*mast.WikilinkNode)
assert.Equal(t, tt.expectedTarget, string(wikilinkNode.Target))
assert.Equal(t, tt.expectedParams, string(wikilinkNode.Params))
} else {
assert.Nil(t, node, "Expected wikilink NOT to be parsed")
}
})
}
}
func TestWikilinkParser_Trigger(t *testing.T) {
p := NewWikilinkParser()
triggers := p.Trigger()
assert.Equal(t, []byte{'['}, triggers)
}
func TestFindClosingBrackets(t *testing.T) {
tests := []struct {
name string
input []byte
expected int
}{
{
name: "simple case",
input: []byte("hello]]world"),
expected: 5,
},
{
name: "not found",
input: []byte("hello world"),
expected: -1,
},
{
name: "at start",
input: []byte("]]hello"),
expected: 0,
},
{
name: "single bracket",
input: []byte("hello]world"),
expected: -1,
},
{
name: "empty",
input: []byte(""),
expected: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := findClosingBrackets(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParseTargetAndParams(t *testing.T) {
tests := []struct {
name string
input []byte
expectedTarget string
expectedParams string
}{
{
name: "no params",
input: []byte("target"),
expectedTarget: "target",
expectedParams: "",
},
{
name: "with params",
input: []byte("target?param=value"),
expectedTarget: "target",
expectedParams: "param=value",
},
{
name: "multiple params",
input: []byte("target?a=1&b=2"),
expectedTarget: "target",
expectedParams: "a=1&b=2",
},
{
name: "whitespace trimmed from target",
input: []byte(" target ?param=value"),
expectedTarget: "target",
expectedParams: "param=value",
},
{
name: "empty params",
input: []byte("target?"),
expectedTarget: "target",
expectedParams: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
target, params := parseTargetAndParams(tt.input)
assert.Equal(t, tt.expectedTarget, string(target))
assert.Equal(t, tt.expectedParams, string(params))
})
}
}
func TestWikilinkNode_Kind(t *testing.T) {
node := &mast.WikilinkNode{
Target: []byte("test"),
}
assert.Equal(t, mast.KindWikilink, node.Kind())
}
func TestWikilinkNode_Dump(t *testing.T) {
node := &mast.WikilinkNode{
Target: []byte("test"),
Params: []byte("param=value"),
}
// Should not panic
assert.NotPanics(t, func() {
node.Dump([]byte("[[test?param=value]]"), 0)
})
}
package renderer
import (
"bytes"
"fmt"
"strings"
gast "github.com/yuin/goldmark/ast"
east "github.com/yuin/goldmark/extension/ast"
mast "github.com/usememos/memos/plugin/markdown/ast"
)
// MarkdownRenderer renders goldmark AST back to markdown text.
type MarkdownRenderer struct {
buf *bytes.Buffer
}
// NewMarkdownRenderer creates a new markdown renderer.
func NewMarkdownRenderer() *MarkdownRenderer {
return &MarkdownRenderer{
buf: &bytes.Buffer{},
}
}
// Render renders the AST node to markdown and returns the result.
func (r *MarkdownRenderer) Render(node gast.Node, source []byte) string {
r.buf.Reset()
r.renderNode(node, source, 0)
return r.buf.String()
}
// renderNode renders a single node and its children.
func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int) {
switch n := node.(type) {
case *gast.Document:
r.renderChildren(n, source, depth)
case *gast.Paragraph:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.Text:
// Text nodes store their content as segments in the source
segment := n.Segment
r.buf.Write(segment.Value(source))
if n.SoftLineBreak() {
r.buf.WriteByte('\n')
} else if n.HardLineBreak() {
r.buf.WriteString(" \n")
}
case *gast.CodeSpan:
r.buf.WriteByte('`')
r.renderChildren(n, source, depth)
r.buf.WriteByte('`')
case *gast.Emphasis:
symbol := "*"
if n.Level == 2 {
symbol = "**"
}
r.buf.WriteString(symbol)
r.renderChildren(n, source, depth)
r.buf.WriteString(symbol)
case *gast.Link:
r.buf.WriteString("[")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.AutoLink:
url := n.URL(source)
if n.AutoLinkType == gast.AutoLinkEmail {
r.buf.WriteString("<")
r.buf.Write(url)
r.buf.WriteString(">")
} else {
r.buf.Write(url)
}
case *gast.Image:
r.buf.WriteString("![")
r.renderChildren(n, source, depth)
r.buf.WriteString("](")
r.buf.Write(n.Destination)
if len(n.Title) > 0 {
r.buf.WriteString(` "`)
r.buf.Write(n.Title)
r.buf.WriteString(`"`)
}
r.buf.WriteString(")")
case *gast.Heading:
r.buf.WriteString(strings.Repeat("#", n.Level))
r.buf.WriteByte(' ')
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.CodeBlock, *gast.FencedCodeBlock:
r.renderCodeBlock(n, source)
case *gast.Blockquote:
// Render each child line with "> " prefix
r.renderBlockquote(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.List:
r.renderChildren(n, source, depth)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *gast.ListItem:
r.renderListItem(n, source, depth)
case *gast.ThematicBreak:
r.buf.WriteString("---")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
case *east.Strikethrough:
r.buf.WriteString("~~")
r.renderChildren(n, source, depth)
r.buf.WriteString("~~")
case *east.TaskCheckBox:
if n.IsChecked {
r.buf.WriteString("[x] ")
} else {
r.buf.WriteString("[ ] ")
}
case *east.Table:
r.renderTable(n, source)
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
// Custom Memos nodes
case *mast.TagNode:
r.buf.WriteByte('#')
r.buf.Write(n.Tag)
case *mast.WikilinkNode:
r.buf.WriteString("[[")
r.buf.Write(n.Target)
if len(n.Params) > 0 {
r.buf.WriteByte('?')
r.buf.Write(n.Params)
}
r.buf.WriteString("]]")
default:
// For unknown nodes, try to render children
r.renderChildren(n, source, depth)
}
}
// renderChildren renders all children of a node.
func (r *MarkdownRenderer) renderChildren(node gast.Node, source []byte, depth int) {
child := node.FirstChild()
for child != nil {
r.renderNode(child, source, depth+1)
child = child.NextSibling()
}
}
// renderCodeBlock renders a code block.
func (r *MarkdownRenderer) renderCodeBlock(node gast.Node, source []byte) {
if fenced, ok := node.(*gast.FencedCodeBlock); ok {
// Fenced code block with language
r.buf.WriteString("```")
if lang := fenced.Language(source); len(lang) > 0 {
r.buf.Write(lang)
}
r.buf.WriteByte('\n')
// Write all lines
lines := fenced.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.Write(line.Value(source))
}
r.buf.WriteString("```")
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
} else if codeBlock, ok := node.(*gast.CodeBlock); ok {
// Indented code block
lines := codeBlock.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
r.buf.WriteString(" ")
r.buf.Write(line.Value(source))
}
if node.NextSibling() != nil {
r.buf.WriteString("\n\n")
}
}
}
// renderBlockquote renders a blockquote with "> " prefix.
func (r *MarkdownRenderer) renderBlockquote(node *gast.Blockquote, source []byte, depth int) {
// Create a temporary buffer for the blockquote content
tempBuf := &bytes.Buffer{}
tempRenderer := &MarkdownRenderer{buf: tempBuf}
tempRenderer.renderChildren(node, source, depth)
// Add "> " prefix to each line
content := tempBuf.String()
lines := strings.Split(strings.TrimRight(content, "\n"), "\n")
for i, line := range lines {
r.buf.WriteString("> ")
r.buf.WriteString(line)
if i < len(lines)-1 {
r.buf.WriteByte('\n')
}
}
}
// renderListItem renders a list item with proper indentation and markers.
func (r *MarkdownRenderer) renderListItem(node *gast.ListItem, source []byte, depth int) {
parent := node.Parent()
list, ok := parent.(*gast.List)
if !ok {
r.renderChildren(node, source, depth)
return
}
// Add indentation only for nested lists
// Document=0, List=1, ListItem=2 (no indent), nested ListItem=3+ (indent)
if depth > 2 {
indent := strings.Repeat(" ", depth-2)
r.buf.WriteString(indent)
}
// Add list marker
if list.IsOrdered() {
r.buf.WriteString(fmt.Sprintf("%d. ", list.Start))
list.Start++ // Increment for next item
} else {
r.buf.WriteString("- ")
}
// Render content
r.renderChildren(node, source, depth)
// Add newline if there's a next sibling
if node.NextSibling() != nil {
r.buf.WriteByte('\n')
}
}
// renderTable renders a table in markdown format.
func (r *MarkdownRenderer) renderTable(table *east.Table, source []byte) {
// This is a simplified table renderer
// A full implementation would need to handle alignment, etc.
r.renderChildren(table, source, 0)
}
package renderer
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/usememos/memos/plugin/markdown/extensions"
)
func TestMarkdownRenderer(t *testing.T) {
// Create goldmark instance with all extensions
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
extensions.WikilinkExtension,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
),
)
tests := []struct {
name string
input string
expected string
}{
{
name: "simple text",
input: "Hello world",
expected: "Hello world",
},
{
name: "paragraph with newlines",
input: "First paragraph\n\nSecond paragraph",
expected: "First paragraph\n\nSecond paragraph",
},
{
name: "emphasis",
input: "This is *italic* and **bold** text",
expected: "This is *italic* and **bold** text",
},
{
name: "headings",
input: "# Heading 1\n\n## Heading 2\n\n### Heading 3",
expected: "# Heading 1\n\n## Heading 2\n\n### Heading 3",
},
{
name: "link",
input: "Check [this link](https://example.com)",
expected: "Check [this link](https://example.com)",
},
{
name: "image",
input: "![alt text](image.png)",
expected: "![alt text](image.png)",
},
{
name: "code inline",
input: "This is `inline code` here",
expected: "This is `inline code` here",
},
{
name: "code block fenced",
input: "```go\nfunc main() {\n}\n```",
expected: "```go\nfunc main() {\n}\n```",
},
{
name: "unordered list",
input: "- Item 1\n- Item 2\n- Item 3",
expected: "- Item 1\n- Item 2\n- Item 3",
},
{
name: "ordered list",
input: "1. First\n2. Second\n3. Third",
expected: "1. First\n2. Second\n3. Third",
},
{
name: "blockquote",
input: "> This is a quote\n> Second line",
expected: "> This is a quote\n> Second line",
},
{
name: "horizontal rule",
input: "Text before\n\n---\n\nText after",
expected: "Text before\n\n---\n\nText after",
},
{
name: "strikethrough",
input: "This is ~~deleted~~ text",
expected: "This is ~~deleted~~ text",
},
{
name: "task list",
input: "- [x] Completed task\n- [ ] Incomplete task",
expected: "- [x] Completed task\n- [ ] Incomplete task",
},
{
name: "tag",
input: "This has #tag in it",
expected: "This has #tag in it",
},
{
name: "multiple tags",
input: "#work #important meeting notes",
expected: "#work #important meeting notes",
},
{
name: "referenced content (wikilink)",
input: "Check [[memos/42]] for details",
expected: "Check [[memos/42]] for details",
},
{
name: "complex mixed content",
input: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\nSee [[memos/1]] for background.\n\n```python\nprint('hello')\n```",
expected: "# Meeting Notes\n\n**Date**: 2024-01-01\n\n## Attendees\n\n- Alice\n- Bob\n\n## Discussion\n\nWe discussed #project status.\n\nSee [[memos/1]] for background.\n\n```python\nprint('hello')\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Parse the input
source := []byte(tt.input)
reader := text.NewReader(source)
doc := md.Parser().Parse(reader)
require.NotNil(t, doc)
// Render back to markdown
renderer := NewMarkdownRenderer()
result := renderer.Render(doc, source)
// For debugging
if result != tt.expected {
t.Logf("Input: %q", tt.input)
t.Logf("Expected: %q", tt.expected)
t.Logf("Got: %q", result)
}
assert.Equal(t, tt.expected, result)
})
}
}
func TestMarkdownRendererPreservesStructure(t *testing.T) {
// Test that parsing and rendering preserves structure
md := goldmark.New(
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
extensions.WikilinkExtension,
),
)
inputs := []string{
"# Title\n\nParagraph",
"**Bold** and *italic*",
"- List\n- Items",
"#tag #another",
"[[wikilink]]",
"> Quote",
}
renderer := NewMarkdownRenderer()
for _, input := range inputs {
t.Run(input, func(t *testing.T) {
source := []byte(input)
reader := text.NewReader(source)
doc := md.Parser().Parse(reader)
result := renderer.Render(doc, source)
// The result should be structurally similar
// (may have minor formatting differences)
assert.NotEmpty(t, result)
})
}
}
syntax = "proto3";
package memos.api.v1;
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
option go_package = "gen/api/v1";
service MarkdownService {
// ParseMarkdown parses the given markdown content and returns a list of nodes.
// This is a utility method that transforms markdown text into structured nodes.
rpc ParseMarkdown(ParseMarkdownRequest) returns (ParseMarkdownResponse) {
option (google.api.http) = {
post: "/api/v1/markdown:parse"
body: "*"
};
}
// RestoreMarkdownNodes restores the given nodes to markdown content.
// This is the inverse operation of ParseMarkdown.
rpc RestoreMarkdownNodes(RestoreMarkdownNodesRequest) returns (RestoreMarkdownNodesResponse) {
option (google.api.http) = {
post: "/api/v1/markdown:restore"
body: "*"
};
}
// StringifyMarkdownNodes stringify the given nodes to plain text content.
// This removes all markdown formatting and returns plain text.
rpc StringifyMarkdownNodes(StringifyMarkdownNodesRequest) returns (StringifyMarkdownNodesResponse) {
option (google.api.http) = {
post: "/api/v1/markdown:stringify"
body: "*"
};
}
// GetLinkMetadata returns metadata for a given link.
// This is useful for generating link previews.
rpc GetLinkMetadata(GetLinkMetadataRequest) returns (LinkMetadata) {
option (google.api.http) = {get: "/api/v1/markdown/links:getMetadata"};
}
}
message ParseMarkdownRequest {
// The markdown content to parse.
string markdown = 1 [(google.api.field_behavior) = REQUIRED];
}
message ParseMarkdownResponse {
// The parsed markdown nodes.
repeated Node nodes = 1;
}
message RestoreMarkdownNodesRequest {
// The nodes to restore to markdown content.
repeated Node nodes = 1 [(google.api.field_behavior) = REQUIRED];
}
message RestoreMarkdownNodesResponse {
// The restored markdown content.
string markdown = 1;
}
message StringifyMarkdownNodesRequest {
// The nodes to stringify to plain text.
repeated Node nodes = 1 [(google.api.field_behavior) = REQUIRED];
}
message StringifyMarkdownNodesResponse {
// The plain text content.
string plain_text = 1;
}
message GetLinkMetadataRequest {
// The link URL to get metadata for.
string link = 1 [(google.api.field_behavior) = REQUIRED];
}
message LinkMetadata {
// The title of the linked page.
string title = 1;
// The description of the linked page.
string description = 2;
// The URL of the preview image for the linked page.
string image = 3;
}
enum NodeType {
NODE_UNSPECIFIED = 0;
// Block nodes.
LINE_BREAK = 1;
PARAGRAPH = 2;
CODE_BLOCK = 3;
HEADING = 4;
HORIZONTAL_RULE = 5;
BLOCKQUOTE = 6;
LIST = 7;
ORDERED_LIST_ITEM = 8;
UNORDERED_LIST_ITEM = 9;
TASK_LIST_ITEM = 10;
MATH_BLOCK = 11;
TABLE = 12;
EMBEDDED_CONTENT = 13;
// Inline nodes.
TEXT = 51;
BOLD = 52;
ITALIC = 53;
BOLD_ITALIC = 54;
CODE = 55;
IMAGE = 56;
LINK = 57;
AUTO_LINK = 58;
TAG = 59;
STRIKETHROUGH = 60;
ESCAPING_CHARACTER = 61;
MATH = 62;
HIGHLIGHT = 63;
SUBSCRIPT = 64;
SUPERSCRIPT = 65;
REFERENCED_CONTENT = 66;
SPOILER = 67;
HTML_ELEMENT = 68;
}
message Node {
NodeType type = 1;
oneof node {
// Block nodes.
LineBreakNode line_break_node = 11;
ParagraphNode paragraph_node = 12;
CodeBlockNode code_block_node = 13;
HeadingNode heading_node = 14;
HorizontalRuleNode horizontal_rule_node = 15;
BlockquoteNode blockquote_node = 16;
ListNode list_node = 17;
OrderedListItemNode ordered_list_item_node = 18;
UnorderedListItemNode unordered_list_item_node = 19;
TaskListItemNode task_list_item_node = 20;
MathBlockNode math_block_node = 21;
TableNode table_node = 22;
EmbeddedContentNode embedded_content_node = 23;
// Inline nodes.
TextNode text_node = 51;
BoldNode bold_node = 52;
ItalicNode italic_node = 53;
BoldItalicNode bold_italic_node = 54;
CodeNode code_node = 55;
ImageNode image_node = 56;
LinkNode link_node = 57;
AutoLinkNode auto_link_node = 58;
TagNode tag_node = 59;
StrikethroughNode strikethrough_node = 60;
EscapingCharacterNode escaping_character_node = 61;
MathNode math_node = 62;
HighlightNode highlight_node = 63;
SubscriptNode subscript_node = 64;
SuperscriptNode superscript_node = 65;
ReferencedContentNode referenced_content_node = 66;
SpoilerNode spoiler_node = 67;
HTMLElementNode html_element_node = 68;
}
}
message LineBreakNode {}
message ParagraphNode {
repeated Node children = 1;
}
message CodeBlockNode {
string language = 1;
string content = 2;
}
message HeadingNode {
int32 level = 1;
repeated Node children = 2;
}
message HorizontalRuleNode {
string symbol = 1;
}
message BlockquoteNode {
repeated Node children = 1;
}
message ListNode {
enum Kind {
KIND_UNSPECIFIED = 0;
ORDERED = 1;
UNORDERED = 2;
DESCRIPTION = 3;
}
Kind kind = 1;
int32 indent = 2;
repeated Node children = 3;
}
message OrderedListItemNode {
string number = 1;
int32 indent = 2;
repeated Node children = 3;
}
message UnorderedListItemNode {
string symbol = 1;
int32 indent = 2;
repeated Node children = 3;
}
message TaskListItemNode {
string symbol = 1;
int32 indent = 2;
bool complete = 3;
repeated Node children = 4;
}
message MathBlockNode {
string content = 1;
}
message TableNode {
repeated Node header = 1;
repeated string delimiter = 2;
message Row {
repeated Node cells = 1;
}
repeated Row rows = 3;
}
message EmbeddedContentNode {
// The resource name of the embedded content.
string resource_name = 1;
// Additional parameters for the embedded content.
string params = 2;
}
message TextNode {
string content = 1;
}
message BoldNode {
string symbol = 1;
repeated Node children = 2;
}
message ItalicNode {
string symbol = 1;
repeated Node children = 2;
}
message BoldItalicNode {
string symbol = 1;
string content = 2;
}
message CodeNode {
string content = 1;
}
message ImageNode {
string alt_text = 1;
string url = 2;
}
message LinkNode {
repeated Node content = 1;
string url = 2;
}
message AutoLinkNode {
string url = 1;
bool is_raw_text = 2;
}
message TagNode {
string content = 1;
}
message StrikethroughNode {
string content = 1;
}
message EscapingCharacterNode {
string symbol = 1;
}
message MathNode {
string content = 1;
}
message HighlightNode {
string content = 1;
}
message SubscriptNode {
string content = 1;
}
message SuperscriptNode {
string content = 1;
}
message ReferencedContentNode {
// The resource name of the referenced content.
string resource_name = 1;
// Additional parameters for the referenced content.
string params = 2;
}
message SpoilerNode {
string content = 1;
}
message HTMLElementNode {
string tag_name = 1;
map<string, string> attributes = 2;
repeated Node children = 3;
bool is_self_closing = 4;
}
...@@ -4,7 +4,6 @@ package memos.api.v1; ...@@ -4,7 +4,6 @@ package memos.api.v1;
import "api/v1/attachment_service.proto"; import "api/v1/attachment_service.proto";
import "api/v1/common.proto"; import "api/v1/common.proto";
import "api/v1/markdown_service.proto";
import "google/api/annotations.proto"; import "google/api/annotations.proto";
import "google/api/client.proto"; import "google/api/client.proto";
import "google/api/field_behavior.proto"; import "google/api/field_behavior.proto";
...@@ -202,9 +201,6 @@ message Memo { ...@@ -202,9 +201,6 @@ message Memo {
// Required. The content of the memo in Markdown format. // Required. The content of the memo in Markdown format.
string content = 7 [(google.api.field_behavior) = REQUIRED]; string content = 7 [(google.api.field_behavior) = REQUIRED];
// Output only. The parsed nodes from the content.
repeated Node nodes = 8 [(google.api.field_behavior) = OUTPUT_ONLY];
// The visibility of the memo. // The visibility of the memo.
Visibility visibility = 9 [(google.api.field_behavior) = REQUIRED]; Visibility visibility = 9 [(google.api.field_behavior) = REQUIRED];
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -14,7 +14,6 @@ var authenticationAllowlistMethods = map[string]bool{ ...@@ -14,7 +14,6 @@ var authenticationAllowlistMethods = map[string]bool{
"/memos.api.v1.UserService/SearchUsers": true, "/memos.api.v1.UserService/SearchUsers": true,
"/memos.api.v1.MemoService/GetMemo": true, "/memos.api.v1.MemoService/GetMemo": true,
"/memos.api.v1.MemoService/ListMemos": true, "/memos.api.v1.MemoService/ListMemos": true,
"/memos.api.v1.MarkdownService/GetLinkMetadata": true,
"/memos.api.v1.AttachmentService/GetAttachmentBinary": true, "/memos.api.v1.AttachmentService/GetAttachmentBinary": true,
} }
......
This diff is collapsed.
...@@ -122,7 +122,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel ...@@ -122,7 +122,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel
if err != nil { if err != nil {
return nil, err return nil, err
} }
memoSnippet, err := getMemoContentSnippet(memo.Content) memoSnippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet") return nil, errors.Wrap(err, "failed to get memo content snippet")
} }
...@@ -130,7 +130,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel ...@@ -130,7 +130,7 @@ func (s *APIV1Service) convertMemoRelationFromStore(ctx context.Context, memoRel
if err != nil { if err != nil {
return nil, err return nil, err
} }
relatedMemoSnippet, err := getMemoContentSnippet(relatedMemo.Content) relatedMemoSnippet, err := s.getMemoContentSnippet(relatedMemo.Content)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get related memo content snippet") return nil, errors.Wrap(err, "failed to get related memo content snippet")
} }
......
...@@ -10,9 +10,6 @@ import ( ...@@ -10,9 +10,6 @@ import (
"github.com/lithammer/shortuuid/v4" "github.com/lithammer/shortuuid/v4"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/usememos/gomark"
"github.com/usememos/gomark/ast"
"github.com/usememos/gomark/renderer"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/emptypb" "google.golang.org/protobuf/types/known/emptypb"
...@@ -53,7 +50,7 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR ...@@ -53,7 +50,7 @@ func (s *APIV1Service) CreateMemo(ctx context.Context, request *v1pb.CreateMemoR
if len(create.Content) > contentLengthLimit { if len(create.Content) > contentLengthLimit {
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit) return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
} }
if err := memopayload.RebuildMemoPayload(create); err != nil { if err := memopayload.RebuildMemoPayload(create, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
} }
if request.Memo.Location != nil { if request.Memo.Location != nil {
...@@ -338,7 +335,7 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR ...@@ -338,7 +335,7 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR
return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit) return nil, status.Errorf(codes.InvalidArgument, "content too long (max %d characters)", contentLengthLimit)
} }
memo.Content = request.Memo.Content memo.Content = request.Memo.Content
if err := memopayload.RebuildMemoPayload(memo); err != nil { if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
} }
update.Content = &memo.Content update.Content = &memo.Content
...@@ -711,17 +708,14 @@ func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMe ...@@ -711,17 +708,14 @@ func (s *APIV1Service) RenameMemoTag(ctx context.Context, request *v1pb.RenameMe
} }
for _, memo := range memos { for _, memo := range memos {
doc, err := gomark.Parse(memo.Content) // Rename tag using goldmark
newContent, err := s.MarkdownService.RenameTag([]byte(memo.Content), request.OldTag, request.NewTag)
if err != nil { if err != nil {
return nil, status.Errorf(codes.Internal, "failed to parse memo: %v", err) return nil, status.Errorf(codes.Internal, "failed to rename tag: %v", err)
} }
memopayload.TraverseASTDocument(doc, func(node ast.Node) { memo.Content = newContent
if tag, ok := node.(*ast.Tag); ok && tag.Content == request.OldTag {
tag.Content = request.NewTag if err := memopayload.RebuildMemoPayload(memo, s.MarkdownService); err != nil {
}
})
memo.Content = gomark.Restore(doc)
if err := memopayload.RebuildMemoPayload(memo); err != nil {
return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err) return nil, status.Errorf(codes.Internal, "failed to rebuild memo payload: %v", err)
} }
if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{ if err := s.Store.UpdateMemo(ctx, &store.UpdateMemo{
...@@ -842,17 +836,13 @@ func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayloa ...@@ -842,17 +836,13 @@ func convertMemoToWebhookPayload(memo *v1pb.Memo) (*webhook.WebhookRequestPayloa
}, nil }, nil
} }
func getMemoContentSnippet(content string) (string, error) { func (s *APIV1Service) getMemoContentSnippet(content string) (string, error) {
doc, err := gomark.Parse(content) // Use goldmark service for snippet generation
snippet, err := s.MarkdownService.GenerateSnippet([]byte(content), 64)
if err != nil { if err != nil {
return "", errors.Wrap(err, "failed to parse content") return "", errors.Wrap(err, "failed to generate snippet")
}
plainText := renderer.NewStringRenderer().RenderDocument(doc)
if len(plainText) > 64 {
return substring(plainText, 64) + "...", nil
} }
return plainText, nil return snippet, nil
} }
func substring(s string, length int) string { func substring(s string, length int) string {
......
...@@ -8,8 +8,6 @@ import ( ...@@ -8,8 +8,6 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/usememos/gomark"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
...@@ -68,15 +66,7 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem ...@@ -68,15 +66,7 @@ func (s *APIV1Service) convertMemoFromStore(ctx context.Context, memo *store.Mem
memoMessage.Attachments = append(memoMessage.Attachments, attachmentResponse) memoMessage.Attachments = append(memoMessage.Attachments, attachmentResponse)
} }
doc, err := gomark.Parse(memo.Content) snippet, err := s.getMemoContentSnippet(memo.Content)
if err != nil {
return nil, errors.Wrap(err, "failed to parse content")
}
if doc != nil {
memoMessage.Nodes = convertFromASTNodes(doc.Children)
}
snippet, err := getMemoContentSnippet(memo.Content)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "failed to get memo content snippet") return nil, errors.Wrap(err, "failed to get memo content snippet")
} }
......
...@@ -5,6 +5,7 @@ import ( ...@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/markdown"
apiv1 "github.com/usememos/memos/server/router/api/v1" apiv1 "github.com/usememos/memos/server/router/api/v1"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
teststore "github.com/usememos/memos/store/test" teststore "github.com/usememos/memos/store/test"
...@@ -36,10 +37,15 @@ func NewTestService(t *testing.T) *TestService { ...@@ -36,10 +37,15 @@ func NewTestService(t *testing.T) *TestService {
// Create APIV1Service with nil grpcServer since we're testing direct calls // Create APIV1Service with nil grpcServer since we're testing direct calls
secret := "test-secret" secret := "test-secret"
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithWikilinkExtension(),
)
service := &apiv1.APIV1Service{ service := &apiv1.APIV1Service{
Secret: secret, Secret: secret,
Profile: testProfile, Profile: testProfile,
Store: testStore, Store: testStore,
MarkdownService: markdownService,
} }
return &TestService{ return &TestService{
......
...@@ -15,6 +15,7 @@ import ( ...@@ -15,6 +15,7 @@ import (
"google.golang.org/grpc/reflection" "google.golang.org/grpc/reflection"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/markdown"
v1pb "github.com/usememos/memos/proto/gen/api/v1" v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
...@@ -30,22 +31,27 @@ type APIV1Service struct { ...@@ -30,22 +31,27 @@ type APIV1Service struct {
v1pb.UnimplementedShortcutServiceServer v1pb.UnimplementedShortcutServiceServer
v1pb.UnimplementedInboxServiceServer v1pb.UnimplementedInboxServiceServer
v1pb.UnimplementedActivityServiceServer v1pb.UnimplementedActivityServiceServer
v1pb.UnimplementedMarkdownServiceServer
v1pb.UnimplementedIdentityProviderServiceServer v1pb.UnimplementedIdentityProviderServiceServer
Secret string Secret string
Profile *profile.Profile Profile *profile.Profile
Store *store.Store Store *store.Store
MarkdownService markdown.Service
grpcServer *grpc.Server grpcServer *grpc.Server
} }
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service { func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service {
grpc.EnableTracing = true grpc.EnableTracing = true
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithWikilinkExtension(),
)
apiv1Service := &APIV1Service{ apiv1Service := &APIV1Service{
Secret: secret, Secret: secret,
Profile: profile, Profile: profile,
Store: store, Store: store,
MarkdownService: markdownService,
grpcServer: grpcServer, grpcServer: grpcServer,
} }
grpc_health_v1.RegisterHealthServer(grpcServer, apiv1Service) grpc_health_v1.RegisterHealthServer(grpcServer, apiv1Service)
...@@ -57,7 +63,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store ...@@ -57,7 +63,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
v1pb.RegisterShortcutServiceServer(grpcServer, apiv1Service) v1pb.RegisterShortcutServiceServer(grpcServer, apiv1Service)
v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service) v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service)
v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service) v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service)
v1pb.RegisterMarkdownServiceServer(grpcServer, apiv1Service)
v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service) v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service)
reflection.Register(grpcServer) reflection.Register(grpcServer)
return apiv1Service return apiv1Service
...@@ -109,9 +114,6 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech ...@@ -109,9 +114,6 @@ func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Ech
if err := v1pb.RegisterActivityServiceHandler(ctx, gwMux, conn); err != nil { if err := v1pb.RegisterActivityServiceHandler(ctx, gwMux, conn); err != nil {
return err return err
} }
if err := v1pb.RegisterMarkdownServiceHandler(ctx, gwMux, conn); err != nil {
return err
}
if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil { if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil {
return err return err
} }
......
...@@ -9,10 +9,9 @@ import ( ...@@ -9,10 +9,9 @@ import (
"github.com/gorilla/feeds" "github.com/gorilla/feeds"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/usememos/gomark"
"github.com/usememos/gomark/renderer"
"github.com/usememos/memos/internal/profile" "github.com/usememos/memos/internal/profile"
"github.com/usememos/memos/plugin/markdown"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
...@@ -24,6 +23,7 @@ const ( ...@@ -24,6 +23,7 @@ const (
type RSSService struct { type RSSService struct {
Profile *profile.Profile Profile *profile.Profile
Store *store.Store Store *store.Store
MarkdownService markdown.Service
} }
type RSSHeading struct { type RSSHeading struct {
...@@ -31,10 +31,11 @@ type RSSHeading struct { ...@@ -31,10 +31,11 @@ type RSSHeading struct {
Description string Description string
} }
func NewRSSService(profile *profile.Profile, store *store.Store) *RSSService { func NewRSSService(profile *profile.Profile, store *store.Store, markdownService markdown.Service) *RSSService {
return &RSSService{ return &RSSService{
Profile: profile, Profile: profile,
Store: store, Store: store,
MarkdownService: markdownService,
} }
} }
...@@ -113,7 +114,7 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st ...@@ -113,7 +114,7 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st
feed.Items = make([]*feeds.Item, itemCountLimit) feed.Items = make([]*feeds.Item, itemCountLimit)
for i := 0; i < itemCountLimit; i++ { for i := 0; i < itemCountLimit; i++ {
memo := memoList[i] memo := memoList[i]
description, err := getRSSItemDescription(memo.Content) description, err := s.getRSSItemDescription(memo.Content)
if err != nil { if err != nil {
return "", err return "", err
} }
...@@ -151,13 +152,12 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st ...@@ -151,13 +152,12 @@ func (s *RSSService) generateRSSFromMemoList(ctx context.Context, memoList []*st
return rss, nil return rss, nil
} }
func getRSSItemDescription(content string) (string, error) { func (s *RSSService) getRSSItemDescription(content string) (string, error) {
doc, err := gomark.Parse(content) html, err := s.MarkdownService.RenderHTML([]byte(content))
if err != nil { if err != nil {
return "", err return "", err
} }
result := renderer.NewHTMLRenderer().RenderDocument(doc) return html, nil
return result, nil
} }
func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error) { func getRSSHeading(ctx context.Context, stores *store.Store) (RSSHeading, error) {
......
...@@ -3,23 +3,23 @@ package memopayload ...@@ -3,23 +3,23 @@ package memopayload
import ( import (
"context" "context"
"log/slog" "log/slog"
"slices"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/usememos/gomark"
"github.com/usememos/gomark/ast"
"github.com/usememos/memos/plugin/markdown"
storepb "github.com/usememos/memos/proto/gen/store" storepb "github.com/usememos/memos/proto/gen/store"
"github.com/usememos/memos/store" "github.com/usememos/memos/store"
) )
type Runner struct { type Runner struct {
Store *store.Store Store *store.Store
MarkdownService markdown.Service
} }
func NewRunner(store *store.Store) *Runner { func NewRunner(store *store.Store, markdownService markdown.Service) *Runner {
return &Runner{ return &Runner{
Store: store, Store: store,
MarkdownService: markdownService,
} }
} }
...@@ -49,7 +49,7 @@ func (r *Runner) RunOnce(ctx context.Context) { ...@@ -49,7 +49,7 @@ func (r *Runner) RunOnce(ctx context.Context) {
// Process batch // Process batch
batchSuccessCount := 0 batchSuccessCount := 0
for _, memo := range memos { for _, memo := range memos {
if err := RebuildMemoPayload(memo); err != nil { if err := RebuildMemoPayload(memo, r.MarkdownService); err != nil {
slog.Error("failed to rebuild memo payload", "err", err, "memoID", memo.ID) slog.Error("failed to rebuild memo payload", "err", err, "memoID", memo.ID)
continue continue
} }
...@@ -71,70 +71,21 @@ func (r *Runner) RunOnce(ctx context.Context) { ...@@ -71,70 +71,21 @@ func (r *Runner) RunOnce(ctx context.Context) {
} }
} }
func RebuildMemoPayload(memo *store.Memo) error { func RebuildMemoPayload(memo *store.Memo, markdownService markdown.Service) error {
doc, err := gomark.Parse(memo.Content)
if err != nil {
return errors.Wrap(err, "failed to parse content")
}
if memo.Payload == nil { if memo.Payload == nil {
memo.Payload = &storepb.MemoPayload{} memo.Payload = &storepb.MemoPayload{}
} }
tags := []string{}
property := &storepb.MemoPayload_Property{}
TraverseASTDocument(doc, func(node ast.Node) {
switch n := node.(type) {
case *ast.Tag:
tag := n.Content
if !slices.Contains(tags, tag) {
tags = append(tags, tag)
}
case *ast.Link, *ast.AutoLink:
property.HasLink = true
case *ast.TaskListItem:
property.HasTaskList = true
if !n.Complete {
property.HasIncompleteTasks = true
}
case *ast.CodeBlock:
property.HasCode = true
case *ast.EmbeddedContent:
// TODO: validate references.
property.References = append(property.References, n.ResourceName)
}
})
memo.Payload.Tags = tags
memo.Payload.Property = property
return nil
}
func TraverseASTDocument(doc *ast.Document, fn func(ast.Node)) { // Use goldmark service to extract all metadata in a single pass (more efficient)
if doc == nil { data, err := markdownService.ExtractAll([]byte(memo.Content))
return if err != nil {
return errors.Wrap(err, "failed to extract markdown metadata")
} }
traverseASTNodes(doc.Children, fn)
}
func traverseASTNodes(nodes []ast.Node, fn func(ast.Node)) { // Set references in property
for _, node := range nodes { data.Property.References = data.References
fn(node)
switch n := node.(type) { memo.Payload.Tags = data.Tags
case *ast.Paragraph: memo.Payload.Property = data.Property
traverseASTNodes(n.Children, fn) return nil
case *ast.Heading:
traverseASTNodes(n.Children, fn)
case *ast.Blockquote:
traverseASTNodes(n.Children, fn)
case *ast.List:
traverseASTNodes(n.Children, fn)
case *ast.OrderedListItem:
traverseASTNodes(n.Children, fn)
case *ast.UnorderedListItem:
traverseASTNodes(n.Children, fn)
case *ast.TaskListItem:
traverseASTNodes(n.Children, fn)
case *ast.Bold:
traverseASTNodes(n.Children, fn)
}
}
} }
...@@ -81,9 +81,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store ...@@ -81,9 +81,6 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
rootGroup := echoServer.Group("") rootGroup := echoServer.Group("")
// Create and register RSS routes.
rss.NewRSSService(s.Profile, s.Store).RegisterRoutes(rootGroup)
// Log full stacktraces if we're in dev // Log full stacktraces if we're in dev
logStacktraces := profile.IsDev() logStacktraces := profile.IsDev()
...@@ -98,6 +95,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store ...@@ -98,6 +95,9 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store
s.grpcServer = grpcServer s.grpcServer = grpcServer
apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer) apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer)
// Create and register RSS routes (needs markdown service from apiV1Service).
rss.NewRSSService(s.Profile, s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup)
// Register gRPC gateway as api v1. // Register gRPC gateway as api v1.
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
return nil, errors.Wrap(err, "failed to register gRPC gateway") return nil, errors.Wrap(err, "failed to register gRPC gateway")
......
...@@ -50,12 +50,17 @@ ...@@ -50,12 +50,17 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.3", "react-i18next": "^15.7.3",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.9.1", "react-router-dom": "^7.9.1",
"react-simple-pull-to-refresh": "^1.3.3", "react-simple-pull-to-refresh": "^1.3.3",
"react-use": "^17.6.0", "react-use": "^17.6.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-wiki-link": "^2.0.1",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"textarea-caret": "^3.1.0", "textarea-caret": "^3.1.0",
"unist-util-visit": "^5.0.0",
"uuid": "^11.1.0" "uuid": "^11.1.0"
}, },
"devDependencies": { "devDependencies": {
...@@ -66,14 +71,15 @@ ...@@ -66,14 +71,15 @@
"@types/katex": "^0.16.7", "@types/katex": "^0.16.7",
"@types/leaflet": "^1.9.20", "@types/leaflet": "^1.9.20",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mdast": "^4.0.4",
"@types/node": "^24.5.1", "@types/node": "^24.5.1",
"@types/qs": "^6.14.0", "@types/qs": "^6.14.0",
"@types/react": "^18.3.24", "@types/react": "^18.3.24",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@types/textarea-caret": "^3.0.4", "@types/textarea-caret": "^3.0.4",
"@types/unist": "^3.0.3",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
"code-inspector-plugin": "^1.2.10",
"eslint": "^9.35.0", "eslint": "^9.35.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
......
This diff is collapsed.
...@@ -17,14 +17,13 @@ import { useState } from "react"; ...@@ -17,14 +17,13 @@ import { useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import ConfirmDialog from "@/components/ConfirmDialog"; import ConfirmDialog from "@/components/ConfirmDialog";
import { markdownServiceClient } from "@/grpcweb";
import useNavigateTo from "@/hooks/useNavigateTo"; import useNavigateTo from "@/hooks/useNavigateTo";
import { memoStore, userStore } from "@/store"; import { memoStore, userStore } from "@/store";
import { workspaceStore } from "@/store"; import { workspaceStore } from "@/store";
import { State } from "@/types/proto/api/v1/common"; import { State } from "@/types/proto/api/v1/common";
import { NodeType } from "@/types/proto/api/v1/markdown_service";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
import { useTranslate } from "@/utils/i18n"; import { useTranslate } from "@/utils/i18n";
import { hasCompletedTasks, removeCompletedTasks } from "@/utils/markdown-manipulation";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
DropdownMenu, DropdownMenu,
...@@ -44,16 +43,7 @@ interface Props { ...@@ -44,16 +43,7 @@ interface Props {
} }
const checkHasCompletedTaskList = (memo: Memo) => { const checkHasCompletedTaskList = (memo: Memo) => {
for (const node of memo.nodes) { return hasCompletedTasks(memo.content);
if (node.type === NodeType.LIST && node.listNode?.children && node.listNode?.children?.length > 0) {
for (let j = 0; j < node.listNode.children.length; j++) {
if (node.listNode.children[j].type === NodeType.TASK_LIST_ITEM && node.listNode.children[j].taskListItemNode?.complete) {
return true;
}
}
}
}
return false;
}; };
const MemoActionMenu = observer((props: Props) => { const MemoActionMenu = observer((props: Props) => {
...@@ -160,27 +150,11 @@ const MemoActionMenu = observer((props: Props) => { ...@@ -160,27 +150,11 @@ const MemoActionMenu = observer((props: Props) => {
}; };
const confirmRemoveCompletedTaskListItems = async () => { const confirmRemoveCompletedTaskListItems = async () => {
const newNodes = JSON.parse(JSON.stringify(memo.nodes)); const newContent = removeCompletedTasks(memo.content);
for (const node of newNodes) {
if (node.type === NodeType.LIST && node.listNode?.children?.length > 0) {
const children = node.listNode.children;
for (let i = 0; i < children.length; i++) {
if (children[i].type === NodeType.TASK_LIST_ITEM && children[i].taskListItemNode?.complete) {
// Remove completed taskList item and next line breaks
children.splice(i, 1);
if (children[i]?.type === NodeType.LINE_BREAK) {
children.splice(i, 1);
}
i--;
}
}
}
}
const { markdown } = await markdownServiceClient.restoreMarkdownNodes({ nodes: newNodes });
await memoStore.updateMemo( await memoStore.updateMemo(
{ {
name: memo.name, name: memo.name,
content: markdown, content: newContent,
}, },
["content"], ["content"],
); );
......
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
import { BaseProps } from "./types";
interface Props extends BaseProps {
children: Node[];
}
const Blockquote: React.FC<Props> = ({ children }: Props) => {
return (
<blockquote className="p-2 border-l-4 rounded border-border bg-muted/50 text-muted-foreground">
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</blockquote>
);
};
export default Blockquote;
import { Node } from "@/types/proto/api/v1/markdown_service";
import Renderer from "./Renderer";
interface Props {
symbol: string;
children: Node[];
}
const Bold: React.FC<Props> = ({ children }: Props) => {
return (
<strong>
{children.map((child, index) => (
<Renderer key={`${child.type}-${index}`} index={String(index)} node={child} />
))}
</strong>
);
};
export default Bold;
interface Props {
symbol: string;
content: string;
}
const BoldItalic: React.FC<Props> = ({ content }: Props) => {
return (
<strong>
<em>{content}</em>
</strong>
);
};
export default BoldItalic;
interface Props {
content: string;
}
const Code: React.FC<Props> = ({ content }: Props) => {
return <code className="inline break-all px-1 font-mono text-sm rounded bg-muted text-muted-foreground">{content}</code>;
};
export default Code;
import copy from "copy-to-clipboard";
import DOMPurify from "dompurify";
import hljs from "highlight.js";
import { CopyIcon } from "lucide-react";
import { observer } from "mobx-react-lite";
import { useEffect, useMemo } from "react";
import toast from "react-hot-toast";
import { cn } from "@/lib/utils";
import { workspaceStore } from "@/store";
import MermaidBlock from "./MermaidBlock";
import { BaseProps } from "./types";
// Special languages that are rendered differently.
enum SpecialLanguage {
HTML = "__html",
MERMAID = "mermaid",
}
interface Props extends BaseProps {
language: string;
content: string;
}
const CodeBlock: React.FC<Props> = ({ language, content }: Props) => {
const formatedLanguage = useMemo(() => (language || "").toLowerCase() || "text", [language]);
// Users can set Markdown code blocks as `__html` to render HTML directly.
// Content is sanitized to prevent XSS attacks while preserving safe HTML.
if (formatedLanguage === SpecialLanguage.HTML) {
const sanitizedHTML = DOMPurify.sanitize(content, {
// Allow common safe HTML tags and attributes
ALLOWED_TAGS: [
"div",
"span",
"p",
"br",
"strong",
"b",
"em",
"i",
"u",
"s",
"strike",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"blockquote",
"code",
"pre",
"ul",
"ol",
"li",
"dl",
"dt",
"dd",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"a",
"img",
"figure",
"figcaption",
"hr",
"small",
"sup",
"sub",
],
ALLOWED_ATTR: "href title alt src width height class id style target rel colspan rowspan".split(" "),
// Forbid dangerous attributes and tags
FORBID_ATTR: "onerror onload onclick onmouseover onfocus onblur onchange".split(" "),
FORBID_TAGS: "script iframe object embed form input button".split(" "),
});
return (
<div
className="w-full overflow-auto my-2!"
dangerouslySetInnerHTML={{
__html: sanitizedHTML,
}}
/>
);
} else if (formatedLanguage === SpecialLanguage.MERMAID) {
return <MermaidBlock content={content} />;
}
const appTheme = workspaceStore.state.theme;
const isDarkTheme = appTheme.includes("dark");
useEffect(() => {
const dynamicImportStyle = async () => {
// Remove any existing highlight.js style
const existingStyle = document.querySelector("style[data-hljs-theme]");
if (existingStyle) {
existingStyle.remove();
}
try {
const cssModule = isDarkTheme
? await import("highlight.js/styles/github-dark-dimmed.css?inline")
: await import("highlight.js/styles/github.css?inline");
// Create and inject the style
const style = document.createElement("style");
style.textContent = cssModule.default;
style.setAttribute("data-hljs-theme", isDarkTheme ? "dark" : "light");
document.head.appendChild(style);
} catch (error) {
console.warn("Failed to load highlight.js theme:", error);
}
};
dynamicImportStyle();
}, [appTheme, isDarkTheme]);
const highlightedCode = useMemo(() => {
try {
const lang = hljs.getLanguage(formatedLanguage);
if (lang) {
return hljs.highlight(content, {
language: formatedLanguage,
}).value;
}
} catch {
// Skip error and use default highlighted code.
}
// Escape any HTML entities when rendering original content.
return Object.assign(document.createElement("span"), {
textContent: content,
}).innerHTML;
}, [formatedLanguage, content]);
const copyContent = () => {
copy(content);
toast.success("Copied to clipboard!");
};
return (
<div className="w-full my-1 bg-card border border-border rounded-md relative">
<div className="w-full px-2 py-0.5 flex flex-row justify-between items-center text-muted-foreground">
<span className="text-xs font-mono">{formatedLanguage}</span>
<CopyIcon className="w-3 h-auto cursor-pointer hover:text-foreground" onClick={copyContent} />
</div>
<div className="overflow-auto">
<pre className={cn("no-wrap overflow-auto", "w-full p-2 bg-muted/50 relative")}>
<code
className={cn(`language-${formatedLanguage}`, "block text-sm leading-5 text-foreground")}
dangerouslySetInnerHTML={{ __html: highlightedCode }}
></code>
</pre>
</div>
</div>
);
};
export default observer(CodeBlock);
import React from "react";
/**
* Creates a conditional component wrapper that checks AST node properties
* before deciding which component to render.
*
* This is more efficient than having every component check its own props,
* and allows us to use specific HTML element types as defaults.
*
* @param CustomComponent - Component to render when condition is met
* @param DefaultComponent - Component/element to render otherwise
* @param condition - Function to check if node matches custom component criteria
*/
export const createConditionalComponent = <P extends Record<string, any>>(
CustomComponent: React.ComponentType<P>,
DefaultComponent: React.ComponentType<P> | keyof JSX.IntrinsicElements,
condition: (node: any) => boolean,
) => {
return (props: P & { node?: any }) => {
const { node, ...restProps } = props;
// Check AST node to determine which component to use
if (node && condition(node)) {
return <CustomComponent {...(restProps as P)} node={node} />;
}
// Render default component/element
if (typeof DefaultComponent === "string") {
return React.createElement(DefaultComponent, restProps);
}
return <DefaultComponent {...(restProps as P)} />;
};
};
/**
* Condition checkers for AST node types
*
* These check the original MDAST node type preserved during transformation:
* - First checks node.data.mdastType (preserved by remarkPreserveType plugin)
* - Falls back to checking HAST properties/className for compatibility
*/
export const isWikiLinkNode = (node: any): boolean => {
// Check preserved mdast type first
if (node?.data?.mdastType === "wikiLink") {
return true;
}
// Fallback: check hast properties
return node?.properties?.className?.includes?.("wikilink") || false;
};
export const isTagNode = (node: any): boolean => {
// Check preserved mdast type first
if (node?.data?.mdastType === "tagNode") {
return true;
}
// Fallback: check hast properties
return node?.properties?.className?.includes?.("tag") || false;
};
export const isTaskListItemNode = (node: any): boolean => {
// Task list checkboxes are standard GFM - check element type
return node?.properties?.type === "checkbox" || false;
};
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment