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