fix(markdown): ignore tags inside links
This commit is contained in:
@@ -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" +
|
||||
"\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: "\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)",
|
||||
"",
|
||||
"[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"');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user