Mirror the #tag mark so the TipTap editor styles @username while typing and on load, matching the read-only view. A shared mention grammar (utils/mention-grammar.ts) feeds both the editor tokenizer and the read-only remark renderer so they can't drift. Styling only -- no autocomplete dropdown (ListUsers is admin-only and there is no user-search RPC). Bare emails still autolink to mailto: and are never treated as mentions: the tokenizer's start() skips email-glued @s so marked's GFM autolinker keeps seeing the whole address.
96 lines
4.5 KiB
TypeScript
96 lines
4.5 KiB
TypeScript
import type { JSONContent } from "@tiptap/core";
|
|
import { describe, expect, it } from "vitest";
|
|
import { parseMarkdown, roundTripMarkdown } from "@/components/MemoEditor/Editor/markdownCodec";
|
|
|
|
function firstParagraphChildren(markdown: string) {
|
|
return parseMarkdown(markdown).content?.[0]?.content ?? [];
|
|
}
|
|
|
|
function hasMentionMark(node: JSONContent) {
|
|
return (node.marks ?? []).some((m) => m.type === "mention");
|
|
}
|
|
|
|
describe("Mention mark", () => {
|
|
it("parses @username into mention-marked text", () => {
|
|
const mentioned = firstParagraphChildren("hey @alice there").find(hasMentionMark);
|
|
expect(mentioned).toMatchObject({ type: "text", text: "@alice", marks: [{ type: "mention", attrs: { username: "alice" } }] });
|
|
});
|
|
|
|
it("serializes mention-marked text back to @username verbatim", () => {
|
|
expect(roundTripMarkdown("ping @bob now").trim()).toBe("ping @bob now");
|
|
});
|
|
|
|
it("preserves username case in the editor model (no lowercasing)", () => {
|
|
const mentioned = firstParagraphChildren("@Alice").find(hasMentionMark);
|
|
expect(mentioned).toMatchObject({ text: "@Alice", marks: [{ type: "mention", attrs: { username: "Alice" } }] });
|
|
expect(roundTripMarkdown("@Alice").trim()).toBe("@Alice");
|
|
});
|
|
|
|
it("supports hyphenated usernames", () => {
|
|
const username = firstParagraphChildren("@jane-doe ok")
|
|
.find(hasMentionMark)
|
|
?.marks?.find((m) => m.type === "mention")?.attrs?.username;
|
|
expect(username).toBe("jane-doe");
|
|
expect(roundTripMarkdown("@jane-doe ok").trim()).toBe("@jane-doe ok");
|
|
});
|
|
|
|
it("autolinks a bare email to mailto and never treats it as a mention", () => {
|
|
// The `@` in an email must not become a mention, and the email itself must
|
|
// still autolink to a mailto: link (GFM, same as the read-only view) — for
|
|
// a standalone email and one mid-sentence alike. The mention tokenizer must
|
|
// stay out of the email's way rather than splitting the text at its `@`.
|
|
for (const input of ["support@example.com", "mail support@example.com please"]) {
|
|
expect(firstParagraphChildren(input).some(hasMentionMark)).toBe(false);
|
|
expect(roundTripMarkdown(input)).toContain("[support@example.com](mailto:support@example.com)");
|
|
}
|
|
});
|
|
|
|
it("stops at a mention-char boundary: @a@b is mention `a` then literal `@b`", () => {
|
|
const mentioned = firstParagraphChildren("@a@b").filter(hasMentionMark);
|
|
expect(mentioned).toHaveLength(1);
|
|
expect(mentioned[0]).toMatchObject({ text: "@a", marks: [{ type: "mention", attrs: { username: "a" } }] });
|
|
expect(roundTripMarkdown("@a@b").trim()).toBe("@a@b");
|
|
});
|
|
|
|
it("carries both bold and mention marks on **@bob** and round-trips", () => {
|
|
const input = "**hi @bob**";
|
|
expect(roundTripMarkdown(input).trim()).toBe(input);
|
|
const markTypes = (firstParagraphChildren(input).find((n) => n.text === "@bob")?.marks ?? []).map((m) => m.type);
|
|
expect(markTypes).toContain("bold");
|
|
expect(markTypes).toContain("mention");
|
|
});
|
|
|
|
it("keeps a mention inside a link label and round-trips byte-identically", () => {
|
|
const input = "[ping @bob](https://example.com)";
|
|
expect(roundTripMarkdown(input).trim()).toBe(input);
|
|
});
|
|
|
|
it("does not create a mention from an all-hyphen run", () => {
|
|
const children = firstParagraphChildren("@--- nope");
|
|
expect(children.some(hasMentionMark)).toBe(false);
|
|
expect(roundTripMarkdown("@--- nope").trim()).toBe("@--- nope");
|
|
});
|
|
|
|
it("caps the username at 32 chars, leaving the overflow as plain text", () => {
|
|
const input = `@${"a".repeat(40)}`;
|
|
const username = firstParagraphChildren(input)
|
|
.find(hasMentionMark)
|
|
?.marks?.find((m) => m.type === "mention")?.attrs?.username;
|
|
expect(username).toBe("a".repeat(32));
|
|
expect(roundTripMarkdown(input).trim()).toBe(input);
|
|
});
|
|
|
|
it("marks the mentions, autolinks the email, in a mixed line", () => {
|
|
// Mentions stay mentions (editor preserves case, unlike backend extraction
|
|
// which lowercases), the email autolinks to mailto, and the two never
|
|
// collide. Based on the backend's canonical case (markdown_test.go).
|
|
const input = "Hi @Alice and @bob. Email support@example.com today.";
|
|
const usernames = firstParagraphChildren(input)
|
|
.filter(hasMentionMark)
|
|
.map((n) => n.marks?.find((m) => m.type === "mention")?.attrs?.username);
|
|
expect(usernames).toEqual(["Alice", "bob"]);
|
|
|
|
const out = roundTripMarkdown(input).trim();
|
|
expect(out).toBe("Hi @Alice and @bob. Email [support@example.com](mailto:support@example.com) today.");
|
|
});
|
|
});
|