diff --git a/plugin/markdown/ast/wikilink.go b/plugin/markdown/ast/wikilink.go
deleted file mode 100644
index 3cbead1e..00000000
--- a/plugin/markdown/ast/wikilink.go
+++ /dev/null
@@ -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)
-}
diff --git a/plugin/markdown/extensions/wikilink.go b/plugin/markdown/extensions/wikilink.go
deleted file mode 100644
index a68e8e6c..00000000
--- a/plugin/markdown/extensions/wikilink.go
+++ /dev/null
@@ -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),
- ),
- )
-}
diff --git a/plugin/markdown/markdown.go b/plugin/markdown/markdown.go
index 364c1e0d..c6498beb 100644
--- a/plugin/markdown/markdown.go
+++ b/plugin/markdown/markdown.go
@@ -62,8 +62,7 @@ type service struct {
type Option func(*config)
type config struct {
- enableTags bool
- enableWikilink bool
+ enableTags bool
}
// WithTagExtension enables #tag parsing.
@@ -73,13 +72,6 @@ func WithTagExtension() Option {
}
}
-// WithWikilinkExtension enables [[wikilink]] parsing.
-func WithWikilinkExtension() Option {
- return func(c *config) {
- c.enableWikilink = true
- }
-}
-
// NewService creates a new markdown service with the given options.
func NewService(opts ...Option) Service {
cfg := &config{}
@@ -95,9 +87,6 @@ func NewService(opts ...Option) Service {
if cfg.enableTags {
exts = append(exts, extensions.TagExtension)
}
- if cfg.enableWikilink {
- exts = append(exts, extensions.WikilinkExtension)
- }
md := goldmark.New(
goldmark.WithExtensions(exts...),
@@ -164,7 +153,7 @@ func (s *service) ExtractProperties(content []byte) (*storepb.MemoPayload_Proper
}
switch n.Kind() {
- case gast.KindLink, mast.KindWikilink:
+ case gast.KindLink:
prop.HasLink = true
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
@@ -321,7 +310,7 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) {
// Extract properties based on node kind
switch n.Kind() {
- case gast.KindLink, mast.KindWikilink:
+ case gast.KindLink:
data.Property.HasLink = true
case gast.KindCodeBlock, gast.KindFencedCodeBlock, gast.KindCodeSpan:
diff --git a/plugin/markdown/markdown_test.go b/plugin/markdown/markdown_test.go
index 92fb7dad..628d96c4 100644
--- a/plugin/markdown/markdown_test.go
+++ b/plugin/markdown/markdown_test.go
@@ -109,7 +109,6 @@ func TestExtractProperties(t *testing.T) {
tests := []struct {
name string
content string
- withExt bool
hasLink bool
hasCode bool
hasTasks bool
@@ -118,7 +117,6 @@ func TestExtractProperties(t *testing.T) {
{
name: "plain text",
content: "Just plain text",
- withExt: false,
hasLink: false,
hasCode: false,
hasTasks: false,
@@ -127,7 +125,6 @@ func TestExtractProperties(t *testing.T) {
{
name: "with link",
content: "Check out [this link](https://example.com)",
- withExt: false,
hasLink: true,
hasCode: false,
hasTasks: false,
@@ -136,7 +133,6 @@ func TestExtractProperties(t *testing.T) {
{
name: "with inline code",
content: "Use `console.log()` to debug",
- withExt: false,
hasLink: false,
hasCode: true,
hasTasks: false,
@@ -145,7 +141,6 @@ func TestExtractProperties(t *testing.T) {
{
name: "with code block",
content: "```go\nfunc main() {}\n```",
- withExt: false,
hasLink: false,
hasCode: true,
hasTasks: false,
@@ -154,7 +149,6 @@ func TestExtractProperties(t *testing.T) {
{
name: "with completed task",
content: "- [x] Completed task",
- withExt: false,
hasLink: false,
hasCode: false,
hasTasks: true,
@@ -163,7 +157,6 @@ func TestExtractProperties(t *testing.T) {
{
name: "with incomplete task",
content: "- [ ] Todo item",
- withExt: false,
hasLink: false,
hasCode: false,
hasTasks: true,
@@ -172,25 +165,14 @@ func TestExtractProperties(t *testing.T) {
{
name: "mixed tasks",
content: "- [x] Done\n- [ ] Not done",
- withExt: false,
hasLink: false,
hasCode: false,
hasTasks: true,
hasInc: true,
},
- {
- name: "with referenced content",
- content: "See [[memos/1]] for details",
- withExt: true,
- hasLink: true,
- hasCode: false,
- hasTasks: false,
- hasInc: false,
- },
{
name: "everything",
content: "# Title\n\n[Link](url)\n\n`code`\n\n- [ ] Task",
- withExt: false,
hasLink: true,
hasCode: true,
hasTasks: true,
@@ -200,12 +182,7 @@ func TestExtractProperties(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- var svc Service
- if tt.withExt {
- svc = NewService(WithWikilinkExtension())
- } else {
- svc = NewService()
- }
+ svc := NewService()
props, err := svc.ExtractProperties([]byte(tt.content))
require.NoError(t, err)
diff --git a/plugin/markdown/parser/tag.go b/plugin/markdown/parser/tag.go
index 348e3da1..26804e26 100644
--- a/plugin/markdown/parser/tag.go
+++ b/plugin/markdown/parser/tag.go
@@ -45,7 +45,7 @@ func (*tagParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.N
}
// Scan tag characters
- // Valid: alphanumeric, dash, underscore
+ // Valid: alphanumeric, dash, underscore, forward slash
tagEnd := 1 // Start after #
for tagEnd < len(line) {
c := line[tagEnd]
@@ -53,7 +53,7 @@ func (*tagParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.N
isValid := (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
- c == '-' || c == '_'
+ c == '-' || c == '_' || c == '/'
if !isValid {
break
diff --git a/plugin/markdown/parser/tag_test.go b/plugin/markdown/parser/tag_test.go
index a0875507..d238b591 100644
--- a/plugin/markdown/parser/tag_test.go
+++ b/plugin/markdown/parser/tag_test.go
@@ -96,6 +96,36 @@ func TestTagParser(t *testing.T) {
expectedTag: "WorkNotes",
shouldParse: true,
},
+ {
+ name: "hierarchical tag with slash",
+ input: "#tag1/subtag",
+ expectedTag: "tag1/subtag",
+ shouldParse: true,
+ },
+ {
+ name: "hierarchical tag with multiple levels",
+ input: "#tag1/subtag/subtag2",
+ expectedTag: "tag1/subtag/subtag2",
+ shouldParse: true,
+ },
+ {
+ name: "hierarchical tag followed by space",
+ input: "#work/notes ",
+ expectedTag: "work/notes",
+ shouldParse: true,
+ },
+ {
+ name: "hierarchical tag followed by punctuation",
+ input: "#project/2024.",
+ expectedTag: "project/2024",
+ shouldParse: true,
+ },
+ {
+ name: "hierarchical tag with numbers and dashes",
+ input: "#work-log/2024/q1",
+ expectedTag: "work-log/2024/q1",
+ shouldParse: true,
+ },
}
for _, tt := range tests {
diff --git a/plugin/markdown/parser/wikilink.go b/plugin/markdown/parser/wikilink.go
deleted file mode 100644
index 492a1f37..00000000
--- a/plugin/markdown/parser/wikilink.go
+++ /dev/null
@@ -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
-}
diff --git a/plugin/markdown/parser/wikilink_test.go b/plugin/markdown/parser/wikilink_test.go
deleted file mode 100644
index 33a8876c..00000000
--- a/plugin/markdown/parser/wikilink_test.go
+++ /dev/null
@@ -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)
- })
-}
diff --git a/plugin/markdown/renderer/markdown_renderer.go b/plugin/markdown/renderer/markdown_renderer.go
index e9971d6b..9ba5215f 100644
--- a/plugin/markdown/renderer/markdown_renderer.go
+++ b/plugin/markdown/renderer/markdown_renderer.go
@@ -156,15 +156,6 @@ func (r *MarkdownRenderer) renderNode(node gast.Node, source []byte, depth int)
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)
diff --git a/plugin/markdown/renderer/markdown_renderer_test.go b/plugin/markdown/renderer/markdown_renderer_test.go
index a665eb42..17aac75a 100644
--- a/plugin/markdown/renderer/markdown_renderer_test.go
+++ b/plugin/markdown/renderer/markdown_renderer_test.go
@@ -19,7 +19,6 @@ func TestMarkdownRenderer(t *testing.T) {
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
- extensions.WikilinkExtension,
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),
@@ -111,15 +110,10 @@ func TestMarkdownRenderer(t *testing.T) {
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```",
+ 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\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\n```python\nprint('hello')\n```",
},
}
@@ -153,7 +147,6 @@ func TestMarkdownRendererPreservesStructure(t *testing.T) {
goldmark.WithExtensions(
extension.GFM,
extensions.TagExtension,
- extensions.WikilinkExtension,
),
)
@@ -162,7 +155,6 @@ func TestMarkdownRendererPreservesStructure(t *testing.T) {
"**Bold** and *italic*",
"- List\n- Items",
"#tag #another",
- "[[wikilink]]",
"> Quote",
}
diff --git a/server/router/api/v1/test/test_helper.go b/server/router/api/v1/test/test_helper.go
index fa4a18b1..eb9ef93b 100644
--- a/server/router/api/v1/test/test_helper.go
+++ b/server/router/api/v1/test/test_helper.go
@@ -39,7 +39,6 @@ func NewTestService(t *testing.T) *TestService {
secret := "test-secret"
markdownService := markdown.NewService(
markdown.WithTagExtension(),
- markdown.WithWikilinkExtension(),
)
service := &apiv1.APIV1Service{
Secret: secret,
diff --git a/server/router/api/v1/v1.go b/server/router/api/v1/v1.go
index 24820d7b..a8b7ecc5 100644
--- a/server/router/api/v1/v1.go
+++ b/server/router/api/v1/v1.go
@@ -45,7 +45,6 @@ func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store
grpc.EnableTracing = true
markdownService := markdown.NewService(
markdown.WithTagExtension(),
- markdown.WithWikilinkExtension(),
)
apiv1Service := &APIV1Service{
Secret: secret,
diff --git a/web/MARKDOWN_STYLE_GUIDE.md b/web/MARKDOWN_STYLE_GUIDE.md
new file mode 100644
index 00000000..03a67d0d
--- /dev/null
+++ b/web/MARKDOWN_STYLE_GUIDE.md
@@ -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
+
+
+ {content}
+
+
+```
+
+Standard elements (p, ul, ol, h1-h6, etc.) are styled via CSS.
+
+### Component-Based (for custom elements)
+```tsx
+
+ {content}
+
+```
+
+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)
diff --git a/web/package.json b/web/package.json
index 5f5b99bb..549463ae 100644
--- a/web/package.json
+++ b/web/package.json
@@ -56,7 +56,6 @@
"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",
"tailwindcss": "^4.1.13",
"textarea-caret": "^3.1.0",
diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml
index eb15fd8b..b4f70100 100644
--- a/web/pnpm-lock.yaml
+++ b/web/pnpm-lock.yaml
@@ -152,9 +152,6 @@ importers:
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
- remark-wiki-link:
- specifier: ^2.0.1
- version: 2.0.1
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -1748,21 +1745,12 @@ packages:
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
- character-entities-legacy@1.1.4:
- resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
-
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
- character-entities@1.2.4:
- resolution: {integrity: sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==}
-
character-entities@2.0.2:
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
- character-reference-invalid@1.1.4:
- resolution: {integrity: sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==}
-
character-reference-invalid@2.0.1:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
@@ -2494,15 +2482,9 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
- is-alphabetical@1.0.4:
- resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
-
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
- is-alphanumerical@1.0.4:
- resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
-
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
@@ -2541,9 +2523,6 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
- is-decimal@1.0.4:
- resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
-
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
@@ -2563,9 +2542,6 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
- is-hexadecimal@1.0.4:
- resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
-
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
@@ -2811,9 +2787,6 @@ packages:
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
- longest-streak@2.0.4:
- resolution: {integrity: sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==}
-
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
@@ -2883,21 +2856,12 @@ packages:
mdast-util-to-hast@13.2.0:
resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==}
- mdast-util-to-markdown@0.6.5:
- resolution: {integrity: sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==}
-
mdast-util-to-markdown@2.1.2:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
- mdast-util-to-string@2.0.0:
- resolution: {integrity: sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==}
-
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
- mdast-util-wiki-link@0.1.2:
- resolution: {integrity: sha512-DTcDyOxKDo3pB3fc0zQlD8myfQjYkW4hazUKI9PUyhtoj9JBeHC2eIdlVXmaT22bZkFAVU2d47B6y2jVKGoUQg==}
-
mdn-data@2.0.14:
resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==}
@@ -2932,9 +2896,6 @@ packages:
micromark-extension-gfm@3.0.0:
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
- micromark-extension-wiki-link@0.0.4:
- resolution: {integrity: sha512-dJc8AfnoU8BHkN+7fWZvIS20SMsMS1ZlxQUn6We67MqeKbOiEDZV5eEvCpwqGBijbJbxX3Kxz879L4K9HIiOvw==}
-
micromark-factory-destination@2.0.1:
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
@@ -3120,9 +3081,6 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
- parse-entities@2.0.0:
- resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
-
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -3372,13 +3330,6 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
- remark-wiki-link@2.0.1:
- resolution: {integrity: sha512-F8Eut1E7GWfFm4ZDTI6/4ejeZEHZgnVk6E933Yqd/ssYsc4AyI32aGakxwsGcEzbbE7dkWi1EfLlGAdGgOZOsA==}
-
- repeat-string@1.6.1:
- resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==}
- engines: {node: '>=0.10'}
-
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -3878,9 +3829,6 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
- zwitch@1.0.5:
- resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==}
-
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -5404,16 +5352,10 @@ snapshots:
character-entities-html4@2.1.0: {}
- character-entities-legacy@1.1.4: {}
-
character-entities-legacy@3.0.0: {}
- character-entities@1.2.4: {}
-
character-entities@2.0.2: {}
- character-reference-invalid@1.1.4: {}
-
character-reference-invalid@2.0.1: {}
chevrotain-allstar@0.3.1(chevrotain@11.0.3):
@@ -6345,15 +6287,8 @@ snapshots:
internmap@2.0.3: {}
- is-alphabetical@1.0.4: {}
-
is-alphabetical@2.0.1: {}
- is-alphanumerical@1.0.4:
- dependencies:
- is-alphabetical: 1.0.4
- is-decimal: 1.0.4
-
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
@@ -6401,8 +6336,6 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
- is-decimal@1.0.4: {}
-
is-decimal@2.0.1: {}
is-extglob@2.1.1: {}
@@ -6422,8 +6355,6 @@ snapshots:
dependencies:
is-extglob: 2.1.1
- is-hexadecimal@1.0.4: {}
-
is-hexadecimal@2.0.1: {}
is-map@2.0.3: {}
@@ -6630,8 +6561,6 @@ snapshots:
long@5.3.2: {}
- longest-streak@2.0.4: {}
-
longest-streak@3.1.0: {}
loose-envify@1.4.0:
@@ -6793,15 +6722,6 @@ snapshots:
unist-util-visit: 5.0.0
vfile: 6.0.3
- mdast-util-to-markdown@0.6.5:
- dependencies:
- '@types/unist': 2.0.11
- longest-streak: 2.0.4
- mdast-util-to-string: 2.0.0
- parse-entities: 2.0.0
- repeat-string: 1.6.1
- zwitch: 1.0.5
-
mdast-util-to-markdown@2.1.2:
dependencies:
'@types/mdast': 4.0.4
@@ -6814,17 +6734,10 @@ snapshots:
unist-util-visit: 5.0.0
zwitch: 2.0.4
- mdast-util-to-string@2.0.0: {}
-
mdast-util-to-string@4.0.0:
dependencies:
'@types/mdast': 4.0.4
- mdast-util-wiki-link@0.1.2:
- dependencies:
- '@babel/runtime': 7.28.4
- mdast-util-to-markdown: 0.6.5
-
mdn-data@2.0.14: {}
merge2@1.4.1: {}
@@ -6931,10 +6844,6 @@ snapshots:
micromark-util-combine-extensions: 2.0.1
micromark-util-types: 2.0.2
- micromark-extension-wiki-link@0.0.4:
- dependencies:
- '@babel/runtime': 7.28.4
-
micromark-factory-destination@2.0.1:
dependencies:
micromark-util-character: 2.1.1
@@ -7188,15 +7097,6 @@ snapshots:
dependencies:
callsites: 3.1.0
- parse-entities@2.0.0:
- dependencies:
- character-entities: 1.2.4
- character-entities-legacy: 1.1.4
- character-reference-invalid: 1.1.4
- is-alphanumerical: 1.0.4
- is-decimal: 1.0.4
- is-hexadecimal: 1.0.4
-
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -7487,14 +7387,6 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
- remark-wiki-link@2.0.1:
- dependencies:
- '@babel/runtime': 7.28.4
- mdast-util-wiki-link: 0.1.2
- micromark-extension-wiki-link: 0.0.4
-
- repeat-string@1.6.1: {}
-
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -8052,6 +7944,4 @@ snapshots:
yocto-queue@0.1.0: {}
- zwitch@1.0.5: {}
-
zwitch@2.0.4: {}
diff --git a/web/src/components/MemoContent/ConditionalComponent.tsx b/web/src/components/MemoContent/ConditionalComponent.tsx
index dd5bc5a3..d1df56ed 100644
--- a/web/src/components/MemoContent/ConditionalComponent.tsx
+++ b/web/src/components/MemoContent/ConditionalComponent.tsx
@@ -39,15 +39,6 @@ export const createConditionalComponent = >(
* - 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") {
diff --git a/web/src/components/MemoContent/DefaultLink.tsx b/web/src/components/MemoContent/DefaultLink.tsx
deleted file mode 100644
index 21783b30..00000000
--- a/web/src/components/MemoContent/DefaultLink.tsx
+++ /dev/null
@@ -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 {
- node?: any; // AST node from react-markdown
- href?: string;
- children?: React.ReactNode;
-}
-
-export const DefaultLink: React.FC = ({ href, children, ...props }) => {
- const isExternal = href?.startsWith("http://") || href?.startsWith("https://");
-
- return (
- e.stopPropagation()}
- >
- {children}
-
- );
-};
diff --git a/web/src/components/MemoContent/MemoContentContext.tsx b/web/src/components/MemoContent/MemoContentContext.tsx
index ce4a43a9..e2e36f94 100644
--- a/web/src/components/MemoContent/MemoContentContext.tsx
+++ b/web/src/components/MemoContent/MemoContentContext.tsx
@@ -4,7 +4,7 @@ import { createContext } from "react";
* Context for MemoContent rendering
*
* Provides memo metadata and configuration to child components
- * Used by custom react-markdown components (TaskListItem, WikiLink, Tag, etc.)
+ * Used by custom react-markdown components (TaskListItem, Tag, etc.)
*/
export interface MemoContentContextType {
diff --git a/web/src/components/MemoContent/WikiLink.tsx b/web/src/components/MemoContent/WikiLink.tsx
deleted file mode 100644
index f979b604..00000000
--- a/web/src/components/MemoContent/WikiLink.tsx
+++ /dev/null
@@ -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 {
- node?: any; // AST node from react-markdown
- href?: string;
- children?: React.ReactNode;
-}
-
-export const WikiLink: React.FC = ({ 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) => {
- e.preventDefault();
- e.stopPropagation();
-
- // TODO: Implement wikilink navigation
- // This could navigate to memo detail, show preview, etc.
- console.log("Wikilink clicked:", target);
- };
-
- return (
-
- {children}
-
- );
-};
diff --git a/web/src/components/MemoContent/index.tsx b/web/src/components/MemoContent/index.tsx
index c65e7401..508cb44a 100644
--- a/web/src/components/MemoContent/index.tsx
+++ b/web/src/components/MemoContent/index.tsx
@@ -3,7 +3,6 @@ import { memo, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import rehypeRaw from "rehype-raw";
import remarkGfm from "remark-gfm";
-import remarkWikiLink from "remark-wiki-link";
import useCurrentUser from "@/hooks/useCurrentUser";
import { cn } from "@/lib/utils";
import { memoStore } from "@/store";
@@ -11,12 +10,10 @@ import { useTranslate } from "@/utils/i18n";
import { remarkPreserveType } from "@/utils/remark-plugins/remark-preserve-type";
import { remarkTag } from "@/utils/remark-plugins/remark-tag";
import { isSuperUser } from "@/utils/user";
-import { createConditionalComponent, isTagNode, isTaskListItemNode, isWikiLinkNode } from "./ConditionalComponent";
-import { DefaultLink } from "./DefaultLink";
+import { createConditionalComponent, isTagNode, isTaskListItemNode } from "./ConditionalComponent";
import { MemoContentContext } from "./MemoContentContext";
import { Tag } from "./Tag";
import { TaskListItem } from "./TaskListItem";
-import { WikiLink } from "./WikiLink";
// MAX_DISPLAY_HEIGHT is the maximum height of the memo content to display in compact mode.
const MAX_DISPLAY_HEIGHT = 256;
@@ -99,12 +96,11 @@ const MemoContent = observer((props: Props) => {
onDoubleClick={onMemoContentDoubleClick}
>
diff --git a/web/src/index.css b/web/src/index.css
index 4bfffde7..190e68b5 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -22,7 +22,7 @@
.markdown-content ul.contains-task-list,
.prose ul.contains-task-list {
padding: 0 !important;
- margin: 0.5rem 0 !important;
+ margin: 0.25rem 0 !important;
list-style: none !important;
margin-block-start: 0 !important;
margin-block-end: 0 !important;
@@ -48,8 +48,8 @@
.prose ul.contains-task-list li.task-list-item {
display: flex !important;
align-items: center !important;
- gap: 0.5rem !important;
- margin: 0.125rem 0 !important;
+ gap: 0.375rem !important;
+ margin: 0.0625rem 0 !important;
padding: 0 !important;
line-height: 1.5rem !important;
list-style: none !important;
@@ -105,81 +105,111 @@
/* ========================================
* Markdown Content Styles
- * Custom minimal styles for markdown rendering
+ * Compact spacing optimized for memos/notes
+ *
+ * Key principles:
+ * 1. Block elements use 8px (0.5rem) bottom margin (compact)
+ * 2. First child has no top margin, last child has no bottom margin
+ * 3. Nested elements have minimal spacing
+ * 4. Inline elements have no vertical spacing
* ======================================== */
.markdown-content {
+ font-size: 1rem;
+ line-height: 1.5;
color: var(--foreground);
- white-space: pre-line; /* Preserve newlines but collapse multiple spaces */
+ word-wrap: break-word;
}
- /* Block elements should not inherit pre-line */
+ /* ========================================
+ * First/Last Child Normalization
+ * Remove boundary spacing to prevent double margins
+ * ======================================== */
+
+ .markdown-content > :first-child {
+ margin-top: 0 !important;
+ }
+
+ .markdown-content > :last-child {
+ margin-bottom: 0 !important;
+ }
+
+ /* ========================================
+ * Block Elements
+ * Compact 8px bottom margin
+ * ======================================== */
+
+ .markdown-content p,
+ .markdown-content blockquote,
+ .markdown-content ul,
+ .markdown-content ol,
+ .markdown-content dl,
+ .markdown-content table,
+ .markdown-content pre,
+ .markdown-content hr {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ }
+
+ /* ========================================
+ * Headings
+ * Compact spacing for visual separation
+ * ======================================== */
+
.markdown-content h1,
.markdown-content h2,
.markdown-content h3,
.markdown-content h4,
.markdown-content h5,
- .markdown-content h6,
- .markdown-content p,
- .markdown-content ul,
- .markdown-content ol,
- .markdown-content pre,
- .markdown-content blockquote,
- .markdown-content table {
- white-space: normal;
- }
-
- /* Code blocks need pre-wrap */
- .markdown-content pre {
- white-space: pre-wrap;
- }
-
- /* Headings */
- .markdown-content h1 {
- font-size: 1.875rem;
- font-weight: 700;
- line-height: 2.25rem;
- margin-top: 1rem;
+ .markdown-content h6 {
+ margin-top: 0.75rem;
margin-bottom: 0.5rem;
+ font-weight: 600;
+ line-height: 1.25;
+ }
+
+ .markdown-content h1 {
+ font-size: 2em;
+ font-weight: 700;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 0.25rem;
}
.markdown-content h2 {
- font-size: 1.5rem;
- font-weight: 600;
- line-height: 2rem;
- margin-top: 0.75rem;
- margin-bottom: 0.5rem;
+ font-size: 1.5em;
+ border-bottom: 1px solid var(--border);
+ padding-bottom: 0.25rem;
}
.markdown-content h3 {
- font-size: 1.25rem;
- font-weight: 600;
- line-height: 1.75rem;
- margin-top: 0.75rem;
- margin-bottom: 0.5rem;
+ font-size: 1.25em;
+ }
+
+ .markdown-content h4 {
+ font-size: 1em;
+ }
+
+ .markdown-content h5 {
+ font-size: 0.875em;
}
- .markdown-content h4,
- .markdown-content h5,
.markdown-content h6 {
- font-size: 1.125rem;
- font-weight: 600;
- line-height: 1.75rem;
- margin-top: 0.5rem;
- margin-bottom: 0.5rem;
+ font-size: 0.85em;
+ color: var(--muted-foreground);
}
- /* First heading has no top margin */
- .markdown-content > :first-child {
- margin-top: 0;
- }
+ /* ========================================
+ * Paragraphs
+ * ======================================== */
- /* Paragraphs */
.markdown-content p {
- margin: 0.5rem 0;
+ line-height: 1.5;
}
- /* Links */
+ /* ========================================
+ * Links
+ * ======================================== */
+
.markdown-content a {
color: var(--primary);
text-decoration: underline;
@@ -190,10 +220,12 @@
opacity: 0.8;
}
- /* Lists - MINIMAL spacing */
+ /* ========================================
+ * Lists
+ * ======================================== */
+
.markdown-content ul,
.markdown-content ol {
- margin: 0.5rem 0;
padding-left: 1.5em;
list-style-position: outside;
}
@@ -207,25 +239,34 @@
}
.markdown-content li {
- margin: 0.125rem 0;
+ margin-top: 0.125rem;
line-height: 1.5;
}
+ .markdown-content li > p {
+ margin-bottom: 0.125rem;
+ }
+
+ /* Nested lists should have minimal spacing */
+ .markdown-content li > ul,
+ .markdown-content li > ol {
+ margin-top: 0.125rem;
+ margin-bottom: 0.125rem;
+ }
+
+ /* First and last items in lists */
.markdown-content li:first-child {
margin-top: 0;
}
- .markdown-content li:last-child {
- margin-bottom: 0;
+ .markdown-content li + li {
+ margin-top: 0.125rem;
}
- /* Nested lists */
- .markdown-content li > ul,
- .markdown-content li > ol {
- margin: 0.25rem 0;
- }
+ /* ========================================
+ * Code (inline and blocks)
+ * ======================================== */
- /* Code */
.markdown-content code {
font-family: var(--font-mono);
font-size: 0.875em;
@@ -238,76 +279,120 @@
font-family: var(--font-mono);
font-size: 0.875rem;
background: var(--muted);
- padding: 0.75rem 1rem;
- border-radius: 0.5rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.375rem;
overflow-x: auto;
- margin: 0.75rem 0;
+ white-space: pre-wrap;
+ word-wrap: break-word;
}
.markdown-content pre code {
background: none;
padding: 0;
font-size: inherit;
+ border-radius: 0;
}
- /* Blockquotes */
+ /* ========================================
+ * Blockquotes
+ * ======================================== */
+
.markdown-content blockquote {
- border-left: 3px solid var(--border);
- padding-left: 1rem;
- margin: 0.75rem 0;
+ padding: 0 0.75rem;
color: var(--muted-foreground);
- font-style: italic;
+ border-left: 0.25rem solid var(--border);
}
- /* Horizontal rule */
+ .markdown-content blockquote > :first-child {
+ margin-top: 0;
+ }
+
+ .markdown-content blockquote > :last-child {
+ margin-bottom: 0;
+ }
+
+ /* ========================================
+ * Horizontal Rules
+ * ======================================== */
+
.markdown-content hr {
- border: none;
- border-top: 1px solid var(--border);
- margin: 1rem 0;
+ height: 0.25em;
+ padding: 0;
+ background: transparent;
+ border: 0;
+ border-bottom: 1px solid var(--border);
}
- /* Tables */
+ /* ========================================
+ * Tables
+ * ======================================== */
+
.markdown-content table {
+ display: block;
width: 100%;
+ width: max-content;
+ max-width: 100%;
+ overflow: auto;
+ border-spacing: 0;
border-collapse: collapse;
- margin: 0.75rem 0;
}
- .markdown-content th,
- .markdown-content td {
+ .markdown-content table th,
+ .markdown-content table td {
+ padding: 0.25rem 0.5rem;
border: 1px solid var(--border);
- padding: 0.5rem;
- text-align: left;
}
- .markdown-content th {
- background: var(--muted);
+ .markdown-content table th {
font-weight: 600;
+ background: var(--muted);
}
- /* Images */
+ .markdown-content table tr {
+ background: transparent;
+ border-top: 1px solid var(--border);
+ }
+
+ .markdown-content table tr:nth-child(2n) {
+ background: var(--muted);
+ opacity: 0.5;
+ }
+
+ /* ========================================
+ * Images
+ * ======================================== */
+
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
- margin: 0.75rem 0;
}
- /* Strong/Bold */
+ /* ========================================
+ * Inline Elements
+ * No vertical spacing
+ * ======================================== */
+
.markdown-content strong {
font-weight: 600;
}
- /* Emphasis/Italic */
.markdown-content em {
font-style: italic;
}
- /* Inline elements shouldn't add vertical spacing */
.markdown-content code,
.markdown-content strong,
.markdown-content em,
.markdown-content a {
vertical-align: baseline;
}
+
+ /* ========================================
+ * Strikethrough (GFM)
+ * ======================================== */
+
+ .markdown-content del {
+ text-decoration: line-through;
+ }
}
diff --git a/web/src/utils/remark-plugins/remark-tag.ts b/web/src/utils/remark-plugins/remark-tag.ts
index a051dc16..4f1866c6 100644
--- a/web/src/utils/remark-plugins/remark-tag.ts
+++ b/web/src/utils/remark-plugins/remark-tag.ts
@@ -11,10 +11,11 @@ import { visit } from "unist-util-visit";
* #work → #work
* #2024_plans → #2024_plans
* #work-notes → #work-notes
+ * #tag1/subtag/subtag2 → #tag1/subtag/subtag2
*
* Rules:
- * - Tag must start with # followed by alphanumeric, underscore, or hyphen
- * - Tag ends at whitespace, punctuation (except - and _), or end of line
+ * - Tag must start with # followed by alphanumeric, underscore, hyphen, or forward slash
+ * - Tag ends at whitespace, punctuation (except -, _, /), or end of line
* - Tags at start of line after ## are headings, not tags
*/
@@ -22,7 +23,7 @@ import { visit } from "unist-util-visit";
* Check if character is valid for tag content
*/
function isTagChar(char: string): boolean {
- return /[a-zA-Z0-9_-]/.test(char);
+ return /[a-zA-Z0-9_\-/]/.test(char);
}
/**