diff --git a/internal/block/block.go b/internal/block/block.go index 5f61223..5c6ad71 100644 --- a/internal/block/block.go +++ b/internal/block/block.go @@ -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. @@ -62,6 +63,8 @@ func (bt BlockType) String() string { return "Table" case Kanban: return "Kanban" + case Bookmark: + return "Bookmark" default: return "Unknown" } @@ -110,6 +113,8 @@ func (bt BlockType) Short() string { return "tb" case Kanban: return "kb" + case Bookmark: + return "bm" default: return "?" } @@ -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 diff --git a/internal/block/parse.go b/internal/block/parse.go index 8f806f7..db15494 100644 --- a/internal/block/parse.go +++ b/internal/block/parse.go @@ -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) { @@ -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 @@ -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) } diff --git a/internal/block/parse_test.go b/internal/block/parse_test.go index 94ba836..e1e514c 100644 --- a/internal/block/parse_test.go +++ b/internal/block/parse_test.go @@ -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 { @@ -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) diff --git a/internal/block/serialize.go b/internal/block/serialize.go index d44b21e..965eb99 100644 --- a/internal/block/serialize.go +++ b/internal/block/serialize.go @@ -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)...) diff --git a/internal/block/serialize_test.go b/internal/block/serialize_test.go index 099170c..7e45d0d 100644 --- a/internal/block/serialize_test.go +++ b/internal/block/serialize_test.go @@ -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 { @@ -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 |", } diff --git a/internal/editor/editor.go b/internal/editor/editor.go index a5f3c00..038f548 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -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 @@ -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. @@ -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() diff --git a/internal/editor/palette.go b/internal/editor/palette.go index 515a025..0a8413b 100644 --- a/internal/editor/palette.go +++ b/internal/editor/palette.go @@ -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. diff --git a/internal/editor/render.go b/internal/editor/render.go index 13e0548..3add848 100644 --- a/internal/editor/render.go +++ b/internal/editor/render.go @@ -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) @@ -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. @@ -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 { @@ -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) diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 2bdcd6d..75987e5 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -118,6 +118,13 @@ type TableStyle struct { HeaderBold bool // whether header row is rendered bold } +// BookmarkStyle controls bookmark card rendering. +type BookmarkStyle struct { + Border string // hex border color; "" means theme.Border + TitleColor string // hex title color; "" means theme.Accent + URLColor string // hex url color; "" means theme.Muted +} + // BlockStyles groups all per-block-type formatting. type BlockStyles struct { Heading1 HeadingStyle @@ -133,6 +140,7 @@ type BlockStyles struct { Definition DefinitionStyle Embed EmbedStyle Table TableStyle + Bookmark BookmarkStyle } // DefaultBlockStyles returns the baseline block styles that match the original