diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go index 4d107469f..f6636309a 100644 --- a/internal/markdown/markdown.go +++ b/internal/markdown/markdown.go @@ -119,6 +119,26 @@ func (s *service) parse(content []byte) (gast.Node, error) { return doc, nil } +func isTagNodeInLinkOrImage(n gast.Node) bool { + for parent := n.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Kind() { + case gast.KindLink, gast.KindImage: + return true + default: + // Keep walking ancestors. + } + } + return false +} + +func asMemoTagNode(n gast.Node) (*mast.TagNode, bool) { + tagNode, ok := n.(*mast.TagNode) + if !ok || isTagNodeInLinkOrImage(n) { + return nil, false + } + return tagNode, true +} + // ExtractTags returns all #tags found in content. func (s *service) ExtractTags(content []byte) ([]string, error) { root, err := s.parse(content) @@ -134,8 +154,7 @@ func (s *service) ExtractTags(content []byte) ([]string, error) { return gast.WalkContinue, nil } - // Check for custom TagNode - if tagNode, ok := n.(*mast.TagNode); ok { + if tagNode, ok := asMemoTagNode(n); ok { tags = append(tags, string(tagNode.Tag)) } @@ -354,8 +373,7 @@ func (s *service) ExtractAll(content []byte) (*ExtractedData, error) { return gast.WalkContinue, nil } - // Extract tags - if tagNode, ok := n.(*mast.TagNode); ok { + if tagNode, ok := asMemoTagNode(n); ok { data.Tags = append(data.Tags, string(tagNode.Tag)) } if mentionNode, ok := n.(*mast.MentionNode); ok { @@ -416,8 +434,7 @@ func (s *service) RenameTag(content []byte, oldTag, newTag string) (string, erro return gast.WalkContinue, nil } - // Check for custom TagNode and rename if it matches - if tagNode, ok := n.(*mast.TagNode); ok { + if tagNode, ok := asMemoTagNode(n); ok { if string(tagNode.Tag) == oldTag { tagNode.Tag = []byte(newTag) } diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go index 3e6a1fc86..6ac1778ff 100644 --- a/internal/markdown/markdown_test.go +++ b/internal/markdown/markdown_test.go @@ -349,6 +349,20 @@ func TestExtractAllMentions(t *testing.T) { assert.ElementsMatch(t, []string{"tag"}, data.Tags) } +func TestExtractAllSkipsTagsInsideLinks(t *testing.T) { + svc := NewService(WithTagExtension()) + + data, err := svc.ExtractAll([]byte( + "[release #notes](https://example.com/releases#release-notes)\n\n" + + "![preview #image](https://example.com/image#preview)\n\n" + + "Outside #memo-tag", + )) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"memo-tag"}, data.Tags) + assert.True(t, data.Property.HasLink) +} + func TestExtractTags(t *testing.T) { tests := []struct { name string @@ -410,6 +424,30 @@ func TestExtractTags(t *testing.T) { withExt: true, expected: []string{"todo", "done"}, }, + { + name: "autolink URL fragment not tag", + content: "https://github.com/dmtrKovalenko/fff#pi-agent-extension\n\nProject #memo-tag", + withExt: true, + expected: []string{"memo-tag"}, + }, + { + name: "markdown link text and fragment not tags", + content: "[release #notes](https://example.com/releases#release-notes) Outside #memo-tag", + withExt: true, + expected: []string{"memo-tag"}, + }, + { + name: "reference link text and fragment not tags", + content: "[reference #anchor][docs]\n\n[docs]: https://example.com/docs#reference-anchor\n\nOutside #memo-tag", + withExt: true, + expected: []string{"memo-tag"}, + }, + { + name: "image alt text and fragment not tags", + content: "![preview #image](https://example.com/image#preview)\n\nOutside #memo-tag", + withExt: true, + expected: []string{"memo-tag"}, + }, { name: "no extension enabled", content: "Text with #tag", @@ -470,6 +508,19 @@ func TestExtractTags(t *testing.T) { } } +func TestRenameTagSkipsTagsInsideLinks(t *testing.T) { + svc := NewService(WithTagExtension()) + + result, err := svc.RenameTag( + []byte("[release #notes](https://example.com/releases#release-notes)\n\nOutside #notes"), + "notes", + "done", + ) + require.NoError(t, err) + + assert.Equal(t, "[release #notes](https://example.com/releases#release-notes)\n\nOutside #done", result) +} + func TestUniquePreserveCase(t *testing.T) { tests := []struct { name string diff --git a/web/src/utils/remark-plugins/remark-tag.ts b/web/src/utils/remark-plugins/remark-tag.ts index 24a0efbcb..96f11408e 100644 --- a/web/src/utils/remark-plugins/remark-tag.ts +++ b/web/src/utils/remark-plugins/remark-tag.ts @@ -1,6 +1,5 @@ import type { Root, Text } from "mdast"; import type { Node as UnistNode } from "unist"; -import { visit } from "unist-util-visit"; import type { TagNode, TagNodeData } from "@/types/markdown"; const MAX_TAG_LENGTH = 100; @@ -81,17 +80,27 @@ function createTagNode(tagValue: string): TagNode { } as TagNode; } -export const remarkTag = () => { - return (tree: Root) => { - visit(tree, (node, index, parent) => { - if (node.type !== "text" || !parent || index === null) return; +type ParentNode = UnistNode & { children: UnistNode[] }; - const textNode = node as Text; - const text = textNode.value; - const segments = parseTagsFromText(text); +function isParentNode(node: UnistNode): node is ParentNode { + return Array.isArray((node as { children?: unknown }).children); +} + +function isLinkNode(node: UnistNode): boolean { + return node.type === "link" || node.type === "linkReference"; +} + +function transformTagTextNodes(parent: ParentNode, insideLink: boolean): void { + for (let index = 0; index < parent.children.length; index++) { + const child = parent.children[index]; + const childInsideLink = insideLink || isLinkNode(child); + + if (child.type === "text" && !childInsideLink) { + const textNode = child as Text; + const segments = parseTagsFromText(textNode.value); if (segments.every((seg) => seg.type === "text")) { - return; + continue; } const newNodes = segments.map((segment) => { @@ -104,9 +113,19 @@ export const remarkTag = () => { } as Text; }); - if (typeof index === "number" && parent) { - (parent.children as UnistNode[]).splice(index, 1, ...(newNodes as UnistNode[])); - } - }); + parent.children.splice(index, 1, ...(newNodes as UnistNode[])); + index += newNodes.length - 1; + continue; + } + + if (isParentNode(child)) { + transformTagTextNodes(child, childInsideLink); + } + } +} + +export const remarkTag = () => { + return (tree: Root) => { + transformTagTextNodes(tree as ParentNode, false); }; }; diff --git a/web/tests/remark-tag.test.tsx b/web/tests/remark-tag.test.tsx new file mode 100644 index 000000000..c4d26c90f --- /dev/null +++ b/web/tests/remark-tag.test.tsx @@ -0,0 +1,54 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vitest"; +import { remarkTag } from "@/utils/remark-plugins/remark-tag"; + +const renderMarkdown = (content: string): string => + renderToStaticMarkup( + + {content} + , + ); + +describe("remarkTag", () => { + it("does not turn URL fragments inside autolinks into tags", () => { + const html = renderMarkdown("https://github.com/dmtrKovalenko/fff#pi-agent-extension\n\nProject #memo-tag"); + + expect(html).toContain('href="https://github.com/dmtrKovalenko/fff#pi-agent-extension"'); + expect(html).not.toContain('data-tag="pi-agent-extension"'); + expect(html).toContain('data-tag="memo-tag"'); + }); + + it("does not turn link text or reference link fragments into tags", () => { + const html = renderMarkdown( + [ + "[release #notes](https://example.com/releases#release-notes)", + "[**section #heading**](https://example.com/docs#section-heading)", + "![preview #image](https://example.com/image#preview)", + "[reference #anchor][docs]", + "", + "[docs]: https://example.com/docs#reference-anchor", + "", + "Outside #memo-tag", + ].join("\n"), + ); + + expect(html).not.toContain('data-tag="notes"'); + expect(html).not.toContain('data-tag="heading"'); + expect(html).not.toContain('data-tag="image"'); + expect(html).not.toContain('data-tag="anchor"'); + expect(html).not.toContain('data-tag="release-notes"'); + expect(html).not.toContain('data-tag="section-heading"'); + expect(html).not.toContain('data-tag="preview"'); + expect(html).not.toContain('data-tag="reference-anchor"'); + expect(html).toContain('data-tag="memo-tag"'); + }); + + it("continues to turn formatted text outside links into tags", () => { + const html = renderMarkdown("**#urgent** and _#later_"); + + expect(html).toContain('data-tag="urgent"'); + expect(html).toContain('data-tag="later"'); + }); +});