mirror of https://github.com/usememos/memos.git
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.
This commit is contained in:
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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {}
|
||||
|
|
|
|||
|
|
@ -39,15 +39,6 @@ export const createConditionalComponent = <P extends Record<string, any>>(
|
|||
* - 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") {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkWikiLink, remarkTag, remarkPreserveType]}
|
||||
remarkPlugins={[remarkGfm, remarkTag, remarkPreserveType]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
// Conditionally render custom components based on AST node type
|
||||
input: createConditionalComponent(TaskListItem, "input", isTaskListItemNode),
|
||||
a: createConditionalComponent(WikiLink, DefaultLink, isWikiLinkNode),
|
||||
span: createConditionalComponent(Tag, "span", isTagNode),
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@ import { visit } from "unist-util-visit";
|
|||
* #work → <span class="tag" data-tag="work">#work</span>
|
||||
* #2024_plans → <span class="tag" data-tag="2024_plans">#2024_plans</span>
|
||||
* #work-notes → <span class="tag" data-tag="work-notes">#work-notes</span>
|
||||
* #tag1/subtag/subtag2 → <span class="tag" data-tag="tag1/subtag/subtag2">#tag1/subtag/subtag2</span>
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue