Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions internal/block/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const (
Callout // > [!NOTE] callout/admonition
Table // GFM pipe table
Kanban // ```kanban``` fenced kanban board
Bookmark // [title](url) link card
)

// String returns the human-readable name of a BlockType.
Expand Down Expand Up @@ -62,6 +63,8 @@ func (bt BlockType) String() string {
return "Table"
case Kanban:
return "Kanban"
case Bookmark:
return "Bookmark"
default:
return "Unknown"
}
Expand Down Expand Up @@ -110,6 +113,8 @@ func (bt BlockType) Short() string {
return "tb"
case Kanban:
return "kb"
case Bookmark:
return "bm"
default:
return "?"
}
Expand Down Expand Up @@ -273,6 +278,17 @@ func ExtractDefinition(content string) (term, definition string) {
return first, ""
}

// ExtractBookmark splits a bookmark block's content into its title line
// (first line) and URL (second line). When only a URL is stored, title
// is empty.
func ExtractBookmark(content string) (title, url string) {
first, rest, found := strings.Cut(content, "\n")
if found {
return first, rest
}
return "", first
}

// ExtractCodeLanguage splits a code block's content into its title line
// (typically a language identifier) and the remaining body. The first line
// is always treated as the title regardless of whether it is a recognized
Expand Down
37 changes: 36 additions & 1 deletion internal/block/parse.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
package block

import "strings"
import (
"regexp"
"strings"
)

var (
bookmarkLinkRe = regexp.MustCompile(`^\[([^\]]*)\]\((https?://[^\s)]+)\)\s*$`)
bookmarkBareRe = regexp.MustCompile(`^(https?://\S+)\s*$`)
)

// ParseBookmark reports whether a single line is a bookmark — either a
// titled markdown link `[title](url)` or a bare http(s) URL — and returns
// the extracted title (may be empty) and URL.
func ParseBookmark(line string) (title, url string, ok bool) {
if m := bookmarkLinkRe.FindStringSubmatch(line); m != nil {
return m[1], m[2], true
}
if m := bookmarkBareRe.FindStringSubmatch(line); m != nil {
return "", m[1], true
}
return "", "", false
}

// stripListIndent counts leading 4-space groups on a line.
func stripListIndent(line string) (indent int, stripped string) {
Expand Down Expand Up @@ -200,6 +221,17 @@ func Parse(markdown string) []Block {
continue
}

// --- Bookmark ([title](url) or bare URL on its own line) ---
if title, url, ok := ParseBookmark(line); ok {
content := url
if title != "" {
content = title + "\n" + url
}
blocks = append(blocks, Block{Type: Bookmark, Content: content})
i++
continue
}

// --- Definition (term followed by one or more ": definition" lines) ---
if i+1 < len(lines) && !isBlockStart(line) && isDefinitionLine(lines[i+1]) {
term := line
Expand Down Expand Up @@ -268,6 +300,9 @@ func isBlockStart(line string) bool {
if strings.HasPrefix(line, "![[") && strings.HasSuffix(line, "]]") {
return true
}
if _, _, ok := ParseBookmark(line); ok {
return true
}
return isDivider(line)
}

Expand Down
48 changes: 48 additions & 0 deletions internal/block/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,52 @@ func TestParse(t *testing.T) {
{Type: Paragraph, Content: "Some text"},
},
},
{
name: "bookmark titled link",
input: "[Example](https://example.com)",
expect: []Block{
{Type: Bookmark, Content: "Example\nhttps://example.com"},
},
},
{
name: "bookmark bare url",
input: "https://example.com",
expect: []Block{
{Type: Bookmark, Content: "https://example.com"},
},
},
{
name: "bookmark http url",
input: "http://example.com/path?q=1",
expect: []Block{
{Type: Bookmark, Content: "http://example.com/path?q=1"},
},
},
{
name: "bookmark between paragraphs",
input: "above\n\n[Site](https://site.io)\n\nbelow",
expect: []Block{
{Type: Paragraph, Content: "above"},
{Type: Paragraph, Content: ""},
{Type: Bookmark, Content: "Site\nhttps://site.io"},
{Type: Paragraph, Content: ""},
{Type: Paragraph, Content: "below"},
},
},
{
name: "bookmark titled link with empty title",
input: "[](https://example.com)",
expect: []Block{
{Type: Bookmark, Content: "https://example.com"},
},
},
{
name: "inline link in paragraph not parsed as bookmark",
input: "see [docs](https://example.com) for details",
expect: []Block{
{Type: Paragraph, Content: "see [docs](https://example.com) for details"},
},
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -371,6 +417,8 @@ func formatBlocks(blocks []Block) string {
b.WriteString("Embed")
case Table:
b.WriteString("Table")
case Bookmark:
b.WriteString("Bookmark")
}
if bl.Content != "" {
b.WriteString(" " + bl.Content)
Expand Down
8 changes: 8 additions & 0 deletions internal/block/serialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ func Serialize(blocks []Block) string {
case Embed:
lines = append(lines, "![["+b.Content+"]]")

case Bookmark:
title, url := ExtractBookmark(b.Content)
if title == "" {
lines = append(lines, url)
} else {
lines = append(lines, "["+title+"]("+url+")")
}

case Table:
lines = append(lines, serializeTable(b.Content)...)

Expand Down
15 changes: 15 additions & 0 deletions internal/block/serialize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,18 @@ func TestSerializeRoundTrip(t *testing.T) {
name: "table with no body rows",
md: "| Name | Age |\n| ---- | --- |",
},
{
name: "bookmark titled link",
md: "[Example](https://example.com)",
},
{
name: "bookmark bare url",
md: "https://example.com",
},
{
name: "bookmark between paragraphs",
md: "above\n\n[Site](https://site.io)\n\nbelow",
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -195,6 +207,9 @@ func TestSerializeIdempotent(t *testing.T) {
"API\n: Application Programming Interface\n\nSDK\n: Software Development Kit",
"![[notebook/note]]",
"text\n\n![[ref]]\n\nmore text",
"[Example](https://example.com)",
"https://example.com",
"text\n\n[Docs](https://docs.io)\n\nmore",
"| A | B |\n| --- | --- |\n| 1 | 2 |",
"| Left | Center | Right |\n| :--- | :----: | ----: |\n| a | b | c |",
}
Expand Down
36 changes: 35 additions & 1 deletion internal/editor/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ func (m *Model) navigateDown() {

// isMultiLine returns true if the block type allows multi-line content.
func isMultiLine(bt block.BlockType) bool {
return bt == block.Paragraph || bt == block.CodeBlock || bt == block.Quote || bt == block.DefinitionList || bt == block.Callout || bt == block.Table
return bt == block.Paragraph || bt == block.CodeBlock || bt == block.Quote || bt == block.DefinitionList || bt == block.Callout || bt == block.Table || bt == block.Bookmark
}

// insertBlockBefore inserts a new block before the given index, creates a
Expand Down Expand Up @@ -1169,6 +1169,33 @@ func (m *Model) handleEnter() {
}
}

// Bookmark: two-line internal structure (title on line 0, URL on line 1).
// Enter on the title line moves the cursor to the URL line.
// Enter on the URL line exits the block to a new paragraph below.
if bt == block.Bookmark {
trimmed := strings.TrimSpace(strings.ReplaceAll(content, "\n", ""))
if trimmed == "" || trimmed == "https://" || trimmed == "http://" {
m.blocks[m.active].Type = block.Paragraph
m.blocks[m.active].Content = ""
newTA := newTextareaForBlock(m.blocks[m.active], m.width)
m.cursorCmd = newTA.Focus()
m.textareas[m.active] = newTA
return
}
if ta.Line() == 0 {
lines := strings.Split(content, "\n")
if len(lines) < 2 {
ta.SetValue(content + "\n")
}
ta.CursorDown()
ta.CursorEnd()
m.cursorCmd = ta.Focus()
return
}
m.insertBlockAfter(m.active, block.Block{Type: block.Paragraph})
return
}

// Code block / Quote / Callout: Enter inserts a newline within the block.
// On an empty last line, exit the block by trimming the empty line
// and inserting a new paragraph below.
Expand Down Expand Up @@ -1531,6 +1558,13 @@ func (m *Model) applyPaletteSelection(bt block.BlockType) {
m.textareas[m.active].MoveToBegin()
m.cursorCmd = m.textareas[m.active].Focus()
}
// Bookmark: seed with "Title\nhttps://" so the user types the title
// first then arrows down to fill in the URL.
if bt == block.Bookmark && !strings.Contains(m.textareas[m.active].Value(), "\n") {
m.textareas[m.active].SetValue("\nhttps://")
m.textareas[m.active].MoveToBegin()
m.cursorCmd = m.textareas[m.active].Focus()
}
// Open the note picker for embed blocks so the user can browse targets.
if bt == block.Embed && m.config.ListEmbedTargets != nil {
targets := m.config.ListEmbedTargets()
Expand Down
1 change: 1 addition & 0 deletions internal/editor/palette.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ var paletteItemDefs = []struct {
{"!", "Callout", block.Callout},
{"\u2014", "Divider", block.Divider},
{"\u2197", "Embed", block.Embed},
{"\u29c9", "Bookmark", block.Bookmark},
}

// defaultPaletteItems returns the full list of block-type entries.
Expand Down
96 changes: 96 additions & 0 deletions internal/editor/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,39 @@ func (m Model) renderActiveBlock(idx int, b block.Block, _ string) string {
rendered = prefixFirstLine(prefix, taView)
}

case block.Bookmark:
bs := th.Blocks.Bookmark
titleColor := resolveColor(bs.TitleColor, th.Accent)
urlColor := resolveColor(bs.URLColor, th.Muted)
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(titleColor))
urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(urlColor)).Underline(true)
rawLines := strings.Split(ta.Value(), "\n")
title := ""
url := ""
if len(rawLines) > 0 {
title = rawLines[0]
}
if len(rawLines) > 1 {
url = rawLines[1]
}
cursorOnTitle := ta.Line() == 0
var titleLine, urlLine string
if title == "" {
titleLine = renderPlaceholder("Bookmark title", cursorOnTitle)
} else if cursorOnTitle {
titleLine = renderLabelCursor(title, ta.LineInfo().ColumnOffset, titleStyle)
} else {
titleLine = titleStyle.Render(title)
}
if url == "" {
urlLine = renderPlaceholder("https://", !cursorOnTitle)
} else if !cursorOnTitle {
urlLine = renderLabelCursor(url, ta.LineInfo().ColumnOffset, urlStyle)
} else {
urlLine = urlStyle.Render(url)
}
rendered = titleLine + "\n" + urlLine

case block.Callout:
cs := th.Blocks.Callout
variantColor := calloutVariantColor(cs, b.Variant)
Expand Down Expand Up @@ -502,6 +535,63 @@ func (m Model) renderActiveBlock(idx int, b block.Block, _ string) string {
return strings.Join(lines, "\n")
}

// renderBookmarkCard renders a bookmark as a bordered card with title on
// top and URL below. width is the content column width.
func renderBookmarkCard(content string, width int, hovered bool) string {
th := theme.Current()
bs := th.Blocks.Bookmark
borderColor := resolveColor(bs.Border, th.Border)
titleColor := resolveColor(bs.TitleColor, th.Accent)
urlColor := resolveColor(bs.URLColor, th.Muted)

title, url := block.ExtractBookmark(content)
if title == "" && url == "" {
title = "Bookmark"
}
if title == "" {
title = url
}

innerW := width - 4
if innerW < 10 {
innerW = 10
}
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(titleColor))
urlStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(urlColor))
if hovered {
urlStyle = urlStyle.Underline(true)
titleStyle = titleStyle.Underline(true)
}
bc := lipgloss.NewStyle().Foreground(lipgloss.Color(borderColor))

titleR := []rune(title)
if len(titleR) > innerW {
titleR = append(titleR[:innerW-1], '…')
}
urlR := []rune(url)
if len(urlR) > innerW {
urlR = append(urlR[:innerW-1], '…')
}
titleLine := titleStyle.Render(string(titleR))
urlLine := urlStyle.Render(string(urlR))

titlePad := innerW - len([]rune(string(titleR)))
if titlePad < 0 {
titlePad = 0
}
urlPad := innerW - len([]rune(string(urlR)))
if urlPad < 0 {
urlPad = 0
}

top := bc.Render("╭" + strings.Repeat("─", innerW+2) + "╮")
bottom := bc.Render("╰" + strings.Repeat("─", innerW+2) + "╯")
bar := bc.Render("│")
titleRow := bar + " " + titleLine + strings.Repeat(" ", titlePad) + " " + bar
urlRow := bar + " " + urlLine + strings.Repeat(" ", urlPad) + " " + bar
return strings.Join([]string{top, titleRow, urlRow, bottom}, "\n")
}

// renderCodeBox renders code in a bordered box with the label always in the
// top border. labelAlign controls horizontal placement: "left", "center", or
// "right" (default "left"). The label is pre-styled by the caller.
Expand Down Expand Up @@ -896,6 +986,9 @@ func renderInactiveBlock(b block.Block, content string, width int, wordWrap bool
Render(icon + wrapped)
}

case block.Bookmark:
rendered = renderBookmarkCard(content, contentWidth, false)

case block.Table:
tableWidth := width - gutterWidth
if tableWidth < 10 {
Expand Down Expand Up @@ -1120,6 +1213,9 @@ func renderViewBlock(b block.Block, content string, width int, wordWrap bool, bl
rendered = style.Render(icon + wrapped)
}

case block.Bookmark:
rendered = renderBookmarkCard(content, contentWidth, hovered)

case block.Table:
rendered = renderTableGrid(content, contentWidth, th.Border, th.Blocks.Table.HeaderBold, true)

Expand Down
Loading
Loading