feat: implement embedded memo renderer

pull/2799/head
Steven 1 year ago
parent 67f5ac3657
commit 8a34013558

@ -65,6 +65,8 @@ func convertFromASTNode(rawNode ast.Node) *apiv2pb.Node {
node.Node = &apiv2pb.Node_MathBlockNode{MathBlockNode: &apiv2pb.MathBlockNode{Content: n.Content}}
case *ast.Table:
node.Node = &apiv2pb.Node_TableNode{TableNode: convertTableFromASTNode(n)}
case *ast.EmbeddedContent:
node.Node = &apiv2pb.Node_EmbeddedContentNode{EmbeddedContentNode: &apiv2pb.EmbeddedContentNode{ResourceName: n.ResourceName}}
case *ast.Text:
node.Node = &apiv2pb.Node_TextNode{TextNode: &apiv2pb.TextNode{Content: n.Content}}
case *ast.Bold:
@ -142,6 +144,8 @@ func convertToASTNode(node *apiv2pb.Node) ast.Node {
return &ast.MathBlock{Content: n.MathBlockNode.Content}
case *apiv2pb.Node_TableNode:
return convertTableToASTNode(node)
case *apiv2pb.Node_EmbeddedContentNode:
return &ast.EmbeddedContent{ResourceName: n.EmbeddedContentNode.ResourceName}
case *apiv2pb.Node_TextNode:
return &ast.Text{Content: n.TextNode.Content}
case *apiv2pb.Node_BoldNode:

@ -16,6 +16,7 @@ const (
TaskListNode
MathBlockNode
TableNode
EmbeddedContentNode
// Inline nodes.
TextNode
BoldNode

@ -228,3 +228,17 @@ func (n *Table) Restore() string {
}
return result
}
type EmbeddedContent struct {
BaseBlock
ResourceName string
}
func (*EmbeddedContent) Type() NodeType {
return EmbeddedContentNode
}
func (n *EmbeddedContent) Restore() string {
return fmt.Sprintf("![[%s]]", n.ResourceName)
}

@ -0,0 +1,51 @@
package parser
import (
"errors"
"github.com/usememos/memos/plugin/gomark/ast"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
)
type EmbeddedContentParser struct{}
func NewEmbeddedContentParser() *EmbeddedContentParser {
return &EmbeddedContentParser{}
}
func (*EmbeddedContentParser) Match(tokens []*tokenizer.Token) (int, bool) {
lines := tokenizer.Split(tokens, tokenizer.Newline)
if len(lines) < 1 {
return 0, false
}
firstLine := lines[0]
if len(firstLine) < 5 {
return 0, false
}
if firstLine[0].Type != tokenizer.ExclamationMark || firstLine[1].Type != tokenizer.LeftSquareBracket || firstLine[2].Type != tokenizer.LeftSquareBracket {
return 0, false
}
matched := false
for index, token := range firstLine[:len(firstLine)-1] {
if token.Type == tokenizer.RightSquareBracket && firstLine[index+1].Type == tokenizer.RightSquareBracket && index+1 == len(firstLine)-1 {
matched = true
break
}
}
if !matched {
return 0, false
}
return len(firstLine), true
}
func (p *EmbeddedContentParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
size, ok := p.Match(tokens)
if size == 0 || !ok {
return nil, errors.New("not matched")
}
return &ast.EmbeddedContent{
ResourceName: tokenizer.Stringify(tokens[3 : size-2]),
}, nil
}

@ -0,0 +1,51 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/plugin/gomark/ast"
"github.com/usememos/memos/plugin/gomark/parser/tokenizer"
"github.com/usememos/memos/plugin/gomark/restore"
)
func TestEmbeddedContentParser(t *testing.T) {
tests := []struct {
text string
embeddedContent ast.Node
}{
{
text: "![[Hello world]",
embeddedContent: nil,
},
{
text: "![[Hello world]]",
embeddedContent: &ast.EmbeddedContent{
ResourceName: "Hello world",
},
},
{
text: "![[memos/1]]",
embeddedContent: &ast.EmbeddedContent{
ResourceName: "memos/1",
},
},
{
text: "![[resources/101]] \n123",
embeddedContent: nil,
},
{
text: "![[resources/101]]\n123",
embeddedContent: &ast.EmbeddedContent{
ResourceName: "resources/101",
},
},
}
for _, test := range tests {
tokens := tokenizer.Tokenize(test.text)
node, _ := NewEmbeddedContentParser().Parse(tokens)
require.Equal(t, restore.Restore([]ast.Node{test.embeddedContent}), restore.Restore([]ast.Node{node}))
}
}

@ -39,6 +39,7 @@ var defaultBlockParsers = []BlockParser{
NewUnorderedListParser(),
NewOrderedListParser(),
NewMathBlockParser(),
NewEmbeddedContentParser(),
NewParagraphParser(),
NewLineBreakParser(),
}

@ -218,6 +218,22 @@ func TestParser(t *testing.T) {
},
},
},
{
text: "Hello\n![[memos/101]]",
nodes: []ast.Node{
&ast.Paragraph{
Children: []ast.Node{
&ast.Text{
Content: "Hello",
},
},
},
&ast.LineBreak{},
&ast.EmbeddedContent{
ResourceName: "memos/101",
},
},
},
}
for _, test := range tests {

@ -39,9 +39,11 @@ func (*TableParser) Match(tokens []*tokenizer.Token) (int, bool) {
rowTokens := []*tokenizer.Token{}
for index, token := range tokens[len(headerTokens)+len(delimiterTokens)+2:] {
temp := len(headerTokens) + len(delimiterTokens) + 2 + index
if token.Type == tokenizer.Newline && temp != len(tokens)-1 && tokens[temp+1].Type != tokenizer.Pipe {
if token.Type == tokenizer.Newline {
if (temp == len(tokens)-1) || (temp+1 == len(tokens)-1 && tokens[temp+1].Type == tokenizer.Newline) {
break
}
}
rowTokens = append(rowTokens, token)
}
if len(rowTokens) < 5 {
@ -65,7 +67,18 @@ func (*TableParser) Match(tokens []*tokenizer.Token) (int, bool) {
if delimiterCells != headerCells || !ok {
return 0, false
}
for _, t := range tokenizer.Split(delimiterTokens, tokenizer.Pipe) {
for index, t := range tokenizer.Split(delimiterTokens, tokenizer.Pipe) {
if index == 0 || index == headerCells {
if len(t) != 0 {
return 0, false
}
continue
}
if len(t) < 5 {
return 0, false
}
delimiterTokens := t[1 : len(t)-1]
if len(delimiterTokens) < 3 {
return 0, false
@ -112,15 +125,16 @@ func (p *TableParser) Parse(tokens []*tokenizer.Token) (ast.Node, error) {
delimiter := make([]string, 0)
rows := make([][]string, 0)
for _, t := range tokenizer.Split(headerTokens, tokenizer.Pipe) {
cols := len(tokenizer.Split(headerTokens, tokenizer.Pipe)) - 2
for _, t := range tokenizer.Split(headerTokens, tokenizer.Pipe)[1 : cols+1] {
header = append(header, tokenizer.Stringify(t[1:len(t)-1]))
}
for _, t := range tokenizer.Split(dilimiterTokens, tokenizer.Pipe) {
for _, t := range tokenizer.Split(dilimiterTokens, tokenizer.Pipe)[1 : cols+1] {
delimiter = append(delimiter, tokenizer.Stringify(t[1:len(t)-1]))
}
for _, row := range rowTokens {
cells := make([]string, 0)
for _, t := range tokenizer.Split(row, tokenizer.Pipe) {
for _, t := range tokenizer.Split(row, tokenizer.Pipe)[1 : cols+1] {
cells = append(cells, tokenizer.Stringify(t[1:len(t)-1]))
}
rows = append(rows, cells)
@ -145,10 +159,13 @@ func matchTableCellTokens(tokens []*tokenizer.Token) (int, bool) {
}
}
cells := tokenizer.Split(tokens, tokenizer.Pipe)
if len(cells) != pipes-1 {
if len(cells) != pipes+1 {
return 0, false
}
if len(cells[0]) != 0 || len(cells[len(cells)-1]) != 0 {
return 0, false
}
for _, cellTokens := range cells {
for _, cellTokens := range cells[1 : len(cells)-1] {
if len(cellTokens) == 0 {
return 0, false
}
@ -160,5 +177,5 @@ func matchTableCellTokens(tokens []*tokenizer.Token) (int, bool) {
}
}
return len(cells), true
return len(cells) - 1, true
}

@ -132,20 +132,20 @@ func Stringify(tokens []*Token) string {
}
func Split(tokens []*Token, delimiter TokenType) [][]*Token {
if len(tokens) == 0 {
return [][]*Token{}
}
result := make([][]*Token, 0)
current := make([]*Token, 0)
for _, token := range tokens {
if token.Type == delimiter {
if len(current) > 0 {
result = append(result, current)
current = make([]*Token, 0)
}
} else {
current = append(current, token)
}
}
if len(current) > 0 {
result = append(result, current)
}
return result
}

@ -77,3 +77,144 @@ func TestTokenize(t *testing.T) {
require.Equal(t, test.tokens, result)
}
}
func TestSplit(t *testing.T) {
tests := []struct {
tokens []*Token
sep TokenType
result [][]*Token
}{
{
tokens: []*Token{
{
Type: Asterisk,
Value: "*",
},
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
sep: Asterisk,
result: [][]*Token{
{
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
},
},
{
tokens: []*Token{
{
Type: Asterisk,
Value: "*",
},
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: ExclamationMark,
Value: "!",
},
},
sep: Text,
result: [][]*Token{
{
{
Type: Asterisk,
Value: "*",
},
},
{
{
Type: Space,
Value: " ",
},
},
{
{
Type: ExclamationMark,
Value: "!",
},
},
},
},
{
tokens: []*Token{
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
{
Type: Newline,
Value: "\n",
},
},
sep: Newline,
result: [][]*Token{
{
{
Type: Text,
Value: "Hello",
},
{
Type: Space,
Value: " ",
},
{
Type: Text,
Value: "world",
},
},
},
},
}
for _, test := range tests {
result := Split(test.tokens, test.sep)
require.Equal(t, test.result, result)
}
}

@ -36,21 +36,22 @@ enum NodeType {
TASK_LIST = 9;
MATH_BLOCK = 10;
TABLE = 11;
TEXT = 12;
BOLD = 13;
ITALIC = 14;
BOLD_ITALIC = 15;
CODE = 16;
IMAGE = 17;
LINK = 18;
AUTO_LINK = 19;
TAG = 20;
STRIKETHROUGH = 21;
ESCAPING_CHARACTER = 22;
MATH = 23;
HIGHLIGHT = 24;
SUBSCRIPT = 25;
SUPERSCRIPT = 26;
EMBEDDED_CONTENT = 12;
TEXT = 13;
BOLD = 14;
ITALIC = 15;
BOLD_ITALIC = 16;
CODE = 17;
IMAGE = 18;
LINK = 19;
AUTO_LINK = 20;
TAG = 21;
STRIKETHROUGH = 22;
ESCAPING_CHARACTER = 23;
MATH = 24;
HIGHLIGHT = 25;
SUBSCRIPT = 26;
SUPERSCRIPT = 27;
}
message Node {
@ -67,21 +68,22 @@ message Node {
TaskListNode task_list_node = 10;
MathBlockNode math_block_node = 11;
TableNode table_node = 12;
TextNode text_node = 13;
BoldNode bold_node = 14;
ItalicNode italic_node = 15;
BoldItalicNode bold_italic_node = 16;
CodeNode code_node = 17;
ImageNode image_node = 18;
LinkNode link_node = 19;
AutoLinkNode auto_link_node = 20;
TagNode tag_node = 21;
StrikethroughNode strikethrough_node = 22;
EscapingCharacterNode escaping_character_node = 23;
MathNode math_node = 24;
HighlightNode highlight_node = 25;
SubscriptNode subscript_node = 26;
SuperscriptNode superscript_node = 27;
EmbeddedContentNode embedded_content_node = 13;
TextNode text_node = 14;
BoldNode bold_node = 15;
ItalicNode italic_node = 16;
BoldItalicNode bold_italic_node = 17;
CodeNode code_node = 18;
ImageNode image_node = 19;
LinkNode link_node = 20;
AutoLinkNode auto_link_node = 21;
TagNode tag_node = 22;
StrikethroughNode strikethrough_node = 23;
EscapingCharacterNode escaping_character_node = 24;
MathNode math_node = 25;
HighlightNode highlight_node = 26;
SubscriptNode subscript_node = 27;
SuperscriptNode superscript_node = 28;
}
}
@ -142,6 +144,10 @@ message TableNode {
repeated Row rows = 3;
}
message EmbeddedContentNode {
string resource_name = 1;
}
message TextNode {
string content = 1;
}

@ -72,6 +72,7 @@
- [BoldNode](#memos-api-v2-BoldNode)
- [CodeBlockNode](#memos-api-v2-CodeBlockNode)
- [CodeNode](#memos-api-v2-CodeNode)
- [EmbeddedContentNode](#memos-api-v2-EmbeddedContentNode)
- [EscapingCharacterNode](#memos-api-v2-EscapingCharacterNode)
- [HeadingNode](#memos-api-v2-HeadingNode)
- [HighlightNode](#memos-api-v2-HighlightNode)
@ -1058,6 +1059,21 @@
<a name="memos-api-v2-EmbeddedContentNode"></a>
### EmbeddedContentNode
| Field | Type | Label | Description |
| ----- | ---- | ----- | ----------- |
| resource_name | [string](#string) | | |
<a name="memos-api-v2-EscapingCharacterNode"></a>
### EscapingCharacterNode
@ -1227,6 +1243,7 @@
| task_list_node | [TaskListNode](#memos-api-v2-TaskListNode) | | |
| math_block_node | [MathBlockNode](#memos-api-v2-MathBlockNode) | | |
| table_node | [TableNode](#memos-api-v2-TableNode) | | |
| embedded_content_node | [EmbeddedContentNode](#memos-api-v2-EmbeddedContentNode) | | |
| text_node | [TextNode](#memos-api-v2-TextNode) | | |
| bold_node | [BoldNode](#memos-api-v2-BoldNode) | | |
| italic_node | [ItalicNode](#memos-api-v2-ItalicNode) | | |
@ -1473,21 +1490,22 @@
| TASK_LIST | 9 | |
| MATH_BLOCK | 10 | |
| TABLE | 11 | |
| TEXT | 12 | |
| BOLD | 13 | |
| ITALIC | 14 | |
| BOLD_ITALIC | 15 | |
| CODE | 16 | |
| IMAGE | 17 | |
| LINK | 18 | |
| AUTO_LINK | 19 | |
| TAG | 20 | |
| STRIKETHROUGH | 21 | |
| ESCAPING_CHARACTER | 22 | |
| MATH | 23 | |
| HIGHLIGHT | 24 | |
| SUBSCRIPT | 25 | |
| SUPERSCRIPT | 26 | |
| EMBEDDED_CONTENT | 12 | |
| TEXT | 13 | |
| BOLD | 14 | |
| ITALIC | 15 | |
| BOLD_ITALIC | 16 | |
| CODE | 17 | |
| IMAGE | 18 | |
| LINK | 19 | |
| AUTO_LINK | 20 | |
| TAG | 21 | |
| STRIKETHROUGH | 22 | |
| ESCAPING_CHARACTER | 23 | |
| MATH | 24 | |
| HIGHLIGHT | 25 | |
| SUBSCRIPT | 26 | |
| SUPERSCRIPT | 27 | |

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
import { useContext, useEffect } from "react";
import { useMemoStore } from "@/store/v1";
import MemoContent from "..";
import { RendererContext } from "../types";
interface Props {
memoId: number;
}
const EmbeddedMemo = ({ memoId }: Props) => {
const context = useContext(RendererContext);
const memoStore = useMemoStore();
const memo = memoStore.getMemoById(memoId);
const resourceName = `memos/${memoId}`;
useEffect(() => {
memoStore.getOrFetchMemoById(memoId);
}, [memoId]);
if (memoId === context.memoId || context.embeddedMemos.has(resourceName)) {
return <p>Nested Rendering Error: {`![[${resourceName}]]`}</p>;
}
context.embeddedMemos.add(resourceName);
return (
<div className="embedded-memo">
<MemoContent nodes={memo.nodes} memoId={memoId} embeddedMemos={context.embeddedMemos} />
</div>
);
};
export default EmbeddedMemo;

@ -0,0 +1,20 @@
import EmbeddedMemo from "./EmbeddedMemo";
interface Props {
resourceName: string;
}
const extractResourceTypeAndId = (resourceName: string) => {
const [resourceType, resourceId] = resourceName.split("/");
return { resourceType, resourceId };
};
const EmbeddedContent = ({ resourceName }: Props) => {
const { resourceType, resourceId } = extractResourceTypeAndId(resourceName);
if (resourceType === "memos") {
return <EmbeddedMemo memoId={Number(resourceId)} />;
}
return <p>Unknown resource: {resourceName}</p>;
};
export default EmbeddedContent;

@ -5,6 +5,7 @@ import {
BoldNode,
CodeBlockNode,
CodeNode,
EmbeddedContentNode,
EscapingCharacterNode,
HeadingNode,
HighlightNode,
@ -31,6 +32,7 @@ import Bold from "./Bold";
import BoldItalic from "./BoldItalic";
import Code from "./Code";
import CodeBlock from "./CodeBlock";
import EmbeddedContent from "./EmbeddedContent";
import EscapingCharacter from "./EscapingCharacter";
import Heading from "./Heading";
import Highlight from "./Highlight";
@ -80,6 +82,8 @@ const Renderer: React.FC<Props> = ({ index, node }: Props) => {
return <Math {...(node.mathBlockNode as MathNode)} block={true} />;
case NodeType.TABLE:
return <Table {...(node.tableNode as TableNode)} />;
case NodeType.EMBEDDED_CONTENT:
return <EmbeddedContent {...(node.embeddedContentNode as EmbeddedContentNode)} />;
case NodeType.TEXT:
return <Text {...(node.textNode as TextNode)} />;
case NodeType.BOLD:

@ -10,12 +10,15 @@ interface Props {
memoId?: number;
readonly?: boolean;
disableFilter?: boolean;
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos?: Set<string>;
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
const MemoContent: React.FC<Props> = (props: Props) => {
const { className, memoId, nodes, onClick } = props;
const { className, memoId, nodes, embeddedMemos, onClick } = props;
const currentUser = useCurrentUser();
const memoStore = useMemoStore();
const memoContentContainerRef = useRef<HTMLDivElement>(null);
@ -37,6 +40,7 @@ const MemoContent: React.FC<Props> = (props: Props) => {
memoId,
readonly: !allowEdit,
disableFilter: props.disableFilter,
embeddedMemos: embeddedMemos || new Set(),
}}
>
<div className={`w-full flex flex-col justify-start items-start text-gray-800 dark:text-gray-300 ${className || ""}`}>

@ -3,6 +3,9 @@ import { Node } from "@/types/proto/api/v2/markdown_service";
interface Context {
nodes: Node[];
// embeddedMemos is a set of memo resource names that are embedded in the current memo.
// This is used to prevent infinite loops when a memo embeds itself.
embeddedMemos: Set<string>;
memoId?: number;
readonly?: boolean;
disableFilter?: boolean;
@ -10,4 +13,5 @@ interface Context {
export const RendererContext = createContext<Context>({
nodes: [],
embeddedMemos: new Set(),
});

Loading…
Cancel
Save