fix(markdown): ignore tags inside links

pull/6017/head
boojack 1 month ago
parent 5e71c0a737
commit a50ce09e81

@ -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)
}

@ -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

@ -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);
};
};

@ -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(
<ReactMarkdown remarkPlugins={[remarkGfm, remarkTag]}>
{content}
</ReactMarkdown>,
);
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"');
});
});
Loading…
Cancel
Save