Memos layers #tag syntax on top of CommonMark, but there was no way to write a literal "#NAS" — it always became a clickable tag (issue #6035). Handle escapes lexically, the way TipTap/prosemirror-markdown do, with no "escaped tag" node in the document model: - Parse: marked's built-in backslash escape already turns "\#NAS" into plain text; the read-only renderer (remark-tag) now honors the same escape by lexing from the original source slice and falling back to the prior value-based parse when it can't faithfully reconstruct the text. - Serialize: a tag-aware Markdown extension (tagMarkdown.ts) backslash- escapes a "#" that would re-parse into a tag, skipping links and code where tags never form. - The editor's #tag lexing moves to the canonical @tiptap/markdown markdownTokenizer; tag-in-link skipping moves to a tree pass so the editor and renderer agree. The capped tag-run grammar is consolidated into utils/tag-grammar.ts (TAG_RUN), shared by the tokenizer, the serialize-escape, and the renderer so they can't drift. README documents why the markdown manager is worked around in three places.
81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
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"');
|
|
});
|
|
|
|
it("does not turn a backslash-escaped \\#tag into a tag, but still tags an unescaped one", () => {
|
|
const html = renderMarkdown("\\#NAS is my server and a #real tag");
|
|
|
|
// Escaped: rendered as the literal text "#NAS", never a tag pill.
|
|
expect(html).not.toContain('data-tag="NAS"');
|
|
expect(html).toContain("#NAS");
|
|
// Unescaped neighbour is unaffected.
|
|
expect(html).toContain('data-tag="real"');
|
|
});
|
|
|
|
it("escapes only the marked hash when escaped and unescaped tags share a node", () => {
|
|
const html = renderMarkdown("\\#first then #second");
|
|
|
|
expect(html).not.toContain('data-tag="first"');
|
|
expect(html).toContain("#first");
|
|
expect(html).toContain('data-tag="second"');
|
|
});
|
|
|
|
it("still tags a hash that shares a text node with an entity reference", () => {
|
|
// The source slice ("...&...") differs from the decoded value, so the
|
|
// escape-aware path bows out and the tag is detected the original way.
|
|
const html = renderMarkdown("Tom & Jerry #cartoon");
|
|
|
|
expect(html).toContain('data-tag="cartoon"');
|
|
expect(html).toContain("Tom & Jerry");
|
|
});
|
|
});
|