246 lines
14 KiB
TypeScript
246 lines
14 KiB
TypeScript
import { markdown } from "@codemirror/lang-markdown";
|
||
import { forceParsing } from "@codemirror/language";
|
||
import { EditorState } from "@codemirror/state";
|
||
import { EditorView } from "@codemirror/view";
|
||
import { describe, expect, it } from "vitest";
|
||
import { findMarkdownMentionMatches, findMarkdownTagMatches } from "@/components/MemoEditor/Editor/markdownTagRanges";
|
||
import { tagMentionDecorations } from "@/components/MemoEditor/Editor/tagMentionDecorations";
|
||
import { memoMarkdownExtensions } from "@/utils/memo-markdown-extension";
|
||
|
||
function countClass(doc: string, cls: string): number {
|
||
const view = new EditorView({
|
||
state: EditorState.create({ doc, extensions: [markdown({ extensions: memoMarkdownExtensions }), tagMentionDecorations] }),
|
||
parent: document.body,
|
||
});
|
||
forceParsing(view, view.state.doc.length, 1000);
|
||
const n = view.dom.querySelectorAll(`.${cls}`).length;
|
||
view.destroy();
|
||
return n;
|
||
}
|
||
|
||
describe("tag/mention decorations", () => {
|
||
it("decorates #tags", () => expect(countClass("a #todo and #work/sub b", "cm-memo-tag")).toBe(2));
|
||
it("does not require a left boundary", () => expect(countClass("hello#tag 中文#标签", "cm-memo-tag")).toBe(2));
|
||
it("keeps apostrophes only inside XID words", () => {
|
||
const source = "#tag's #сім'я #O’Brien '#quoted' #users' #'missing";
|
||
const state = EditorState.create({ doc: source, extensions: [markdown({ extensions: memoMarkdownExtensions })] });
|
||
expect(findMarkdownTagMatches(state, 0, source.length).map(({ source, value }) => ({ source, value }))).toEqual([
|
||
{ source: "tag's", value: "tag's" },
|
||
{ source: "сім'я", value: "сім'я" },
|
||
{ source: "O’Brien", value: "O’Brien" },
|
||
{ source: "quoted", value: "quoted" },
|
||
{ source: "users", value: "users" },
|
||
]);
|
||
});
|
||
it("uses emitted spans for ignored characters and emoji", () =>
|
||
expect(countClass("#AB #́foo #foo/́bar #👩💻 #️⃣ ##️⃣", "cm-memo-tag")).toBe(5));
|
||
it("keeps maximal valid prefixes and adjacent tags", () =>
|
||
expect(countClass("#book/ #book//fiction #first#second ##tag", "cm-memo-tag")).toBe(5));
|
||
it("does not cross formatting syntax boundaries", () => expect(countClass("#_foo_ **#urgent**", "cm-memo-tag")).toBe(1));
|
||
it("does not decorate tags in opaque Markdown source", () =>
|
||
expect(
|
||
countClass("`#code` [#label](https://example.com/#link) https://example.com/#url \\#escaped #entity #ok", "cm-memo-tag"),
|
||
).toBe(1));
|
||
it("keeps unknown character-reference shapes in literal tag source", () =>
|
||
expect(countClass("#R&bogus;D #Q&&bogus;D", "cm-memo-tag")).toBe(2));
|
||
it("supplements decoded source boundaries hidden by broad parser nodes", () => {
|
||
expect(countClass("+http://x.m/#u##v +http://x.m/#w\\|", "cm-memo-tag")).toBe(3);
|
||
expect(countClass("#a∳b#c", "cm-memo-tag")).toBe(2);
|
||
});
|
||
it.each([
|
||
".https://a.b/`#code` #tail",
|
||
".https://a.b/[#link](/x) #tail",
|
||
".https://a.b/ #tail",
|
||
".https://a.b/$#math$ #tail",
|
||
])("reparses opaque syntax swallowed by a rejected URL node: %s", (source) => expect(countClass(source, "cm-memo-tag")).toBe(1));
|
||
it("projects emphasis closed inside a rejected URL node", () => {
|
||
const source = "_[http://x.m/#v_>";
|
||
const state = EditorState.create({ doc: source, extensions: [markdown({ extensions: memoMarkdownExtensions })] });
|
||
expect(findMarkdownTagMatches(state, 0, source.length)).toEqual([{ from: 13, to: 15, source: "v", value: "v" }]);
|
||
});
|
||
it("coalesces literal source across a rejected URL node", () => {
|
||
for (const [source, value] of [
|
||
["#http://x.y/", "http"],
|
||
["#www.x.y/", "www"],
|
||
["#b-www.x.y/", "b-www"],
|
||
]) {
|
||
const state = EditorState.create({ doc: source, extensions: [markdown({ extensions: memoMarkdownExtensions })] });
|
||
expect(findMarkdownTagMatches(state, 0, source.length).map((match) => match.value)).toEqual([value]);
|
||
}
|
||
});
|
||
it("treats a rejected link reference definition as ordinary text", () => {
|
||
expect(countClass("[#-]:(", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("[#use][bad]\n\n[bad]:(", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("[#foo@example.com]:(", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("[ https://example.com/#hidden]:(", "cm-memo-tag")).toBe(0);
|
||
});
|
||
it("follows CommonMark tab expansion inside blockquotes", () => {
|
||
expect(countClass(">\t #a", "cm-memo-tag")).toBe(1);
|
||
expect(countClass(" >\t #a", "cm-memo-tag")).toBe(1);
|
||
expect(countClass(" >\t #a", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("- >\t #a", "cm-memo-tag")).toBe(1);
|
||
expect(countClass(">\t #a", "cm-memo-tag")).toBe(0);
|
||
});
|
||
it.each(["- [ ] #a", "- [x] #a", "- [ ] #a\n #b"])("decorates tags in a GFM task item: %s", (source) =>
|
||
expect(countClass(source, "cm-memo-tag")).toBe(source.includes("#b") ? 2 : 1));
|
||
it("keeps GFM URLs and emails opaque in task items", () =>
|
||
expect(countClass("- [ ] https://example.com/#url #foo@example.com #tag", "cm-memo-tag")).toBe(1));
|
||
it("keeps the exact value and range before an unknown entity semicolon", () => {
|
||
const source = "#R&bogus;D";
|
||
const state = EditorState.create({ doc: source, extensions: [markdown({ extensions: memoMarkdownExtensions })] });
|
||
|
||
expect(findMarkdownTagMatches(state, 0, source.length)).toEqual([{ from: 0, to: 8, source: "R&bogus", value: "R&bogus" }]);
|
||
});
|
||
it("decorates tags inside unresolved link-like source", () =>
|
||
expect(countClass("[#tag][missing] [#other](unterminated", "cm-memo-tag")).toBe(2));
|
||
it("decorates tags in both labels when a reference does not resolve", () => {
|
||
expect(countClass("[x][#two]", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("[#one][#two]", "cm-memo-tag")).toBe(2);
|
||
expect(countClass("[x][**#two**]", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("[#x][#_foo_]", "cm-memo-tag")).toBe(1);
|
||
});
|
||
it.each([
|
||
"[#foo@example.com][missing]",
|
||
"[x][#foo@example.com]",
|
||
"[#foo@example.com](unterminated",
|
||
"![#foo@example.com][missing]",
|
||
"[x][_#foo@example.com_]",
|
||
"![x][_#foo@example.com_]",
|
||
])("keeps GFM emails opaque inside unresolved link-like source: %s", (source) => expect(countClass(source, "cm-memo-tag")).toBe(0));
|
||
it("reparses all Markdown boundaries in unresolved second labels", () => {
|
||
expect(countClass("[x][<#foo@example.com>] ![x][<https://example.com/#foo>]", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("[x][\\#escaped #real] ![x][#escaped #other]", "cm-memo-tag")).toBe(2);
|
||
expect(countClass("[x][#R&D #real]", "cm-memo-tag")).toBe(2);
|
||
expect(countClass("[x][https://example.com/#foo] ![x][https://example.com/#bar]", "cm-memo-tag")).toBe(2);
|
||
});
|
||
it.each(["\n", "\r", "\r\n"])("deduplicates a %j boundary in an unresolved second label", (lineEnding) =>
|
||
expect(countClass(`[#a][${lineEnding}]${lineEnding}${lineEnding}[#a]: /x`, "cm-memo-tag")).toBe(1));
|
||
it("keeps resolved reference and inline links opaque", () =>
|
||
expect(countClass("[#reference][known] [#inline](/path)\n\n[known]: /path", "cm-memo-tag")).toBe(0));
|
||
it.each([
|
||
"[a [#link](x) b][missing]",
|
||
"[a  b][missing]",
|
||
])("keeps a resolved child opaque inside an unresolved outer link: %s", (source) => expect(countClass(source, "cm-memo-tag")).toBe(0));
|
||
it("fails closed while a distant reference definition is not parsed yet", () => {
|
||
const source = `[#hidden][known]\n\n${"x".repeat(120_000)}\n\n[known]: /path`;
|
||
const state = EditorState.create({ doc: source, extensions: [markdown({ extensions: memoMarkdownExtensions })] });
|
||
|
||
expect(findMarkdownTagMatches(state, 0, 18)).toEqual([]);
|
||
});
|
||
it("uses CommonMark identifier normalization when resolving reference links", () => {
|
||
expect(countClass("[#hidden][ẞ]\n\n[SS]: /path", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("[#hidden][a\0b]\n\n[a<>b]: /path", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("[#visible][\u00a0ref]\n\n[ref]: /path", "cm-memo-tag")).toBe(1);
|
||
});
|
||
it("follows written-GFM URL domain boundaries", () =>
|
||
expect(
|
||
countClass("https://example.COM/#hidden https://a_b.foo.example/路径#also-hidden https://foo_bar.example/#visible", "cm-memo-tag"),
|
||
).toBe(1));
|
||
it("keeps written GFM URLs with Unicode domains opaque", () =>
|
||
expect(
|
||
countClass(
|
||
"https://點看.com/#hidden www.點看.com/#also-hidden https://a_b.點看.com/#still-hidden " +
|
||
"https://foo_bar.點看/#visible https://點看.com_/#suffix hellohttps://點看.com/#joined",
|
||
"cm-memo-tag",
|
||
),
|
||
).toBe(3));
|
||
it("uses GFM protocol and www boundaries for Unicode domains", () =>
|
||
expect(
|
||
countClass(
|
||
"1https://點看.com/#digit .https://點看.com/#punctuation ]www.點看.com/#www " +
|
||
"中https://點看.com/#unicode hellohttps://點看.com/#joined",
|
||
"cm-memo-tag",
|
||
),
|
||
).toBe(5));
|
||
it("keeps URLs blocked by unbalanced brackets tag-eligible", () =>
|
||
expect(
|
||
countClass(
|
||
"[https://點看.com/#protocol\n\n[www.點看.com/#www\n\n\\[https://點看.com/#escaped\n\n\\[www.點看.com/#escaped-www",
|
||
"cm-memo-tag",
|
||
),
|
||
).toBe(4));
|
||
it("keeps a URL later in unresolved bracket text opaque", () =>
|
||
expect(countClass("[text https://點看.com/#inside\n\n[more https://example.com/#ascii", "cm-memo-tag")).toBe(0));
|
||
it.each([
|
||
"[ https://example.com/#hidden]",
|
||
"[x https://example.com/#hidden]",
|
||
"![ https://example.com/#hidden]",
|
||
])("follows GFM URL boundaries inside unresolved bracket text: %s", (source) => expect(countClass(source, "cm-memo-tag")).toBe(0));
|
||
it("uses one explicit separator class for written URLs", () =>
|
||
expect(
|
||
countClass(
|
||
"https://點看.com/\u00a0#nbsp https://點看.com/\u2003#em-space https://點看.com/\u000b#vertical-tab " +
|
||
"https://點看.com/\u2028#line-separator https://點看.com/\u2029#paragraph-separator https://點看.com/\u0085#nel-hidden",
|
||
"cm-memo-tag",
|
||
),
|
||
).toBe(5));
|
||
it.each([
|
||
"`[` https://example.com/#hidden",
|
||
"$[$ https://example.com/#hidden",
|
||
'<span data-x="[">x</span> https://example.com/#hidden',
|
||
"<https://x.example/[> https://example.com/#hidden",
|
||
])("ignores brackets in earlier opaque syntax when classifying a URL: %s", (source) => expect(countClass(source, "cm-memo-tag")).toBe(0));
|
||
it("keeps the complete suffix of a recognized GFM URL opaque", () => {
|
||
expect(countClass("http://example.com#z", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("https://foo.example_/#visible", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("\uFEFFhttp://example.com#hidden #shown", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("before\n\n\uFEFFhttp://example.com#shown", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("\uFEFF\uFEFFhttp://example.com#shown", "cm-memo-tag")).toBe(1);
|
||
});
|
||
it.each([
|
||
"#foo/bar_baz@example.com",
|
||
"#next/item_@example.com",
|
||
"_foo@example.com #tag_",
|
||
])("follows complete GFM email ranges: %s", (doc) => expect(countClass(doc, "cm-memo-tag")).toBe(1));
|
||
it("decodes escapes while reconciling repeated and adjacent GFM email ranges", () => {
|
||
expect(countClass("#foo\\+bar@example.com #next\\_item@example.com", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("#foo\\+bar@example.com #next\\_item@example.com", "cm-memo-mention")).toBe(0);
|
||
expect(countClass("foo@bar.com@baz.example foo@bar.com+abc@def.com", "cm-memo-mention")).toBe(0);
|
||
});
|
||
it.each([
|
||
"#foo.bar@example.com",
|
||
"#foo.bar@example.com",
|
||
"#foo@example.com",
|
||
])("decodes known entities before reconciling a GFM email: %s", (source) => expect(countClass(source, "cm-memo-tag")).toBe(0));
|
||
it("keeps invalid email suffixes tag-eligible and slash-separated locals visible", () => {
|
||
expect(countClass("#foo/bar@example.com_", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("#next/item_@example.com", "cm-memo-mention")).toBe(0);
|
||
});
|
||
it("does not decorate inline or block dollar math", () =>
|
||
expect(countClass("$#inline$\n\n$$\n#block\n$$\n\n#ok", "cm-memo-tag")).toBe(1));
|
||
it("treats dollar signs after an inline opener as math delimiters even when preceded by a backslash", () => {
|
||
expect(countClass("$#math\\$", "cm-memo-tag")).toBe(0);
|
||
expect(countClass("$\\$#tag$", "cm-memo-tag")).toBe(1);
|
||
});
|
||
it("does not retry inside an unmatched dollar run", () => expect(countClass("$$#tag$", "cm-memo-tag")).toBe(1));
|
||
it.each(["$$\n#block", "$$meta\n#block"])("keeps unclosed flow math opaque: %s", (doc) => expect(countClass(doc, "cm-memo-tag")).toBe(0));
|
||
it("keeps flow math scoped to its list or blockquote", () => {
|
||
expect(countClass("> $$\n> #math\noutside #tag", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("- $$\n #math\noutside #tag", "cm-memo-tag")).toBe(1);
|
||
});
|
||
it("matches flow fence lengths and rejects dollar signs in metadata", () => {
|
||
expect(countClass("$$$\n#math\n$$$\n#ok", "cm-memo-tag")).toBe(1);
|
||
expect(countClass("$$meta$x\n#tag", "cm-memo-tag")).toBe(1);
|
||
});
|
||
it("allows flow math to interrupt a paragraph", () => expect(countClass("text\n$$\n#math", "cm-memo-tag")).toBe(0));
|
||
it("does not let same-line math hide following text", () => expect(countClass("$$#inline$$\n#ok", "cm-memo-tag")).toBe(1));
|
||
it("allows ordinary text between inline HTML tags", () => expect(countClass("<span>#inside</span>", "cm-memo-tag")).toBe(1));
|
||
it("decorates writable username references", () =>
|
||
expect(countClass("@alice @Alice-2 @1alice @a--b @123 @123-456", "cm-memo-mention")).toBe(6));
|
||
it("rejects invalid username shapes", () => expect(countClass("@-alice @alice- @álîçé", "cm-memo-mention")).toBe(0));
|
||
it("applies mention boundaries to source text", () =>
|
||
expect(countClass("hello@alice foo-@bob foo_@carol 中文@dave (@erin) @frank@grace", "cm-memo-mention")).toBe(4));
|
||
it("decorates mentions in transparent formatting", () => expect(countClass("**@Alice** ~~@bob~~ _@carol_", "cm-memo-mention")).toBe(3));
|
||
it("keeps mentions in opaque Markdown source undecorated", () =>
|
||
expect(
|
||
countClass("`@code` [@link](/x)  https://example.com/@url $@math$ \\@escaped @entity @ok", "cm-memo-mention"),
|
||
).toBe(1));
|
||
it("keeps GFM emails opaque and does not cross their source boundary", () =>
|
||
expect(countClass("@alice@example.com foo@bar.com@bob @ok", "cm-memo-mention")).toBe(1));
|
||
it("returns the exact mention source range and username", () => {
|
||
const source = "x @Alice-2.";
|
||
const state = EditorState.create({ doc: source, extensions: [markdown({ extensions: memoMarkdownExtensions })] });
|
||
|
||
expect(findMarkdownMentionMatches(state, 0, source.length)).toEqual([{ from: 2, to: 10, username: "Alice-2" }]);
|
||
});
|
||
});
|