Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions web/src/features/invite/ui/InvitePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ import {
import { hasNip07Provider } from "@/shared/lib/nostr-signer";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import * as React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";

import { InviteJoinPolicyNotice } from "./InviteJoinPolicyNotice";

Expand Down Expand Up @@ -372,9 +371,7 @@ export function InvitePage({ code }: { code: string }) {
</button>
</div>
<div className="prose prose-sm max-w-none">
<Markdown remarkPlugins={[remarkGfm]}>
{document.markdown}
</Markdown>
<Markdown>{document.markdown}</Markdown>
</div>
</div>
</div>
Expand Down
5 changes: 2 additions & 3 deletions web/src/features/repos/ui/RepoBlobViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@
import { ArrowLeft, Check, Copy, Download, FileText, Play } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { toast } from "sonner";

import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import type { BlobView } from "../git-client";
import { useGitBlob, useGitHtmlDoc } from "../use-git-browse";
import { useRepoContext } from "../use-repo-context";
Expand Down Expand Up @@ -182,7 +181,7 @@ function ViewerBody({
case "markdown":
return (
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
<Markdown remarkPlugins={[remarkGfm]}>{view.content}</Markdown>
<Markdown>{view.content}</Markdown>
</div>
);
case "html":
Expand Down
5 changes: 2 additions & 3 deletions web/src/features/repos/ui/RepoReadmeSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Markdown } from "@/shared/ui/markdown";
import { BookOpen } from "lucide-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ReadmeResult } from "../git-client";

export function RepoReadmeSection({
Expand Down Expand Up @@ -35,7 +34,7 @@ export function RepoReadmeSection({
{readme.filename}
</h2>
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
<Markdown remarkPlugins={[remarkGfm]}>{readme.content}</Markdown>
<Markdown>{readme.content}</Markdown>
</div>
</div>
);
Expand Down
44 changes: 44 additions & 0 deletions web/src/shared/ui/markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";

/**
* True when following `href` leaves this site. Same-origin paths, in-page
* anchors, and custom schemes (`buzz://`, `mailto:`) stay in the current tab —
* a new tab for those would just leave an empty one behind.
*/
export function isOffSiteHref(href: string | undefined): boolean {
if (!href) return false;
try {
const url = new URL(href, window.location.href);
return (
/^https?:$/.test(url.protocol) && url.origin !== window.location.origin
);
} catch {
return false;
}
}

/** Markdown (GitHub flavour) that opens off-site links in a new tab. */
export function Markdown({ children }: { children: string }) {
return (
<ReactMarkdown
components={{
a({ node: _node, href, ...props }) {
return isOffSiteHref(href) ? (
<a
href={href}
rel="noopener noreferrer"
target="_blank"
{...props}
/>
) : (
<a href={href} {...props} />
);
},
}}
remarkPlugins={[remarkGfm]}
>
{children}
</ReactMarkdown>
);
}
39 changes: 39 additions & 0 deletions web/tests/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,45 @@ test("invite requires age and legal consent before opening Buzz", async ({
expect(consentBox?.width).toBe(acceptButtonBox?.width);
});

test("markdown opens off-site links in a new tab, in-app links in place", async ({
page,
}) => {
await page.route("**/api/join-policy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
policy: {
terms_markdown: [
"Read the [full policy](https://example.com/terms),",
"our [help centre](/help), and [section two](#two).",
].join(" "),
age_attestation_required: false,
version: "policy-v1",
},
}),
});
});
await page.route("https://api.github.com/**", async (route) => {
await route.fulfill({ status: 500 });
});

await page.goto("/invite/demo-code");
await page.getByRole("button", { name: "Terms of Service" }).click();

const dialog = page.getByRole("dialog", { name: "Terms of Service" });
const offSite = dialog.getByRole("link", { name: "full policy" });
await expect(offSite).toHaveAttribute("target", "_blank");
await expect(offSite).toHaveAttribute("rel", /noreferrer/);

await expect(
dialog.getByRole("link", { name: "help centre" }),
).not.toHaveAttribute("target", "_blank");
await expect(
dialog.getByRole("link", { name: "section two" }),
).not.toHaveAttribute("target", "_blank");
});

test("invite can enroll a NIP-07 identity for browser access", async ({
page,
}) => {
Expand Down