From 5b0397268bab225e3ea17afc9e600e82c3d2de14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 21 Apr 2025 12:28:51 +0400 Subject: [PATCH] Improve Nostr link handling Fixes a bunch of problems with how Nostr links are created and replaced in Markdown content --- deno.json | 4 +- deno.lock | 60 +++++++++++------ directory.ts | 1 + models/article.ts | 2 +- nostr.ts | 45 ------------- nostr/links.ts | 129 ++++++++++++++++++++++++++++++++++++ tests/fixtures/article-2.md | 41 ++++++++++++ tests/nostr/links_test.ts | 62 +++++++++++++++++ 8 files changed, 274 insertions(+), 70 deletions(-) create mode 100644 nostr/links.ts create mode 100644 tests/fixtures/article-2.md create mode 100644 tests/nostr/links_test.ts diff --git a/deno.json b/deno.json index 4161c81..2026df2 100644 --- a/deno.json +++ b/deno.json @@ -3,7 +3,7 @@ "dev": "deno run --allow-all --watch server.ts", "server": "deno run --allow-all server.ts", "compile": "deno compile --allow-all --include ./assets/ --output ./build/substr_x86_64-unknown-linux-gnu server.ts", - "test": "deno test --allow-read --allow-env" + "test": "DENO_ENV=test deno test --allow-read --allow-env" }, "imports": { "@deno/gfm": "jsr:@deno/gfm@^0.10.0", @@ -12,7 +12,7 @@ "@oak/oak": "jsr:@oak/oak@^17.1.3", "@std/dotenv": "jsr:@std/dotenv@^0.225.2", "@std/expect": "jsr:@std/expect@^1.0.8", - "@std/testing": "jsr:@std/testing@^1.0.5", + "@std/testing": "jsr:@std/testing@^1.0.11", "@std/yaml": "jsr:@std/yaml@^1.0.5", "ldapts": "npm:ldapts@^7.2.2" }, diff --git a/deno.lock b/deno.lock index ada1ef9..15a4f5a 100644 --- a/deno.lock +++ b/deno.lock @@ -3,34 +3,34 @@ "specifiers": { "jsr:@deno/gfm@0.10": "0.10.0", "jsr:@denosaurs/emoji@0.3": "0.3.1", - "jsr:@nostr/tools@^2.10.4": "2.10.4", + "jsr:@nostr/tools@^2.10.4": "2.12.0", "jsr:@nostrify/nostrify@~0.36.2": "0.36.2", "jsr:@nostrify/types@0.35": "0.35.0", "jsr:@oak/commons@1": "1.0.0", "jsr:@oak/oak@^17.1.3": "17.1.3", "jsr:@std/assert@0.224": "0.224.0", "jsr:@std/assert@1": "1.0.8", + "jsr:@std/assert@^1.0.12": "1.0.12", "jsr:@std/assert@^1.0.8": "1.0.8", "jsr:@std/bytes@1": "1.0.4", "jsr:@std/bytes@^1.0.2": "1.0.4", "jsr:@std/crypto@0.224": "0.224.0", "jsr:@std/crypto@1": "1.0.3", - "jsr:@std/data-structures@^1.0.4": "1.0.4", - "jsr:@std/dotenv@~0.225.2": "0.225.2", + "jsr:@std/dotenv@~0.225.2": "0.225.3", "jsr:@std/encoding@0.224": "0.224.3", "jsr:@std/encoding@1": "1.0.5", "jsr:@std/encoding@^1.0.5": "1.0.5", "jsr:@std/encoding@~0.224.1": "0.224.3", - "jsr:@std/expect@^1.0.8": "1.0.8", - "jsr:@std/fs@^1.0.5": "1.0.5", + "jsr:@std/expect@^1.0.8": "1.0.15", "jsr:@std/http@1": "1.0.10", "jsr:@std/internal@^1.0.5": "1.0.5", + "jsr:@std/internal@^1.0.6": "1.0.6", "jsr:@std/io@0.224": "0.224.9", "jsr:@std/media-types@1": "1.1.0", "jsr:@std/path@1": "1.0.8", "jsr:@std/path@^1.0.7": "1.0.8", "jsr:@std/path@^1.0.8": "1.0.8", - "jsr:@std/testing@^1.0.5": "1.0.5", + "jsr:@std/testing@^1.0.11": "1.0.11", "jsr:@std/yaml@*": "1.0.5", "jsr:@std/yaml@^1.0.5": "1.0.5", "npm:@noble/ciphers@~0.5.1": "0.5.3", @@ -90,6 +90,15 @@ "npm:nostr-wasm" ] }, + "@nostr/tools@2.12.0": { + "integrity": "0584d5197682c6eabaded17bae10e765f215ef051ae70aa463f994abf90f295a", + "dependencies": [ + "npm:@noble/ciphers", + "npm:@noble/curves", + "npm:@noble/hashes", + "npm:@scure/base@1.1.1" + ] + }, "@nostrify/nostrify@0.36.2": { "integrity": "cc4787ca170b623a2e5dfed1baa4426077daa6143af728ea7dd325d58f4d04d6", "dependencies": [ @@ -139,7 +148,13 @@ "@std/assert@1.0.8": { "integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.5" + ] + }, + "@std/assert@1.0.12": { + "integrity": "08009f0926dda9cbd8bef3a35d3b6a4b964b0ab5c3e140a4e0351fbf34af5b9a", + "dependencies": [ + "jsr:@std/internal@^1.0.6" ] }, "@std/bytes@1.0.4": { @@ -155,12 +170,12 @@ "@std/crypto@1.0.3": { "integrity": "a2a32f51ddef632d299e3879cd027c630dcd4d1d9a5285d6e6788072f4e51e7f" }, - "@std/data-structures@1.0.4": { - "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" - }, "@std/dotenv@0.225.2": { "integrity": "e2025dce4de6c7bca21dece8baddd4262b09d5187217e231b033e088e0c4dd23" }, + "@std/dotenv@0.225.3": { + "integrity": "a95e5b812c27b0854c52acbae215856d9cce9d4bbf774d938c51d212711e8d4a" + }, "@std/encoding@0.224.3": { "integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf" }, @@ -171,13 +186,14 @@ "integrity": "27e40d8f3aefb372fc6a703fb0b69e34560e72a2f78705178babdffa00119a5f", "dependencies": [ "jsr:@std/assert@^1.0.8", - "jsr:@std/internal" + "jsr:@std/internal@^1.0.5" ] }, - "@std/fs@1.0.5": { - "integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e", + "@std/expect@1.0.15": { + "integrity": "eca360007b5a7f13dbfa1294224baee7fb98dcd460d8461fe64eeae302902945", "dependencies": [ - "jsr:@std/path@^1.0.7" + "jsr:@std/assert@^1.0.12", + "jsr:@std/internal@^1.0.6" ] }, "@std/http@1.0.10": { @@ -189,6 +205,9 @@ "@std/internal@1.0.5": { "integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba" }, + "@std/internal@1.0.6": { + "integrity": "9533b128f230f73bd209408bb07a4b12f8d4255ab2a4d22a1fd6d87304aca9a4" + }, "@std/io@0.224.9": { "integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3", "dependencies": [ @@ -201,14 +220,11 @@ "@std/path@1.0.8": { "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" }, - "@std/testing@1.0.5": { - "integrity": "6e693cbec94c81a1ad3df668685c7ba8e20742bb10305bc7137faa5cf16d2ec4", + "@std/testing@1.0.11": { + "integrity": "12b3db12d34f0f385a26248933bde766c0f8c5ad8b6ab34d4d38f528ab852f48", "dependencies": [ - "jsr:@std/assert@^1.0.8", - "jsr:@std/data-structures", - "jsr:@std/fs", - "jsr:@std/internal", - "jsr:@std/path@^1.0.8" + "jsr:@std/assert@^1.0.12", + "jsr:@std/internal@^1.0.6" ] }, "@std/yaml@1.0.5": { @@ -519,7 +535,7 @@ "jsr:@oak/oak@^17.1.3", "jsr:@std/dotenv@~0.225.2", "jsr:@std/expect@^1.0.8", - "jsr:@std/testing@^1.0.5", + "jsr:@std/testing@^1.0.11", "jsr:@std/yaml@^1.0.5", "npm:ldapts@^7.2.2" ] diff --git a/directory.ts b/directory.ts index 8d5ded4..ac4e3c8 100644 --- a/directory.ts +++ b/directory.ts @@ -1,3 +1,4 @@ +// deno-lint-ignore-file require-await import config from "./config.ts"; import { lookupUsernameByPubkey as ldapLookupUsername } from "./ldap.ts"; import { lookupPubkeyByUsername as ldapLookupPubkey } from "./ldap.ts"; diff --git a/models/article.ts b/models/article.ts index 5b423d0..8a32abc 100644 --- a/models/article.ts +++ b/models/article.ts @@ -1,7 +1,7 @@ import { render as renderMarkdown } from "@deno/gfm"; import { nip19 } from "@nostr/tools"; import { NostrEvent as NEvent } from "@nostrify/nostrify"; -import { replaceNostrUris } from "../nostr.ts"; +import { replaceNostrUris } from "../nostr/links.ts"; import config from "../config.ts"; export default class Article { diff --git a/nostr.ts b/nostr.ts index 647951a..04c3d08 100644 --- a/nostr.ts +++ b/nostr.ts @@ -1,6 +1,4 @@ import { NostrEvent, NostrFilter, NPool, NRelay1 } from "@nostrify/nostrify"; -import { nip19 } from "@nostr/tools"; -import { lookupUsernameByPubkey } from "./directory.ts"; import config from "./config.ts"; import Article from "./models/article.ts"; @@ -79,49 +77,6 @@ export async function fetchProfileEvent(pubkey: string) { return events.length > 0 ? events[0] : null; } -export async function nostrUriToUrl(uri: string): Promise { - const bech32 = uri.replace(/^nostr:/, ""); - - if (bech32.match(/^(naddr|nprofile|npub)/)) { - try { - const r = nip19.decode(bech32); - let username; - - switch (r.type) { - case "naddr": - username = await lookupUsernameByPubkey(r.data.pubkey); - if (username) return `/${bech32}`; - break; - case "nprofile": - username = await lookupUsernameByPubkey(r.data.pubkey); - if (username) return `/@${username}`; - break; - case "npub": - username = await lookupUsernameByPubkey(r.data); - if (username) return `/@${username}`; - break; - } - } catch (e) { - console.error(e); - } - } - - return `${config.njump_url}/${bech32}`; -} - -export async function replaceNostrUris(markdown: string): Promise { - const nostrUriRegex = /(nostr:|nprofile|naddr|nevent|npub)[a-z0-9]+/g; - const matches = markdown.match(nostrUriRegex); - if (!matches) return markdown; - - for (const uri of matches) { - const url = await nostrUriToUrl(uri); - markdown = markdown.replace(uri, url); - } - - return markdown; -} - export async function verifyNip05Address( address: string, pubkey: string, diff --git a/nostr/links.ts b/nostr/links.ts new file mode 100644 index 0000000..c01c992 --- /dev/null +++ b/nostr/links.ts @@ -0,0 +1,129 @@ +import { lookupUsernameByPubkey } from "../directory.ts"; +import { nip19 } from "@nostr/tools"; +import config from "../config.ts"; + +const nostrUriRegex = /(nostr:|@)(nprofile|naddr|nevent|npub)[a-z0-9]+/g; + +export async function nostrUriToUrl(uri: string): Promise { + const bech32 = uri.replace(/^(nostr:|@)/, ""); + + if (bech32.match(/^(naddr|nprofile|npub)/)) { + try { + const r = nip19.decode(bech32); + let username; + + switch (r.type) { + case "naddr": + username = await lookupUsernameByPubkey(r.data.pubkey); + if (username) return `/${bech32}`; + break; + case "nprofile": + username = await lookupUsernameByPubkey(r.data.pubkey); + if (username) return `/@${username}`; + break; + case "npub": + username = await lookupUsernameByPubkey(r.data); + if (username) return `/@${username}`; + break; + } + } catch (e) { + console.error(e); + } + } + + return `${config.njump_url}/${bech32}`; +} + +export async function replaceNostrUris(markdown: string): Promise { + const protectedRegex = /(`{3,}[\s\S]*?`{3,})|(`[^`]*`)|(
[\s\S]*?<\/pre>)|(https?:\/\/[^\s<>"']+)/gi;
+
+  // Split text into segments: unprotected text and protected areas (code blocks, URLs)
+  const segments: string[] = [];
+  let lastIndex = 0;
+
+  markdown.replace(protectedRegex, (match, _fencedCode, _inlineCode, _preTag, _url, index) => {
+    segments.push(markdown.slice(lastIndex, index));
+    segments.push(match);
+    lastIndex = index + match.length;
+    return match;
+  });
+  segments.push(markdown.slice(lastIndex));
+
+  // Process each segment
+  let result = '';
+  for (let i = 0; i < segments.length; i++) {
+    if (i % 2 === 1 || protectedRegex.test(segments[i])) {
+      // Protected segment (code block or URL), leave unchanged
+      result += segments[i];
+    } else {
+      // Unprotected text, replace URIs and handle markdown links
+      result += await processUnprotectedText(segments[i]);
+    }
+  }
+
+  return result;
+}
+
+async function processUnprotectedText(text: string): Promise {
+  const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
+  let lastIndex = 0;
+  const parts: string[] = [];
+
+  // Process markdown links first
+  let match;
+  while ((match = markdownLinkRegex.exec(text)) !== null) {
+    const [fullMatch, linkText, target] = match;
+
+    // Add text before the link
+    parts.push(await replaceUrisInText(text.slice(lastIndex, match.index)));
+
+    // Process the link target
+    if (nostrUriRegex.test(target) && target.match(nostrUriRegex)![0] === target) {
+      // Target is a Nostr URI, replace with resolved URL
+      const resolvedUrl = await nostrUriToUrl(target);
+      parts.push(`[${linkText}](${resolvedUrl})`);
+    } else {
+      // Not a Nostr URI, keep the original link
+      parts.push(fullMatch);
+    }
+
+    lastIndex = match.index + fullMatch.length;
+  }
+
+  // Add any remaining text after the last link
+  parts.push(await replaceUrisInText(text.slice(lastIndex)));
+
+  return parts.join('');
+}
+
+async function replaceUrisInText(text: string): Promise {
+  let modifiedText = text;
+  const replacements: { start: number; end: number; replacement: string }[] = [];
+
+  // Collect all replacements for bare Nostr URIs
+  let match;
+  while ((match = nostrUriRegex.exec(modifiedText)) !== null) {
+    const fullUri = match[0];
+    const url = await nostrUriToUrl(fullUri);
+    const linkTitle = cleanUriForTitle(fullUri);
+    const markdownLink = `[${linkTitle}](${url})`;
+    replacements.push({
+      start: match.index,
+      end: match.index + fullUri.length,
+      replacement: markdownLink,
+    });
+  }
+
+  // Apply replacements from right to left to avoid index shifting
+  for (let i = replacements.length - 1; i >= 0; i--) {
+    const { start, end, replacement } = replacements[i];
+    modifiedText = modifiedText.slice(0, start) + replacement + modifiedText.slice(end);
+  }
+
+  return modifiedText;
+}
+
+function cleanUriForTitle(uri: string): string {
+  // Remove "nostr:" prefix, keep "@" for the title
+  return uri.startsWith('nostr:') ? uri.replace(/^nostr:/, '') : uri;
+}
diff --git a/tests/fixtures/article-2.md b/tests/fixtures/article-2.md
new file mode 100644
index 0000000..c4ff93b
--- /dev/null
+++ b/tests/fixtures/article-2.md
@@ -0,0 +1,41 @@
+I think we should agree on an HTML attribute for pointing to the Nostr representation of a document/URL on the Web. We could use the existing one for link relations for example. Something like:
+
+```html
+
+```
+
+This would be useful in multiple ways:
+
+1. Existing Web publications can retroactively create Nostr versions of their content and easily link the Nostr articles on all of their existing article pages.
+2. Nostr clients, when fetching meta/preview information for a URL that is linked in a note, can detect that there's a Nostr representation of the content, and then render it in Nostr-native ways (whatever that may be depending on the client)
+3. User agents, usually a browser or browser extension, when opening a URL on the Web, can offer opening the alternative representation of a page in a Nostr client. And/or they could offer to follow the author's pubkey on Nostr. And/or they could offer to zap the content.
+4. When publishing a new article on Nostr, authors can share their preferred Web URL everywhere, without having to consider if the recipients' clients support Nostr IDs and content or not. This makes it easy for the reader to share the author's preferred Web URL on any medium, instead of sharing a link to whatever their own Nostr client prefers (usually its own Web UI).
+
+
+### Testing Nostr IDs
+
+Receive [special badges](https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc)
+
+raucao scheme 1: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
+raucao at 1: @npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
+raucao scheme 2: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
+raucao at 2: @npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees
+raucao scheme link 1: [raucao](nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees)
+raucao scheme link 2: [raucao](nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees)
+
+Amber scheme 1: nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
+Amber at 1: @npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
+Amber scheme 2: nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
+Amber at 2: @npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
+Amber scheme link 1: [Amber](nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7)
+Amber scheme link 2: [Amber](nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7)
+
+## More protected text
+
+```
+Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7
+```
+
+Inline: `raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees`
diff --git a/tests/nostr/links_test.ts b/tests/nostr/links_test.ts
new file mode 100644
index 0000000..3125e0a
--- /dev/null
+++ b/tests/nostr/links_test.ts
@@ -0,0 +1,62 @@
+import { beforeAll, describe, it } from "@std/testing/bdd";
+import { expect } from "@std/expect";
+import { replaceNostrUris } from "../../nostr/links.ts";
+
+describe("Nostr links", () => {
+
+  describe("#replaceNostrUris", () => {
+    let mdContent: string;
+    let result: string;
+
+    beforeAll(async () => {
+      mdContent = Deno.readTextFileSync("tests/fixtures/article-2.md"),
+      result = await replaceNostrUris(mdContent);
+    });
+
+    it("does not replace URIs in URLs", () => {
+      expect(result).toMatch(new RegExp("https://badges.page/p/npub1cpmvpsqtzxl4px44dp4544xwgu0ryv2lscl3qexq42dfakuza02s4fsapc"));
+    });
+
+    it("does not replace URIs in fenced code blocks", () => {
+      expect(result).toMatch(new RegExp("Follow nostr:npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7"));
+    });
+
+    it("does not replace URIs in inline code blocks", () => {
+      expect(result).toMatch(new RegExp("raucao: nostr:npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees"));
+    });
+
+    describe("for unknown usernames", () => {
+      it("replaces plain nostr:id URIs with a markdown link", () => {
+        expect(result).toMatch(/Amber scheme 1\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
+        expect(result).toMatch(/Amber scheme 2\: \[npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
+      });
+
+      it("replaces @id URIs with a markdown link", () => {
+        expect(result).toMatch(/Amber at 1\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
+        expect(result).toMatch(/Amber at 2\: \[@npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
+      });
+
+      it("replaces nostr links with external links", () => {
+        expect(result).toMatch(/Amber scheme link 1\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
+        expect(result).toMatch(/Amber scheme link 2\: \[Amber\]\(https\:\/\/njump\.me\/npub1am3ermkr250dywukzqnaug64cred3x5jht6f3kdhfp3h0rgtjlpqecxrv7\)/);
+      });
+    });
+
+    describe("for known usernames", () => {
+      it("replaces plain nostr:id URIs with a markdown link", () => {
+        expect(result).toMatch(/raucao scheme 1\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
+        expect(result).toMatch(/raucao scheme 2\: \[npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
+      });
+
+      it("replaces @id URIs with a markdown link", () => {
+        expect(result).toMatch(/raucao at 1\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
+        expect(result).toMatch(/raucao at 2\: \[@npub1raustrrh5gjwt03zdj8syn9vmt2dwsv9t467m8c3gua636uxu89svgdees\]\(\/@raucao\)/);
+      });
+
+      it("replaces scheme links with internal links", () => {
+        expect(result).toMatch(/raucao scheme link 1\: \[raucao\]\(\/@raucao\)/);
+        expect(result).toMatch(/raucao scheme link 2\: \[raucao\]\(\/@raucao\)/);
+      });
+    });
+  });
+});