mirror of https://github.com/usememos/memos
				
				
				
			chore: remove unused syntax
- Removed the wikilink extension from markdown services in test and API service. - Deleted the DefaultLink and WikiLink components, simplifying link handling. - Updated ConditionalComponent to remove wikilink checks. - Adjusted MemoContent to exclude wikilink handling in markdown rendering. - Refined markdown styles for compact rendering, enhancing readability. - Added a Markdown Styling Guide to document the new compact styling approach.pull/5122/merge
							parent
							
								
									7eec424274
								
							
						
					
					
						commit
						596b894ca0
					
				@ -1,32 +0,0 @@
 | 
			
		||||
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)
 | 
			
		||||
}
 | 
			
		||||
@ -1,24 +0,0 @@
 | 
			
		||||
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),
 | 
			
		||||
		),
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
@ -1,104 +0,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(_ gast.Node, block text.Reader, _ 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
 | 
			
		||||
}
 | 
			
		||||
@ -1,252 +0,0 @@
 | 
			
		||||
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, ok := node.(*mast.WikilinkNode)
 | 
			
		||||
				require.True(t, ok, "Expected node to be *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)
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,153 @@
 | 
			
		||||
# Markdown Styling Guide
 | 
			
		||||
 | 
			
		||||
This document describes the compact markdown styling approach used in this codebase.
 | 
			
		||||
 | 
			
		||||
## Design Principles
 | 
			
		||||
 | 
			
		||||
Our markdown rendering uses compact spacing optimized for memos and notes:
 | 
			
		||||
 | 
			
		||||
### 1. **Scoped Styles**
 | 
			
		||||
All markdown styles are scoped to `.markdown-content` to avoid global pollution:
 | 
			
		||||
```css
 | 
			
		||||
.markdown-content p { /* scoped */ }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 2. **Compact Block Spacing**
 | 
			
		||||
All block elements use **8px (0.5rem)** bottom margin:
 | 
			
		||||
- Paragraphs
 | 
			
		||||
- Lists (ul, ol)
 | 
			
		||||
- Code blocks (pre)
 | 
			
		||||
- Blockquotes
 | 
			
		||||
- Tables
 | 
			
		||||
- Horizontal rules
 | 
			
		||||
 | 
			
		||||
This is more compact than GitHub's standard (16px) but maintains readability for memo-style content.
 | 
			
		||||
 | 
			
		||||
### 3. **First/Last Child Normalization**
 | 
			
		||||
```css
 | 
			
		||||
.markdown-content > :first-child {
 | 
			
		||||
  margin-top: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.markdown-content > :last-child {
 | 
			
		||||
  margin-bottom: 0 !important;
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
This prevents double margins at container boundaries.
 | 
			
		||||
 | 
			
		||||
### 4. **Nested Element Spacing**
 | 
			
		||||
Nested elements (lists within lists, paragraphs within lists) use **minimal spacing** (2px/0.125rem):
 | 
			
		||||
```css
 | 
			
		||||
.markdown-content li > ul {
 | 
			
		||||
  margin-top: 0.125rem;
 | 
			
		||||
  margin-bottom: 0.125rem;
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 5. **Heading Separation**
 | 
			
		||||
Headings have moderate top margins (12px/0.75rem) to create visual sections:
 | 
			
		||||
```css
 | 
			
		||||
.markdown-content h1,
 | 
			
		||||
.markdown-content h2 {
 | 
			
		||||
  margin-top: 0.75rem;
 | 
			
		||||
  margin-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 6. **No White-Space Preservation**
 | 
			
		||||
We do NOT use `white-space: pre-line`. Spacing is controlled entirely by CSS margins, matching how GitHub/ChatGPT/Claude work.
 | 
			
		||||
 | 
			
		||||
## Component Architecture
 | 
			
		||||
 | 
			
		||||
We use a **hybrid approach**:
 | 
			
		||||
 | 
			
		||||
### CSS-Based (for standard elements)
 | 
			
		||||
```tsx
 | 
			
		||||
<div className="markdown-content">
 | 
			
		||||
  <ReactMarkdown>
 | 
			
		||||
    {content}
 | 
			
		||||
  </ReactMarkdown>
 | 
			
		||||
</div>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Standard elements (p, ul, ol, h1-h6, etc.) are styled via CSS.
 | 
			
		||||
 | 
			
		||||
### Component-Based (for custom elements)
 | 
			
		||||
```tsx
 | 
			
		||||
<ReactMarkdown
 | 
			
		||||
  components={{
 | 
			
		||||
    input: TaskListItem,   // Custom task list checkboxes
 | 
			
		||||
    span: Tag,             // Custom #tag rendering
 | 
			
		||||
  }}
 | 
			
		||||
>
 | 
			
		||||
  {content}
 | 
			
		||||
</ReactMarkdown>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Custom elements use React components for interactivity.
 | 
			
		||||
 | 
			
		||||
## Comparison with Industry Standards
 | 
			
		||||
 | 
			
		||||
| Feature | GitHub | ChatGPT | Claude | Memos (ours) |
 | 
			
		||||
|---------|--------|---------|--------|--------------|
 | 
			
		||||
| Block margin | 16px | 16px | 16px | 8px (compact) ⚡ |
 | 
			
		||||
| Scoped styles | `.markdown-body` | `.prose` | Custom | `.markdown-content` ✅ |
 | 
			
		||||
| First/last normalization | ✅ | ✅ | ✅ | ✅ |
 | 
			
		||||
| Heading underlines (h1/h2) | ✅ | ❌ | ❌ | ✅ |
 | 
			
		||||
| Custom components | Few | Many | Many | Some ✅ |
 | 
			
		||||
| Line height | 1.6 | 1.6 | 1.6 | 1.5 (compact) ⚡ |
 | 
			
		||||
| List padding | 2em | 2em | 2em | 1.5em (compact) ⚡ |
 | 
			
		||||
| Code block padding | 16px | 16px | 16px | 8-12px (compact) ⚡ |
 | 
			
		||||
 | 
			
		||||
**Note:** Our compact spacing is optimized for memo/note-taking apps where screen real estate is important.
 | 
			
		||||
 | 
			
		||||
## Examples
 | 
			
		||||
 | 
			
		||||
### Input
 | 
			
		||||
```markdown
 | 
			
		||||
1312
 | 
			
		||||
 | 
			
		||||
* 123123
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Rendering
 | 
			
		||||
- Paragraph "1312" with `margin-bottom: 0.5rem` (8px)
 | 
			
		||||
- List with `margin-top: 0` (normalized)
 | 
			
		||||
- Result: Single 8px gap between paragraph and list ✅
 | 
			
		||||
 | 
			
		||||
### Before (with `white-space: pre-line`)
 | 
			
		||||
```
 | 
			
		||||
1312
 | 
			
		||||
[blank line from preserved \n\n]
 | 
			
		||||
[16px margin]
 | 
			
		||||
* 123123
 | 
			
		||||
```
 | 
			
		||||
Result: Double spacing ❌
 | 
			
		||||
 | 
			
		||||
### After (compact spacing, no white-space preservation)
 | 
			
		||||
```
 | 
			
		||||
1312
 | 
			
		||||
[8px margin only]
 | 
			
		||||
* 123123
 | 
			
		||||
```
 | 
			
		||||
Result: Clean, compact single spacing ✅
 | 
			
		||||
 | 
			
		||||
## Testing
 | 
			
		||||
 | 
			
		||||
To verify correct rendering:
 | 
			
		||||
 | 
			
		||||
1. **Text followed by list**: `"text\n\n* item"` → single 8px gap
 | 
			
		||||
2. **List followed by text**: `"* item\n\ntext"` → single 8px gap
 | 
			
		||||
3. **Nested lists**: Should have minimal spacing (2px)
 | 
			
		||||
4. **Headings**: Should have 12px top margin (except first child)
 | 
			
		||||
5. **Blockquotes**: Should handle nested content properly
 | 
			
		||||
6. **Code blocks**: Should have 8-12px padding (compact)
 | 
			
		||||
7. **Tables**: Should have compact cell padding (4px vertical, 8px horizontal)
 | 
			
		||||
 | 
			
		||||
## References
 | 
			
		||||
 | 
			
		||||
- [CommonMark Spec](https://spec.commonmark.org/)
 | 
			
		||||
- [GitHub Flavored Markdown Spec](https://github.github.com/gfm/)
 | 
			
		||||
- [GitHub Markdown CSS](https://github.com/sindresorhus/github-markdown-css)
 | 
			
		||||
- [Tailwind Typography](https://tailwindcss.com/docs/typography-plugin)
 | 
			
		||||
@ -1,30 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Default link component for regular markdown links
 | 
			
		||||
 *
 | 
			
		||||
 * Handles external links with proper target and rel attributes.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
interface DefaultLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
 | 
			
		||||
  node?: any; // AST node from react-markdown
 | 
			
		||||
  href?: string;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const DefaultLink: React.FC<DefaultLinkProps> = ({ href, children, ...props }) => {
 | 
			
		||||
  const isExternal = href?.startsWith("http://") || href?.startsWith("https://");
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <a
 | 
			
		||||
      {...props}
 | 
			
		||||
      href={href}
 | 
			
		||||
      className="text-primary hover:opacity-80 transition-colors underline"
 | 
			
		||||
      target={isExternal ? "_blank" : undefined}
 | 
			
		||||
      rel={isExternal ? "noopener noreferrer" : undefined}
 | 
			
		||||
      onClick={(e) => e.stopPropagation()}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </a>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@ -1,44 +0,0 @@
 | 
			
		||||
import React from "react";
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Custom link component for react-markdown wikilinks
 | 
			
		||||
 *
 | 
			
		||||
 * Handles [[wikilink]] rendering with custom styling and click behavior.
 | 
			
		||||
 * The remark-wiki-link plugin converts [[target]] to anchor elements.
 | 
			
		||||
 *
 | 
			
		||||
 * Note: This component should only be used for wikilinks.
 | 
			
		||||
 * Regular links are handled by the default anchor element.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
interface WikiLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
 | 
			
		||||
  node?: any; // AST node from react-markdown
 | 
			
		||||
  href?: string;
 | 
			
		||||
  children?: React.ReactNode;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const WikiLink: React.FC<WikiLinkProps> = ({ href, children, ...props }) => {
 | 
			
		||||
  // Extract target from href
 | 
			
		||||
  // remark-wiki-link creates hrefs like "#/wiki/target"
 | 
			
		||||
  const target = href?.replace("#/wiki/", "") || "";
 | 
			
		||||
 | 
			
		||||
  const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
    // TODO: Implement wikilink navigation
 | 
			
		||||
    // This could navigate to memo detail, show preview, etc.
 | 
			
		||||
    console.log("Wikilink clicked:", target);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <a
 | 
			
		||||
      {...props}
 | 
			
		||||
      href={href}
 | 
			
		||||
      className="wikilink text-primary hover:opacity-80 transition-colors underline"
 | 
			
		||||
      data-target={target}
 | 
			
		||||
      onClick={handleClick}
 | 
			
		||||
    >
 | 
			
		||||
      {children}
 | 
			
		||||
    </a>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
					Loading…
					
					
				
		Reference in New Issue