From ba5a0af7995fc461898e51a5ebdb604bca9c9cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=92=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Tue, 14 Jul 2026 09:39:35 +0300 Subject: [PATCH 1/2] fix(tags): require boundaries before tags --- internal/markdown/markdown_test.go | 6 ++++++ internal/markdown/parser/tag.go | 9 +++++++++ web/src/utils/remark-plugins/remark-tag.ts | 6 +++++- web/tests/remark-tag.test.tsx | 12 ++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go index 6ac1778ffad8d..d35258303e070 100644 --- a/internal/markdown/markdown_test.go +++ b/internal/markdown/markdown_test.go @@ -382,6 +382,12 @@ func TestExtractTags(t *testing.T) { withExt: true, expected: []string{"tag"}, }, + { + name: "tags require a boundary before the hash", + content: "word#tag 1#numeric (#parentheses) [#brackets] \"#quotes\" +#symbol", + withExt: true, + expected: []string{"parentheses", "brackets", "quotes", "symbol"}, + }, { name: "multiple tags", content: "Text with #tag1 and #tag2", diff --git a/internal/markdown/parser/tag.go b/internal/markdown/parser/tag.go index 339e65661e534..db964017be354 100644 --- a/internal/markdown/parser/tag.go +++ b/internal/markdown/parser/tag.go @@ -70,6 +70,10 @@ func isValidTagRune(r rune) bool { return false } +func isTagBoundary(r rune) bool { + return unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) +} + // Parse parses #tag syntax using Unicode-aware validation. // Tags support international characters and follow these rules: // - Must start with # followed by valid tag characters @@ -84,6 +88,11 @@ func (*tagParser) Parse(_ gast.Node, block text.Reader, _ parser.Context) gast.N return nil } + prev := block.PrecendingCharacter() + if prev != '\n' && !isTagBoundary(prev) { + return nil + } + // Check if it's a heading (## or space after #) if len(line) > 1 { if line[1] == '#' { diff --git a/web/src/utils/remark-plugins/remark-tag.ts b/web/src/utils/remark-plugins/remark-tag.ts index 0271f1771980b..b2b06bfb5d4a5 100644 --- a/web/src/utils/remark-plugins/remark-tag.ts +++ b/web/src/utils/remark-plugins/remark-tag.ts @@ -20,6 +20,10 @@ function isAsciiPunctuation(char: string): boolean { ); } +function isTagBoundary(char: string): boolean { + return char === "" || /^(?:\p{White_Space}|\p{P}|\p{S})$/u.test(char); +} + /** * Apply CommonMark backslash-unescaping to a raw source slice, tracking which * resulting characters came from an escape. `\#` yields a `#` flagged escaped, @@ -60,7 +64,7 @@ function parseSegments(chars: string[], escaped: boolean[]): Segment[] { const prevChar = i > 0 ? chars[i - 1] : ""; const nextChar = chars[i + 1]; - if (prevChar === "#" || nextChar === "#" || nextChar === " ") { + if (prevChar === "#" || !isTagBoundary(prevChar) || nextChar === "#" || nextChar === " ") { segments.push({ type: "text", value: chars[i] }); i++; continue; diff --git a/web/tests/remark-tag.test.tsx b/web/tests/remark-tag.test.tsx index b3fa3de29a681..6a7a34342359b 100644 --- a/web/tests/remark-tag.test.tsx +++ b/web/tests/remark-tag.test.tsx @@ -20,6 +20,18 @@ describe("remarkTag", () => { expect(html).toContain('data-tag="memo-tag"'); }); + it("requires a boundary before tags", () => { + const html = renderMarkdown('word#tag 1#numeric #standalone (#parentheses) [#brackets] "#quotes" +#symbol'); + + expect(html).not.toContain('data-tag="tag"'); + expect(html).not.toContain('data-tag="numeric"'); + expect(html).toContain('data-tag="standalone"'); + expect(html).toContain('data-tag="parentheses"'); + expect(html).toContain('data-tag="brackets"'); + expect(html).toContain('data-tag="quotes"'); + expect(html).toContain('data-tag="symbol"'); + }); + it("does not turn link text or reference link fragments into tags", () => { const html = renderMarkdown( [ From fddc8a7ea6574a946f5e7d97ba68288013ff6388 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=92=D0=B5=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B2?= Date: Tue, 14 Jul 2026 10:22:13 +0300 Subject: [PATCH 2/2] fix(tags): reject repeated hash boundaries --- internal/markdown/markdown_test.go | 2 +- internal/markdown/parser/tag.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go index d35258303e070..9f25e807ecfb7 100644 --- a/internal/markdown/markdown_test.go +++ b/internal/markdown/markdown_test.go @@ -384,7 +384,7 @@ func TestExtractTags(t *testing.T) { }, { name: "tags require a boundary before the hash", - content: "word#tag 1#numeric (#parentheses) [#brackets] \"#quotes\" +#symbol", + content: "word#tag 1#numeric word##tag ##tag (#parentheses) [#brackets] \"#quotes\" +#symbol", withExt: true, expected: []string{"parentheses", "brackets", "quotes", "symbol"}, }, diff --git a/internal/markdown/parser/tag.go b/internal/markdown/parser/tag.go index db964017be354..67a791b135b1e 100644 --- a/internal/markdown/parser/tag.go +++ b/internal/markdown/parser/tag.go @@ -71,7 +71,7 @@ func isValidTagRune(r rune) bool { } func isTagBoundary(r rune) bool { - return unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r) + return r != '#' && (unicode.IsSpace(r) || unicode.IsPunct(r) || unicode.IsSymbol(r)) } // Parse parses #tag syntax using Unicode-aware validation.