@blocknote/core
Version:
A "Notion-style" block-based extensible text editor built on top of Prosemirror and Tiptap.
946 lines (817 loc) • 29.3 kB
text/typescript
import { afterEach, describe, expect, it } from "vite-plus/test";
import { TextSelection } from "@tiptap/pm/state";
import { Slice, Fragment } from "@tiptap/pm/model";
import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
import { findLinks, tokenizeLink } from "./helpers/linkDetector.js";
import { isAllowedUri } from "./link.js";
/**
* @vitest-environment jsdom
*/
// ============================================================================
// Helpers
// ============================================================================
/** Wrapper matching the old tokenize().map(t => t.toObject(defaultProtocol)) pattern */
function tokenizeToObjects(text: string, defaultProtocol = "http") {
return tokenizeLink(text, defaultProtocol);
}
/**
* Mirrors the isValidLinkStructure function from autolink.ts.
* A valid structure is either:
* - A single link token
* - A link token wrapped in () or []
*/
function isValidLinkStructure(
tokens: Array<{ isLink: boolean; value: string }>,
) {
if (tokens.length === 1) {
return tokens[0].isLink;
}
if (tokens.length === 3 && tokens[1].isLink) {
return ["()", "[]"].includes(tokens[0].value + tokens[2].value);
}
return false;
}
function createEditor(links?: { isValidLink?: (href: string) => boolean }) {
const editor = BlockNoteEditor.create(links ? { links } : undefined);
const div = document.createElement("div");
editor.mount(div);
return editor;
}
/**
* Insert text at the end of a block, followed by a space to trigger autolink.
* Returns the link marks found in that block afterward.
*/
function typeTextThenSpace(
editor: BlockNoteEditor<any, any, any>,
blockId: string,
text: string,
) {
editor.setTextCursorPosition(blockId, "end");
const view = editor._tiptapEditor.view;
const { from } = view.state.selection;
// Insert the text
view.dispatch(view.state.tr.insertText(text, from));
// Now insert a space to trigger autolink
const afterInsert = view.state.selection.from;
view.dispatch(view.state.tr.insertText(" ", afterInsert));
return getLinksInDocument(editor);
}
/**
* Walk the ProseMirror doc and collect all link marks with their text and href.
*/
function getLinksInDocument(editor: BlockNoteEditor<any, any, any>) {
const links: Array<{ text: string; href: string; from: number; to: number }> =
[];
const doc = editor._tiptapEditor.state.doc;
const linkType = editor._tiptapEditor.schema.marks.link;
doc.descendants((node, pos) => {
if (node.isText && node.marks.length > 0) {
const linkMark = node.marks.find((m) => m.type === linkType);
if (linkMark) {
links.push({
text: node.text || "",
href: linkMark.attrs.href,
from: pos,
to: pos + node.nodeSize,
});
}
}
});
return links;
}
// ============================================================================
// Level 1: Unit tests for findLinks() and tokenizeLink()
// ============================================================================
describe("findLinks() baseline behavior", () => {
describe("basic URL detection", () => {
it("detects https URLs", () => {
const results = findLinks("https://example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
isLink: true,
value: "https://example.com",
href: "https://example.com",
start: 0,
end: 19,
});
});
it("detects http URLs", () => {
const results = findLinks("http://example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
isLink: true,
value: "http://example.com",
href: "http://example.com",
});
});
it("detects schemeless URLs and prepends default protocol", () => {
const results = findLinks("example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
isLink: true,
value: "example.com",
href: "http://example.com",
start: 0,
end: 11,
});
});
it("respects defaultProtocol option", () => {
const results = findLinks("example.com", { defaultProtocol: "https" });
expect(results).toHaveLength(1);
expect(results[0].href).toBe("https://example.com");
});
it("detects www URLs", () => {
const results = findLinks("www.example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
isLink: true,
value: "www.example.com",
href: "http://www.example.com",
});
});
});
describe("multiple URLs in text", () => {
it("finds multiple URLs with correct positions", () => {
const results = findLinks("Visit https://a.com and https://b.com");
expect(results).toHaveLength(2);
expect(results[0]).toMatchObject({
value: "https://a.com",
start: 6,
end: 19,
});
expect(results[1]).toMatchObject({
value: "https://b.com",
start: 24,
end: 37,
});
});
it("finds multiple schemeless URLs", () => {
const results = findLinks("Check example.com or test.org");
expect(results).toHaveLength(2);
expect(results[0].value).toBe("example.com");
expect(results[1].value).toBe("test.org");
});
});
describe("URLs with paths, queries, and fragments", () => {
it("includes full path", () => {
const results = findLinks("https://example.com/path/to/page");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com/path/to/page");
});
it("includes query string", () => {
const results = findLinks("https://example.com?q=hello&b=world");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com?q=hello&b=world");
});
it("includes fragment", () => {
const results = findLinks("https://example.com#section");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com#section");
});
it("includes path + query + fragment", () => {
const results = findLinks("https://example.com/path?q=1#frag");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com/path?q=1#frag");
});
it("includes encoded characters", () => {
const results = findLinks("https://example.com/path%20name");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com/path%20name");
});
it("includes trailing slash", () => {
const results = findLinks("https://example.com/");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com/");
});
});
describe("URLs with ports", () => {
it("detects URL with port", () => {
const results = findLinks("https://example.com:8080");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com:8080");
});
it("detects schemeless URL with port and path", () => {
const results = findLinks("example.com:3000/path");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
value: "example.com:3000/path",
href: "http://example.com:3000/path",
});
});
});
describe("trailing punctuation handling", () => {
it("excludes trailing period", () => {
const results = findLinks("Visit https://example.com.");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com");
});
it("excludes trailing comma", () => {
const results = findLinks("See https://example.com, and more");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com");
});
it("excludes surrounding parentheses", () => {
const results = findLinks("(https://example.com)");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
value: "https://example.com",
start: 1,
end: 20,
});
});
it("keeps balanced parentheses in path (Wikipedia-style)", () => {
const results = findLinks("https://en.wikipedia.org/wiki/Foo_(bar)");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://en.wikipedia.org/wiki/Foo_(bar)");
});
});
describe("non-links", () => {
it("returns empty for plain text", () => {
expect(findLinks("not a link")).toHaveLength(0);
});
it("returns empty for single word", () => {
expect(findLinks("hello")).toHaveLength(0);
});
it("returns empty for empty string", () => {
expect(findLinks("")).toHaveLength(0);
});
it("returns empty for just a protocol", () => {
expect(findLinks("https://")).toHaveLength(0);
});
it("does not detect bare IP addresses", () => {
expect(findLinks("192.168.1.1")).toHaveLength(0);
});
});
describe("domain variations", () => {
it("detects hyphenated domains", () => {
const results = findLinks("my-site.example.com");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("my-site.example.com");
});
it("detects subdomains", () => {
const results = findLinks("sub.domain.example.com");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("sub.domain.example.com");
});
});
describe("URL position in text", () => {
it("detects URL at end of text", () => {
const results = findLinks("go to example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
value: "example.com",
start: 6,
end: 17,
});
});
it("detects URL at start of text", () => {
const results = findLinks("example.com is great");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
value: "example.com",
start: 0,
end: 11,
});
});
});
describe("protocol variations", () => {
it("detects ftp URLs", () => {
const results = findLinks("ftp://files.example.com");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("ftp://files.example.com");
});
it("detects mailto URLs", () => {
const results = findLinks("mailto:user@example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
value: "mailto:user@example.com",
href: "mailto:user@example.com",
});
});
it("detects bare email addresses as links", () => {
const results = findLinks("user@example.com");
expect(results).toHaveLength(1);
expect(results[0]).toMatchObject({
isLink: true,
value: "user@example.com",
href: "mailto:user@example.com",
});
});
});
describe("boundary handling", () => {
it("stops at whitespace", () => {
const results = findLinks("https://example.com/path with spaces");
expect(results).toHaveLength(1);
expect(results[0].value).toBe("https://example.com/path");
});
});
});
describe("tokenizeLink() baseline behavior", () => {
describe("single valid links", () => {
it("tokenizes schemeless URL as single link token", () => {
const tokens = tokenizeToObjects("example.com");
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({
isLink: true,
value: "example.com",
href: "http://example.com",
start: 0,
end: 11,
});
});
it("tokenizes https URL as single link token", () => {
const tokens = tokenizeToObjects("https://example.com");
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({
isLink: true,
value: "https://example.com",
href: "https://example.com",
});
});
it("tokenizes URL with path as single link token", () => {
const tokens = tokenizeToObjects("example.com/path");
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({
isLink: true,
value: "example.com/path",
href: "http://example.com/path",
});
});
it("tokenizes www URL as single link token", () => {
const tokens = tokenizeToObjects("www.example.com");
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({
isLink: true,
value: "www.example.com",
href: "http://www.example.com",
});
});
it("tokenizes URL with https and path as single link token", () => {
const tokens = tokenizeToObjects("https://example.com/path");
expect(tokens).toHaveLength(1);
expect(tokens[0]).toMatchObject({
isLink: true,
value: "https://example.com/path",
href: "https://example.com/path",
});
});
it("tokenizes short TLD (2 chars) as link", () => {
const tokens = tokenizeToObjects("test.co");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(true);
});
});
describe("bracket-wrapped links", () => {
it("tokenizes (url) as 3 tokens with link in middle", () => {
const tokens = tokenizeToObjects("(example.com)");
expect(tokens).toHaveLength(3);
expect(tokens[0]).toMatchObject({
isLink: false,
value: "(",
start: 0,
end: 1,
});
expect(tokens[1]).toMatchObject({
isLink: true,
value: "example.com",
href: "http://example.com",
start: 1,
end: 12,
});
expect(tokens[2]).toMatchObject({
isLink: false,
value: ")",
start: 12,
end: 13,
});
});
it("tokenizes [url] as 3 tokens with link in middle", () => {
const tokens = tokenizeToObjects("[example.com]");
expect(tokens).toHaveLength(3);
expect(tokens[0]).toMatchObject({ isLink: false, value: "[" });
expect(tokens[1]).toMatchObject({
isLink: true,
value: "example.com",
});
expect(tokens[2]).toMatchObject({ isLink: false, value: "]" });
});
it("tokenizes (https://url) as 3 tokens", () => {
const tokens = tokenizeToObjects("(https://example.com)");
expect(tokens).toHaveLength(3);
expect(tokens[0]).toMatchObject({ isLink: false, value: "(" });
expect(tokens[1]).toMatchObject({
isLink: true,
value: "https://example.com",
href: "https://example.com",
});
expect(tokens[2]).toMatchObject({ isLink: false, value: ")" });
});
});
describe("non-links", () => {
it("tokenizes plain word as non-link", () => {
const tokens = tokenizeToObjects("notaurl");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(false);
});
it("tokenizes domain with trailing number as non-link", () => {
// This is a key behavior: example.com1 is NOT a valid link
// because the TLD is "com1" which is not valid
const tokens = tokenizeToObjects("example.com1");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(false);
});
it("tokenizes single-char TLD as non-link", () => {
const tokens = tokenizeToObjects("test.x");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(false);
});
it("tokenizes single-char hostname as non-link", () => {
const tokens = tokenizeToObjects("a.bc");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(false);
});
});
describe("edge cases", () => {
it("tokenizes IP address as non-link", () => {
const tokens = tokenizeToObjects("192.168.1.1");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(false);
});
it("tokenizes localhost as link (filtered downstream by shouldAutoLink)", () => {
const tokens = tokenizeToObjects("localhost");
expect(tokens).toHaveLength(1);
expect(tokens[0].isLink).toBe(true);
});
it("tokenizes url with trailing dot as url + dot tokens", () => {
const tokens = tokenizeToObjects("example.com.");
expect(tokens).toHaveLength(2);
expect(tokens[0]).toMatchObject({
isLink: true,
value: "example.com",
});
expect(tokens[1]).toMatchObject({
isLink: false,
value: ".",
});
});
it("tokenizes {url} as 3 tokens (curly braces)", () => {
const tokens = tokenizeToObjects("{example.com}");
expect(tokens).toHaveLength(3);
expect(tokens[0]).toMatchObject({ isLink: false, value: "{" });
expect(tokens[1]).toMatchObject({ isLink: true, value: "example.com" });
expect(tokens[2]).toMatchObject({ isLink: false, value: "}" });
});
it("respects defaultProtocol parameter", () => {
const tokens = tokenizeToObjects("example.com", "https");
expect(tokens[0].href).toBe("https://example.com");
});
});
});
describe("isValidLinkStructure baseline", () => {
it("accepts single link token", () => {
const tokens = tokenizeToObjects("example.com");
expect(isValidLinkStructure(tokens)).toBe(true);
});
it("accepts link wrapped in parentheses", () => {
const tokens = tokenizeToObjects("(example.com)");
expect(isValidLinkStructure(tokens)).toBe(true);
});
it("accepts link wrapped in square brackets", () => {
const tokens = tokenizeToObjects("[example.com]");
expect(isValidLinkStructure(tokens)).toBe(true);
});
it("rejects link wrapped in curly braces", () => {
// {url} tokenizes to 3 tokens but {} is not in the accepted list
const tokens = tokenizeToObjects("{example.com}");
expect(isValidLinkStructure(tokens)).toBe(false);
});
it("rejects non-link single token", () => {
const tokens = tokenizeToObjects("notaurl");
expect(isValidLinkStructure(tokens)).toBe(false);
});
it("rejects url with trailing dot (2 tokens)", () => {
const tokens = tokenizeToObjects("example.com.");
expect(isValidLinkStructure(tokens)).toBe(false);
});
it("rejects example.com1 (invalid TLD)", () => {
const tokens = tokenizeToObjects("example.com1");
expect(isValidLinkStructure(tokens)).toBe(false);
});
});
// ============================================================================
// Level 2: Integration tests through the editor
// ============================================================================
describe("Link extension autolink behavior", () => {
let editor: BlockNoteEditor<any, any, any>;
afterEach(() => {
if (editor) {
editor._tiptapEditor.destroy();
}
});
function setupEditorWithBlock(content = "") {
editor = createEditor();
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: content || undefined,
},
]);
return editor;
}
describe("should autolink", () => {
it("autolinks https URL when followed by space", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(
editor,
"test-block",
"https://example.com",
);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("https://example.com");
expect(links[0].text).toBe("https://example.com");
});
it("autolinks http URL when followed by space", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(
editor,
"test-block",
"http://example.com",
);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("http://example.com");
});
it("autolinks schemeless URL with default protocol (https in BlockNote)", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "example.com");
expect(links).toHaveLength(1);
// BlockNote overrides the tiptap default to "https" via DEFAULT_LINK_PROTOCOL
expect(links[0].href).toBe("https://example.com");
expect(links[0].text).toBe("example.com");
});
it("autolinks www URL", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "www.example.com");
expect(links).toHaveLength(1);
expect(links[0].href).toBe("https://www.example.com");
});
it("autolinks URL with path and query", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(
editor,
"test-block",
"https://example.com/path?q=1",
);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("https://example.com/path?q=1");
});
});
describe("should NOT autolink", () => {
it("does not autolink plain text", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "notaurl");
expect(links).toHaveLength(0);
});
it("does not autolink single word", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "hello");
expect(links).toHaveLength(0);
});
it("does not autolink IP address without protocol", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "192.168.1.1");
expect(links).toHaveLength(0);
});
it("does not autolink localhost (single-word hostname)", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "localhost");
expect(links).toHaveLength(0);
});
it("does not autolink domain with trailing number (invalid TLD)", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(editor, "test-block", "example.com1");
expect(links).toHaveLength(0);
});
});
describe("bracket-wrapped URLs", () => {
it("autolinks URL in parentheses, linking only the URL", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(
editor,
"test-block",
"(https://example.com)",
);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("https://example.com");
expect(links[0].text).toBe("https://example.com");
});
it("autolinks URL in square brackets, linking only the URL", () => {
setupEditorWithBlock();
const links = typeTextThenSpace(
editor,
"test-block",
"[https://example.com]",
);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("https://example.com");
expect(links[0].text).toBe("https://example.com");
});
});
});
describe("Link extension paste handler behavior", () => {
let editor: BlockNoteEditor<any, any, any>;
afterEach(() => {
if (editor) {
editor._tiptapEditor.destroy();
}
});
it("applies link mark when pasting URL over selected text", () => {
editor = createEditor();
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: "click here",
},
]);
// Select "click here"
editor.setTextCursorPosition("test-block", "start");
const view = editor._tiptapEditor.view;
const doc = view.state.doc;
// Find the text node position
let textStart = 0;
let textEnd = 0;
doc.descendants((node, pos) => {
if (node.isText && node.text === "click here") {
textStart = pos;
textEnd = pos + node.nodeSize;
}
});
// Create selection over the text
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, textStart, textEnd),
);
view.dispatch(tr);
// Create a minimal slice that looks like pasted URL text
const textNode = view.state.schema.text("https://example.com");
const slice = new Slice(Fragment.from(textNode), 0, 0);
// Dispatch paste through the editor view
const handled = view.someProp("handlePaste", (f) =>
f(view, new ClipboardEvent("paste"), slice),
);
expect(handled).toBeTruthy();
// Check that link mark was applied
const links = getLinksInDocument(editor);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("https://example.com");
expect(links[0].text).toBe("click here");
});
it("does not apply link when pasting non-URL text over selection", () => {
editor = createEditor();
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: "click here",
},
]);
editor.setTextCursorPosition("test-block", "start");
const view = editor._tiptapEditor.view;
const doc = view.state.doc;
let textStart = 0;
let textEnd = 0;
doc.descendants((node, pos) => {
if (node.isText && node.text === "click here") {
textStart = pos;
textEnd = pos + node.nodeSize;
}
});
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, textStart, textEnd),
);
view.dispatch(tr);
const textNode = view.state.schema.text("not a url");
const slice = new Slice(Fragment.from(textNode), 0, 0);
const handled = view.someProp("handlePaste", (f) =>
f(view, new ClipboardEvent("paste"), slice),
);
// Should not be handled (not a URL)
expect(handled).toBeFalsy();
// No links should exist
const links = getLinksInDocument(editor);
expect(links).toHaveLength(0);
});
it("does not apply link when pasting URL with empty selection", () => {
editor = createEditor();
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: "some text",
},
]);
// Place cursor without selection
editor.setTextCursorPosition("test-block", "end");
const view = editor._tiptapEditor.view;
const textNode = view.state.schema.text("https://example.com");
const slice = new Slice(Fragment.from(textNode), 0, 0);
const handled = view.someProp("handlePaste", (f) =>
f(view, new ClipboardEvent("paste"), slice),
);
// Should not be handled because selection is empty
expect(handled).toBeFalsy();
});
});
describe("Link extension isValidLink option", () => {
let editor: BlockNoteEditor<any, any, any>;
afterEach(() => {
if (editor) {
editor._tiptapEditor.destroy();
}
});
it("autolink: restrictive override blocks normally-valid URLs on typing", () => {
editor = createEditor({ isValidLink: () => false });
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: "",
},
]);
const links = typeTextThenSpace(
editor,
"test-block",
"https://example.com",
);
expect(links).toHaveLength(0);
});
it("paste-rule: restrictive override blocks pasted URL text", () => {
editor = createEditor({ isValidLink: () => false });
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: "some text here",
},
]);
editor._tiptapEditor.commands.insertContent("https://example.com ");
const links = getLinksInDocument(editor);
expect(links).toHaveLength(0);
});
it("paste-handler: restrictive override blocks URL pasted over selection", () => {
editor = createEditor({ isValidLink: () => false });
editor.replaceBlocks(editor.document, [
{
id: "test-block",
type: "paragraph",
content: "click here",
},
]);
editor.setTextCursorPosition("test-block", "start");
const view = editor._tiptapEditor.view;
const doc = view.state.doc;
let textStart = 0;
let textEnd = 0;
doc.descendants((node, pos) => {
if (node.isText && node.text === "click here") {
textStart = pos;
textEnd = pos + node.nodeSize;
}
});
const tr = view.state.tr.setSelection(
TextSelection.create(view.state.doc, textStart, textEnd),
);
view.dispatch(tr);
const textNode = view.state.schema.text("https://example.com");
const slice = new Slice(Fragment.from(textNode), 0, 0);
const handled = view.someProp("handlePaste", (f) =>
f(view, new ClipboardEvent("paste"), slice),
);
expect(handled).toBeFalsy();
const links = getLinksInDocument(editor);
expect(links).toHaveLength(0);
});
it("parseHTML: permissive override accepts custom-scheme links", () => {
editor = createEditor({
isValidLink: (href) => isAllowedUri(href) || href.startsWith("myapp:"),
});
editor.pasteHTML(`<p><a href="myapp://foo">click</a></p>`);
const links = getLinksInDocument(editor);
expect(links).toHaveLength(1);
expect(links[0].href).toBe("myapp://foo");
});
it("parseHTML: default rejects unknown-scheme links", () => {
editor = createEditor();
editor.pasteHTML(`<p><a href="myapp://foo">click</a></p>`);
const links = getLinksInDocument(editor);
expect(links).toHaveLength(0);
});
it("renderHTML: permissive override preserves custom-scheme href on export", () => {
editor = createEditor({
isValidLink: (href) => isAllowedUri(href) || href.startsWith("myapp:"),
});
editor.pasteHTML(`<p><a href="myapp://foo">click</a></p>`);
const html = editor.blocksToFullHTML(editor.document);
expect(html).toContain('href="myapp://foo"');
});
});