mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
## Summary - Makes Projects repository-aware across browsing, branch and tag selection, local and remote source management, work items, commits, and contextual repository actions. - Refines project details into consistent single-column reading surfaces with a resizable, section-aware context pod, while keeping project conversations available in the attached chat panel. - Adds persistent sidebar project navigation, direct entity links, repository discussion channels, contributor identity matching, and consistent loading and activity presentation. - Keeps branch-specific controls on code-oriented sections while Tasks, Reviews, and Channels remain repository-scoped, reducing misleading context and actions. Replacement for #5981 with an identical final tree flattened into one signed-off commit because the required DCO check suite remained stalled. ### Related issue N/A ### Testing - [x] Desktop pre-push checks, TypeScript typecheck, and unit tests - [x] E2E production build with `pnpm build:e2e` - [x] Focused Playwright smoke coverage for project workspace, task, review, commit, sidebar, and contextual-panel behavior - [ ] Attach final before/after screenshots after design review --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com> Signed-off-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz> Co-authored-by: Wintermute <165f0c871dd2586bb18b6aa109eeaf57bb2132ff4d27b10120f4368a0f627022@buzz.block.builderlab.xyz>
684 lines
17 KiB
TypeScript
684 lines
17 KiB
TypeScript
import {
|
|
buildIssueLink,
|
|
buildProjectLink,
|
|
buildPullRequestLink,
|
|
buildRepoLink,
|
|
isEntityLink,
|
|
parseEntityLink,
|
|
type ParsedEntityLink,
|
|
} from "./entityLink";
|
|
|
|
export type SupportedLinkPreviewKind =
|
|
| "buzz-pull-request"
|
|
| "buzz-issue"
|
|
| "buzz-repository"
|
|
| "buzz-project"
|
|
| "github-pull-request"
|
|
| "github-issue"
|
|
| "github-repository"
|
|
| "linear-issue"
|
|
| "google-drive-file"
|
|
| "google-drive-folder"
|
|
| "google-docs-document"
|
|
| "google-sheets-spreadsheet"
|
|
| "google-slides-presentation"
|
|
| "generic-link";
|
|
|
|
export type SupportedLinkPreview = {
|
|
kind: SupportedLinkPreviewKind;
|
|
href: string;
|
|
provider: string;
|
|
title: string;
|
|
/** Sanitized native-fetched bitmap; never a remote URL. */
|
|
imageDataUrl?: string | null;
|
|
imageDomain?: string | null;
|
|
typeLabel:
|
|
| "PR"
|
|
| "issue"
|
|
| "Review"
|
|
| "Task"
|
|
| "repo"
|
|
| "project"
|
|
| "file"
|
|
| "folder"
|
|
| "document"
|
|
| "spreadsheet"
|
|
| "presentation"
|
|
| "link";
|
|
};
|
|
|
|
// Buzz relay hosts differ per community, so relay git URLs are recognized by
|
|
// their distinctive path shape (`/git/<64-hex-pubkey>/<repo>`) rather than by
|
|
// hostname, and require an explicit scheme. Generic previews remain HTTPS-only.
|
|
const SUPPORTED_URL_RE =
|
|
/(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo|project)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
|
|
const MARKDOWN_SUPPORTED_LINK_RE =
|
|
/!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo|project)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
|
|
const MAX_PREVIEWS = 8;
|
|
|
|
type HiddenRange = {
|
|
start: number;
|
|
end: number;
|
|
};
|
|
|
|
function maskRanges(content: string, ranges: HiddenRange[]): string {
|
|
if (ranges.length === 0) return content;
|
|
|
|
const merged: HiddenRange[] = [];
|
|
for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
|
|
const last = merged[merged.length - 1];
|
|
if (last && range.start <= last.end) {
|
|
last.end = Math.max(last.end, range.end);
|
|
} else {
|
|
merged.push({ ...range });
|
|
}
|
|
}
|
|
|
|
let masked = "";
|
|
let cursor = 0;
|
|
for (const range of merged) {
|
|
masked += content.slice(cursor, range.start);
|
|
masked += content.slice(range.start, range.end).replace(/[^\n]/g, " ");
|
|
cursor = range.end;
|
|
}
|
|
|
|
return masked + content.slice(cursor);
|
|
}
|
|
|
|
function isIndexInRanges(index: number, ranges: HiddenRange[]): boolean {
|
|
return ranges.some((range) => index >= range.start && index < range.end);
|
|
}
|
|
|
|
function overlapsRange(
|
|
start: number,
|
|
end: number,
|
|
ranges: HiddenRange[],
|
|
): boolean {
|
|
return ranges.some((range) => start < range.end && end > range.start);
|
|
}
|
|
|
|
function collectCodeRanges(content: string): HiddenRange[] {
|
|
const ranges: HiddenRange[] = [];
|
|
for (const match of content.matchAll(/```[\s\S]*?```|~~~[\s\S]*?~~~/g)) {
|
|
ranges.push({
|
|
start: match.index ?? 0,
|
|
end: (match.index ?? 0) + match[0].length,
|
|
});
|
|
}
|
|
|
|
for (const match of content.matchAll(/`[^`\n]*`/g)) {
|
|
ranges.push({
|
|
start: match.index ?? 0,
|
|
end: (match.index ?? 0) + match[0].length,
|
|
});
|
|
}
|
|
|
|
for (const match of content.matchAll(/^(?: {4}|\t).*(?:\n|$)/gm)) {
|
|
ranges.push({
|
|
start: match.index ?? 0,
|
|
end: (match.index ?? 0) + match[0].length,
|
|
});
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
function collectMarkdownImageLinkRanges(content: string): HiddenRange[] {
|
|
const ranges: HiddenRange[] = [];
|
|
|
|
for (const match of content.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
|
|
if (!match[0]?.startsWith("!")) continue;
|
|
ranges.push({
|
|
start: match.index ?? 0,
|
|
end: (match.index ?? 0) + match[0].length,
|
|
});
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
function collectBlockSpoilerRanges(
|
|
content: string,
|
|
excludedRanges: HiddenRange[],
|
|
): HiddenRange[] {
|
|
const ranges: HiddenRange[] = [];
|
|
let openStart: number | null = null;
|
|
let lineStart = 0;
|
|
|
|
while (lineStart < content.length) {
|
|
const newlineIndex = content.indexOf("\n", lineStart);
|
|
const lineEnd =
|
|
newlineIndex === -1 ? content.length : newlineIndex + "\n".length;
|
|
const line = content.slice(
|
|
lineStart,
|
|
newlineIndex === -1 ? lineEnd : newlineIndex,
|
|
);
|
|
|
|
if (
|
|
line.trim() === "||" &&
|
|
!overlapsRange(lineStart, lineEnd, excludedRanges)
|
|
) {
|
|
if (openStart == null) {
|
|
openStart = lineStart;
|
|
} else {
|
|
ranges.push({ start: openStart, end: lineEnd });
|
|
openStart = null;
|
|
}
|
|
}
|
|
|
|
lineStart = lineEnd;
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
function collectInlineSpoilerRanges(
|
|
content: string,
|
|
excludedRanges: HiddenRange[],
|
|
): HiddenRange[] {
|
|
const ranges: HiddenRange[] = [];
|
|
let openStart: number | null = null;
|
|
let index = 0;
|
|
|
|
while (index < content.length - 1) {
|
|
if (
|
|
content[index] === "|" &&
|
|
content[index + 1] === "|" &&
|
|
!isIndexInRanges(index, excludedRanges) &&
|
|
!isIndexInRanges(index + 1, excludedRanges)
|
|
) {
|
|
if (openStart == null) {
|
|
openStart = index;
|
|
} else {
|
|
ranges.push({ start: openStart, end: index + 2 });
|
|
openStart = null;
|
|
}
|
|
index += 2;
|
|
continue;
|
|
}
|
|
|
|
index += 1;
|
|
}
|
|
|
|
return ranges;
|
|
}
|
|
|
|
function stripHiddenLinkPreviewContent(content: string): string {
|
|
const codeRanges = collectCodeRanges(content);
|
|
const imageLinkRanges = collectMarkdownImageLinkRanges(content);
|
|
const nonSpoilerHiddenRanges = [...codeRanges, ...imageLinkRanges];
|
|
const blockSpoilerRanges = collectBlockSpoilerRanges(
|
|
content,
|
|
nonSpoilerHiddenRanges,
|
|
);
|
|
const inlineSpoilerRanges = collectInlineSpoilerRanges(content, [
|
|
...nonSpoilerHiddenRanges,
|
|
...blockSpoilerRanges,
|
|
]);
|
|
|
|
return maskRanges(content, [
|
|
...nonSpoilerHiddenRanges,
|
|
...blockSpoilerRanges,
|
|
...inlineSpoilerRanges,
|
|
]);
|
|
}
|
|
|
|
function countChar(value: string, char: string): number {
|
|
let count = 0;
|
|
for (const current of value) {
|
|
if (current === char) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function trimUrlCandidate(candidate: string): string {
|
|
let value = candidate.replace(/[.,!?;:]+$/g, "");
|
|
|
|
const pairs: Array<[close: string, open: string]> = [
|
|
[")", "("],
|
|
["]", "["],
|
|
["}", "{"],
|
|
];
|
|
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const [close, open] of pairs) {
|
|
if (
|
|
value.endsWith(close) &&
|
|
countChar(value, close) > countChar(value, open)
|
|
) {
|
|
value = value.slice(0, -1);
|
|
changed = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function safeDecode(value: string): string {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function normalizeHostname(parsed: URL): string {
|
|
return parsed.hostname.toLowerCase().replace(/^www\./, "");
|
|
}
|
|
|
|
function createPreview(
|
|
kind: SupportedLinkPreviewKind,
|
|
parsed: URL,
|
|
provider: SupportedLinkPreview["provider"],
|
|
typeLabel: SupportedLinkPreview["typeLabel"],
|
|
title: string,
|
|
): SupportedLinkPreview {
|
|
// Strip the `#fragment` from the preview identity. A fragment is a
|
|
// client-only anchor into the page — the preview (and the signed snapshot's
|
|
// canonicalUrl) is of the page itself. Keeping it would fail the
|
|
// fragment-free snapshot-URL guard, so a link like `pull/3767#review-1`
|
|
// would silently get no preview at all. The message body keeps the raw URL,
|
|
// so click-through to the anchor is preserved.
|
|
const canonical = new URL(parsed.href);
|
|
canonical.hash = "";
|
|
return {
|
|
kind,
|
|
href: canonical.href,
|
|
provider,
|
|
title,
|
|
typeLabel,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Placeholder title shown before (or instead of) the relay event lookup in
|
|
* `useResolvedLinkPreviews` resolves the real `subject` / repo name.
|
|
* Exported so the resolver can tell "still the fallback" apart from a
|
|
* markdown-label override it must not overwrite.
|
|
*/
|
|
export function buzzEntityFallbackTitle(link: ParsedEntityLink): string {
|
|
if (link.type === "repo" || link.type === "project") return link.dtag;
|
|
return `${link.dtag} #${link.id.slice(0, 8)}`;
|
|
}
|
|
|
|
/**
|
|
* Map a `buzz://pr|issue|repo|project` deep link onto a preview card. The
|
|
* href is rebuilt through the canonical builders so equivalent links (case
|
|
* or query order variants) dedupe to a single card.
|
|
*/
|
|
function parseBuzzEntityPreview(href: string): SupportedLinkPreview | null {
|
|
const parsed = parseEntityLink(href);
|
|
if (!parsed.ok) return null;
|
|
|
|
const link = parsed.value;
|
|
const title = buzzEntityFallbackTitle(link);
|
|
if (link.type === "pr") {
|
|
return {
|
|
kind: "buzz-pull-request",
|
|
href: buildPullRequestLink(link),
|
|
provider: "Buzz",
|
|
title,
|
|
typeLabel: "Review",
|
|
};
|
|
}
|
|
if (link.type === "issue") {
|
|
return {
|
|
kind: "buzz-issue",
|
|
href: buildIssueLink(link),
|
|
provider: "Buzz",
|
|
title,
|
|
typeLabel: "Task",
|
|
};
|
|
}
|
|
if (link.type === "project") {
|
|
return {
|
|
kind: "buzz-project",
|
|
href: buildProjectLink(link),
|
|
provider: "Buzz",
|
|
title,
|
|
typeLabel: "project",
|
|
};
|
|
}
|
|
return {
|
|
kind: "buzz-repository",
|
|
href: buildRepoLink(link),
|
|
provider: "Buzz",
|
|
title,
|
|
typeLabel: "repo",
|
|
};
|
|
}
|
|
|
|
const BUZZ_GIT_PATH_RE =
|
|
/^\/git\/([a-f0-9]{64})\/([a-zA-Z0-9._-]+?)(?:\.git)?\/?$/;
|
|
|
|
/**
|
|
* Recognize a Buzz relay git URL (`{relay-origin}/git/<owner-pubkey>/<repo>`,
|
|
* the clone URL shape agents paste when announcing work). The preview href
|
|
* is normalized to the canonical `buzz://repo` deep link: the raw git
|
|
* transport endpoint is not a browsable page, and the buzz:// href gives the
|
|
* card the same in-app click navigation as explicit entity links (and
|
|
* dedupes the two spellings of the same repository).
|
|
*
|
|
* Security: the URL origin must equal `activeRelayOrigin` (the currently
|
|
* connected relay). Path shape alone is not proof that a host belongs to the
|
|
* active Buzz relay — an arbitrary external URL sharing the path shape must
|
|
* remain an ordinary external link. Pass `null` when the relay origin is not
|
|
* yet resolved; the link stays external until it can be verified.
|
|
*/
|
|
function parseBuzzGitLink(
|
|
parsed: URL,
|
|
activeRelayOrigin: string | null,
|
|
): SupportedLinkPreview | null {
|
|
if (!activeRelayOrigin || parsed.origin !== activeRelayOrigin) {
|
|
return null;
|
|
}
|
|
|
|
const match = BUZZ_GIT_PATH_RE.exec(parsed.pathname);
|
|
if (!match) return null;
|
|
|
|
const [, owner, repo] = match;
|
|
if (repo.startsWith(".") || repo.includes("..") || repo.length > 64) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
kind: "buzz-repository",
|
|
href: buildRepoLink({ owner, dtag: repo }),
|
|
provider: "Buzz",
|
|
title: repo,
|
|
typeLabel: "repo",
|
|
};
|
|
}
|
|
|
|
function parseGithubLink(parsed: URL): SupportedLinkPreview | null {
|
|
if (normalizeHostname(parsed) !== "github.com") {
|
|
return null;
|
|
}
|
|
|
|
const segments = parsed.pathname.split("/").filter(Boolean).map(safeDecode);
|
|
const [owner, repo, resource, number] = segments;
|
|
if (!owner || !repo) return null;
|
|
|
|
const repoLabel = `${owner}/${repo}`;
|
|
if (resource === undefined) {
|
|
return createPreview(
|
|
"github-repository",
|
|
parsed,
|
|
"GitHub",
|
|
"repo",
|
|
repoLabel,
|
|
);
|
|
}
|
|
|
|
if (/^\d+$/.test(number ?? "")) {
|
|
if (resource === "pull") {
|
|
return createPreview(
|
|
"github-pull-request",
|
|
parsed,
|
|
"GitHub",
|
|
"PR",
|
|
`${repoLabel} #${number}`,
|
|
);
|
|
}
|
|
|
|
if (resource === "issues") {
|
|
return createPreview(
|
|
"github-issue",
|
|
parsed,
|
|
"GitHub",
|
|
"issue",
|
|
`${repoLabel} #${number}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseLinearIssue(parsed: URL): SupportedLinkPreview | null {
|
|
if (normalizeHostname(parsed) !== "linear.app") {
|
|
return null;
|
|
}
|
|
|
|
const segments = parsed.pathname.split("/").filter(Boolean).map(safeDecode);
|
|
const issueSegmentIndex = segments.findIndex(
|
|
(segment) => segment.toLowerCase() === "issue",
|
|
);
|
|
const community = segments[0];
|
|
const issueId = segments[issueSegmentIndex + 1]?.toUpperCase();
|
|
|
|
if (
|
|
!community ||
|
|
issueSegmentIndex < 1 ||
|
|
!issueId ||
|
|
!/^[A-Z][A-Z0-9]*-\d+$/.test(issueId)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return createPreview("linear-issue", parsed, "Linear", "issue", issueId);
|
|
}
|
|
|
|
function parseGoogleDriveLink(parsed: URL): SupportedLinkPreview | null {
|
|
if (normalizeHostname(parsed) !== "drive.google.com") {
|
|
return null;
|
|
}
|
|
|
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
const folderSegmentIndex = segments.findIndex(
|
|
(segment) => segment.toLowerCase() === "folders",
|
|
);
|
|
|
|
if (folderSegmentIndex >= 0 && segments[folderSegmentIndex + 1]) {
|
|
return createPreview(
|
|
"google-drive-folder",
|
|
parsed,
|
|
"Google Drive",
|
|
"folder",
|
|
"Drive folder",
|
|
);
|
|
}
|
|
|
|
if (
|
|
(segments[0] === "file" && segments[1] === "d" && segments[2]) ||
|
|
(segments[0] === "open" && parsed.searchParams.has("id"))
|
|
) {
|
|
return createPreview(
|
|
"google-drive-file",
|
|
parsed,
|
|
"Google Drive",
|
|
"file",
|
|
"Drive file",
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null {
|
|
if (normalizeHostname(parsed) !== "docs.google.com") {
|
|
return null;
|
|
}
|
|
|
|
const segments = parsed.pathname.split("/").filter(Boolean);
|
|
const [resource, dSegment, id] = segments;
|
|
if (dSegment !== "d" || !id) return null;
|
|
|
|
if (resource === "document") {
|
|
return createPreview(
|
|
"google-docs-document",
|
|
parsed,
|
|
"Google Docs",
|
|
"document",
|
|
"Document",
|
|
);
|
|
}
|
|
|
|
if (resource === "spreadsheets") {
|
|
return createPreview(
|
|
"google-sheets-spreadsheet",
|
|
parsed,
|
|
"Google Sheets",
|
|
"spreadsheet",
|
|
"Spreadsheet",
|
|
);
|
|
}
|
|
|
|
if (resource === "presentation") {
|
|
return createPreview(
|
|
"google-slides-presentation",
|
|
parsed,
|
|
"Google Slides",
|
|
"presentation",
|
|
"Presentation",
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** Parse a supported external URL into a compact preview. */
|
|
export function parseSupportedLinkPreview(
|
|
href: string,
|
|
activeRelayOrigin?: string | null,
|
|
): SupportedLinkPreview | null {
|
|
const candidate = trimUrlCandidate(href);
|
|
if (isEntityLink(candidate)) {
|
|
return parseBuzzEntityPreview(candidate);
|
|
}
|
|
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(
|
|
/^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
return null;
|
|
}
|
|
|
|
const recognized =
|
|
parseBuzzGitLink(parsed, activeRelayOrigin ?? null) ??
|
|
parseGithubLink(parsed) ??
|
|
parseLinearIssue(parsed) ??
|
|
parseGoogleDriveLink(parsed) ??
|
|
parseGoogleDocsLink(parsed);
|
|
if (recognized) return recognized;
|
|
const hostname = normalizeHostname(parsed);
|
|
if (
|
|
parsed.protocol !== "https:" ||
|
|
[
|
|
"github.com",
|
|
"linear.app",
|
|
"drive.google.com",
|
|
"docs.google.com",
|
|
].includes(hostname)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const provider = hostname;
|
|
return createPreview("generic-link", parsed, provider, "link", provider);
|
|
}
|
|
|
|
export function isSupportedLinkAutolinkLabel(
|
|
label: string,
|
|
preview: SupportedLinkPreview,
|
|
activeRelayOrigin?: string | null,
|
|
): boolean {
|
|
return (
|
|
parseSupportedLinkPreview(label, activeRelayOrigin)?.href === preview.href
|
|
);
|
|
}
|
|
|
|
function titleFromMarkdownLabel(
|
|
label: string,
|
|
preview: SupportedLinkPreview,
|
|
activeRelayOrigin: string | null,
|
|
): string | null {
|
|
const title = label.replace(/\s+/g, " ").trim();
|
|
if (
|
|
!title ||
|
|
isSupportedLinkAutolinkLabel(title, preview, activeRelayOrigin)
|
|
) {
|
|
return null;
|
|
}
|
|
return title;
|
|
}
|
|
|
|
function withTitle(
|
|
preview: SupportedLinkPreview,
|
|
title: string | null,
|
|
): SupportedLinkPreview {
|
|
return title ? { ...preview, title } : preview;
|
|
}
|
|
|
|
type LinkPreviewCandidate = {
|
|
href: string;
|
|
index: number;
|
|
label?: string;
|
|
order: number;
|
|
};
|
|
|
|
/** Extract supported link previews from message text, preserving first-seen order. */
|
|
export function extractSupportedLinkPreviews(
|
|
content: string,
|
|
activeRelayOrigin?: string | null,
|
|
): SupportedLinkPreview[] {
|
|
const previews: SupportedLinkPreview[] = [];
|
|
const seen = new Set<string>();
|
|
const searchable = stripHiddenLinkPreviewContent(content);
|
|
const candidates: LinkPreviewCandidate[] = [];
|
|
let order = 0;
|
|
|
|
for (const match of searchable.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
|
|
if (match[0]?.startsWith("!")) continue;
|
|
candidates.push({
|
|
href: match[2],
|
|
index: match.index ?? 0,
|
|
label: match[1],
|
|
order,
|
|
});
|
|
order += 1;
|
|
}
|
|
|
|
for (const match of searchable.matchAll(SUPPORTED_URL_RE)) {
|
|
const prefix = match[1] ?? "";
|
|
const href = match[2];
|
|
if (!href) continue;
|
|
candidates.push({
|
|
href,
|
|
index: (match.index ?? 0) + prefix.length,
|
|
order,
|
|
});
|
|
order += 1;
|
|
}
|
|
|
|
candidates.sort((a, b) => a.index - b.index || a.order - b.order);
|
|
|
|
const relayOrigin = activeRelayOrigin ?? null;
|
|
for (const candidate of candidates) {
|
|
const preview = parseSupportedLinkPreview(candidate.href, relayOrigin);
|
|
if (!preview || seen.has(preview.href)) continue;
|
|
|
|
seen.add(preview.href);
|
|
previews.push(
|
|
withTitle(
|
|
preview,
|
|
candidate.label
|
|
? titleFromMarkdownLabel(candidate.label, preview, relayOrigin)
|
|
: null,
|
|
),
|
|
);
|
|
if (previews.length >= MAX_PREVIEWS) break;
|
|
}
|
|
|
|
return previews;
|
|
}
|