源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import type { UiLanguage } from "../../contexts/settings/domain/agentSettings";
|
||||
import { normalizeAgentTitleText } from "./agentTitleText.ts";
|
||||
import {
|
||||
isRichTextMentionHref,
|
||||
parseRichTextMentionHref
|
||||
} from "@tutti-os/ui-rich-text/core";
|
||||
|
||||
const MARKDOWN_LINK_PATTERN = /\[((?:\\.|[^\]\\])*)\]\(([^)\s]+)\)/g;
|
||||
const MARKDOWN_LABEL_ESCAPE_PATTERN = /\\([\\[\]()])/g;
|
||||
const SESSION_MENTION_DISPLAY_PREFIX_BY_LANGUAGE: Record<UiLanguage, string> = {
|
||||
"zh-CN": "@会话 · ",
|
||||
en: "@session · "
|
||||
};
|
||||
const PLAIN_SESSION_AGENT_LABELS = [
|
||||
"Claude Code",
|
||||
"Hermes Agent",
|
||||
"OpenClaw",
|
||||
"Nexight",
|
||||
"Hermes",
|
||||
"Codex"
|
||||
] as const;
|
||||
|
||||
export interface FormatAgentSessionMentionTextOptions {
|
||||
language?: UiLanguage;
|
||||
}
|
||||
|
||||
export function formatAgentSessionMentionText(
|
||||
value: string | null | undefined,
|
||||
options: FormatAgentSessionMentionTextOptions = {}
|
||||
): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
const sessionMentionDisplayPrefix = sessionMentionDisplayPrefixForLanguage(
|
||||
options.language
|
||||
);
|
||||
|
||||
const withSessionMentionsNormalized = trimmed.replace(
|
||||
MARKDOWN_LINK_PATTERN,
|
||||
(fullMatch, rawLabel: string, href: string) => {
|
||||
const label = unescapeMarkdownLabel(rawLabel);
|
||||
const mention = parseRichTextMentionHref(href, label);
|
||||
if (!mention) {
|
||||
return isRichTextMentionHref(href) ? label : fullMatch;
|
||||
}
|
||||
const providerId = mention.providerId.trim().toLowerCase();
|
||||
const displayLabel = label || mention.label;
|
||||
if (providerId === "agent-session") {
|
||||
return formatSessionLabel(displayLabel, sessionMentionDisplayPrefix);
|
||||
}
|
||||
if (providerId === "workspace-issue") {
|
||||
return formatIssueLabel(displayLabel);
|
||||
}
|
||||
return displayLabel;
|
||||
}
|
||||
);
|
||||
const normalized = normalizeAgentTitleText(withSessionMentionsNormalized);
|
||||
if (isPlainSessionMentionText(normalized, sessionMentionDisplayPrefix)) {
|
||||
return formatSessionLabel(normalized, sessionMentionDisplayPrefix);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPlainSessionMentionText(
|
||||
value: string,
|
||||
sessionMentionDisplayPrefix: string
|
||||
): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (
|
||||
!trimmed.startsWith("@") ||
|
||||
trimmed.startsWith(sessionMentionDisplayPrefix)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return PLAIN_SESSION_AGENT_LABELS.some((agentLabel) => {
|
||||
const ampersandSeparator = ` & ${agentLabel}`;
|
||||
const ampersandIndex = trimmed.indexOf(ampersandSeparator);
|
||||
if (ampersandIndex > 1) {
|
||||
const trailing = trimmed.slice(
|
||||
ampersandIndex + ampersandSeparator.length
|
||||
);
|
||||
if (trailing === "" || trailing.startsWith(" ")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const dottedSeparator = ` · ${agentLabel} · `;
|
||||
return trimmed.indexOf(dottedSeparator) > 1;
|
||||
});
|
||||
}
|
||||
|
||||
function sessionMentionDisplayPrefixForLanguage(
|
||||
language: UiLanguage | undefined
|
||||
): string {
|
||||
return SESSION_MENTION_DISPLAY_PREFIX_BY_LANGUAGE[language ?? "en"];
|
||||
}
|
||||
|
||||
function formatSessionLabel(
|
||||
label: string,
|
||||
sessionMentionDisplayPrefix: string
|
||||
): string {
|
||||
const normalizedLabel = label.trim().replace(/^@+/, "").trim();
|
||||
return normalizedLabel
|
||||
? `${sessionMentionDisplayPrefix}${normalizedLabel}`
|
||||
: label.trim();
|
||||
}
|
||||
|
||||
function formatIssueLabel(label: string): string {
|
||||
const normalizedLabel = label.trim().replace(/^@+/, "").trim();
|
||||
return normalizedLabel ? `@Task · ${normalizedLabel}` : label.trim();
|
||||
}
|
||||
|
||||
function unescapeMarkdownLabel(label: string): string {
|
||||
return label.replace(MARKDOWN_LABEL_ESCAPE_PATTERN, "$1");
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeAgentTitleText } from "./agentTitleText";
|
||||
|
||||
describe("normalizeAgentTitleText", () => {
|
||||
const fixtures = JSON.parse(
|
||||
readFileSync(resolve(process.cwd(), "../titletext-fixtures.json"), "utf8")
|
||||
) as Array<{ input: string; name: string; normalized: string }>;
|
||||
|
||||
it.each(fixtures)("normalizes $name", ({ input, normalized }) => {
|
||||
expect(normalizeAgentTitleText(input)).toBe(normalized);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
export function normalizeAgentTitleText(
|
||||
value: string | null | undefined
|
||||
): string {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
let normalized = "";
|
||||
for (let index = 0; index < trimmed.length; ) {
|
||||
const link = markdownLinkAt(trimmed, index);
|
||||
if (!link) {
|
||||
normalized += trimmed[index];
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
normalized += unescapeMarkdownLabel(
|
||||
trimmed.slice(link.labelStart, link.labelEnd)
|
||||
);
|
||||
index = link.hrefEnd + 1;
|
||||
}
|
||||
return normalized.replace(/\s+/gu, " ").trim();
|
||||
}
|
||||
|
||||
function unescapeMarkdownLabel(label: string): string {
|
||||
let normalized = "";
|
||||
let escaped = false;
|
||||
for (const character of label) {
|
||||
if (escaped) {
|
||||
normalized += "\\[]()".includes(character) ? character : `\\${character}`;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
normalized += character;
|
||||
}
|
||||
return escaped ? `${normalized}\\` : normalized;
|
||||
}
|
||||
|
||||
function markdownLinkAt(
|
||||
value: string,
|
||||
start: number
|
||||
): {
|
||||
hrefEnd: number;
|
||||
labelEnd: number;
|
||||
labelStart: number;
|
||||
} | null {
|
||||
if (value[start] !== "[") {
|
||||
return null;
|
||||
}
|
||||
const labelEnd = findUnescaped(value, start + 1, "]");
|
||||
if (labelEnd < 0 || value[labelEnd + 1] !== "(") {
|
||||
return null;
|
||||
}
|
||||
const hrefEnd = findBalancedHrefEnd(value, labelEnd + 2);
|
||||
return hrefEnd < 0 ? null : { hrefEnd, labelEnd, labelStart: start + 1 };
|
||||
}
|
||||
|
||||
function findUnescaped(value: string, start: number, target: string): number {
|
||||
let escaped = false;
|
||||
for (let index = start; index < value.length; index += 1) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (value[index] === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (value[index] === target) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findBalancedHrefEnd(value: string, start: number): number {
|
||||
let depth = 0;
|
||||
let escaped = false;
|
||||
for (let index = start; index < value.length; index += 1) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
switch (value[index]) {
|
||||
case "\\":
|
||||
escaped = true;
|
||||
break;
|
||||
case "(":
|
||||
depth += 1;
|
||||
break;
|
||||
case ")":
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
depth -= 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
AgentHostManagedAgentsStateItem,
|
||||
AgentHostManagedAgentsState
|
||||
} from "../contracts/dto";
|
||||
import type { AgentProvider } from "../../contexts/settings/domain/agentSettings";
|
||||
import { resolveMigratedAgentGUIProviderIdentity } from "../../providerIdentityCatalog.ts";
|
||||
|
||||
export type AgentHostManagedToolchainAgent = {
|
||||
id: string;
|
||||
label: string;
|
||||
actionAgentId?: string;
|
||||
toolIds: string[];
|
||||
agentIds: string[];
|
||||
runtimeManaged: boolean;
|
||||
helperProvider?:
|
||||
| "codex"
|
||||
| "claude"
|
||||
| "cursor"
|
||||
| "openclaw"
|
||||
| "opencode"
|
||||
| "nexight";
|
||||
aliases?: string[];
|
||||
};
|
||||
|
||||
export type AgentHostManagedToolchainActionKind =
|
||||
| "installed"
|
||||
| "sync"
|
||||
| "install";
|
||||
|
||||
export const AGENT_HOST_MANAGED_TOOLCHAIN_AGENTS: readonly AgentHostManagedToolchainAgent[] =
|
||||
[
|
||||
{
|
||||
id: "claude-code",
|
||||
// i18n-check-ignore: Provider brand name.
|
||||
label: "Claude Code",
|
||||
toolIds: ["claude-code-cli"],
|
||||
agentIds: ["claude-code"],
|
||||
runtimeManaged: true,
|
||||
helperProvider: "claude",
|
||||
aliases: ["claude code", "claude"]
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
label: migratedProviderDisplayName("codex"),
|
||||
toolIds: ["codex-cli"],
|
||||
agentIds: ["codex"],
|
||||
runtimeManaged: true,
|
||||
helperProvider: "codex"
|
||||
},
|
||||
{
|
||||
id: "cursor",
|
||||
// i18n-check-ignore: Provider brand name.
|
||||
label: "Cursor",
|
||||
toolIds: ["cursor-cli"],
|
||||
agentIds: ["cursor"],
|
||||
runtimeManaged: true,
|
||||
helperProvider: "cursor",
|
||||
aliases: ["cursor cli", "cursor agent", "cursor-agent"]
|
||||
},
|
||||
{
|
||||
id: "tutti",
|
||||
// i18n-check-ignore: Provider brand name.
|
||||
label: "Tutti",
|
||||
actionAgentId: "nexight",
|
||||
toolIds: ["nexight-cli"],
|
||||
agentIds: ["nexight", "tutti"],
|
||||
runtimeManaged: false,
|
||||
helperProvider: "nexight"
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
// i18n-check-ignore: Provider brand name.
|
||||
label: "OpenClaw",
|
||||
toolIds: ["openclaw-cli"],
|
||||
agentIds: ["openclaw"],
|
||||
runtimeManaged: true,
|
||||
helperProvider: "openclaw",
|
||||
aliases: ["open claw"]
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
label: migratedProviderDisplayName("opencode"),
|
||||
toolIds: ["opencode-cli"],
|
||||
agentIds: ["opencode"],
|
||||
runtimeManaged: true,
|
||||
helperProvider: "opencode",
|
||||
aliases: migratedProviderAliases("opencode")
|
||||
}
|
||||
] as const;
|
||||
|
||||
function migratedProviderDisplayName(providerId: string): string {
|
||||
const identity = resolveMigratedAgentGUIProviderIdentity(providerId);
|
||||
if (!identity) {
|
||||
throw new Error(`Missing migrated provider identity for ${providerId}`);
|
||||
}
|
||||
return identity.displayName;
|
||||
}
|
||||
|
||||
function migratedProviderAliases(providerId: string): string[] {
|
||||
const identity = resolveMigratedAgentGUIProviderIdentity(providerId);
|
||||
if (!identity) {
|
||||
throw new Error(`Missing migrated provider identity for ${providerId}`);
|
||||
}
|
||||
return [...identity.aliases];
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace Dock 中托管 Agent 图标顺序,与 Manage Agents 页面列表(`AGENT_HOST_MANAGED_TOOLCHAIN_AGENTS`)一致。
|
||||
*/
|
||||
export const WORKSPACE_DESKTOP_MANAGED_AGENT_DOCK_PROVIDER_ORDER: readonly AgentProvider[] =
|
||||
AGENT_HOST_MANAGED_TOOLCHAIN_AGENTS.map((agent) =>
|
||||
agent.id === "tutti" ? "tutti-agent" : (agent.id as AgentProvider)
|
||||
);
|
||||
|
||||
function normalizeKey(value: string | null | undefined): string {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function hasAnyAgentState(
|
||||
stateAgentIds: Set<string>,
|
||||
agent: AgentHostManagedToolchainAgent
|
||||
): boolean {
|
||||
return agent.agentIds.some((candidate) =>
|
||||
stateAgentIds.has(normalizeKey(candidate))
|
||||
);
|
||||
}
|
||||
|
||||
function hasHostConfig(
|
||||
item: AgentHostManagedAgentsStateItem | undefined
|
||||
): boolean {
|
||||
return Boolean(item?.hostConfigDetected);
|
||||
}
|
||||
|
||||
export function findAgentHostManagedAgentsStateItemIndex(
|
||||
agent: AgentHostManagedToolchainAgent,
|
||||
managedAgentsState: AgentHostManagedAgentsState | null
|
||||
): number {
|
||||
if (!managedAgentsState) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return managedAgentsState.items.findIndex((item) => {
|
||||
const toolId = normalizeKey(item.toolId);
|
||||
const agentId = normalizeKey(item.agentId);
|
||||
return (
|
||||
agent.toolIds.some((candidate) => normalizeKey(candidate) === toolId) ||
|
||||
agent.agentIds.some((candidate) => normalizeKey(candidate) === agentId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Managed toolchain agent action used by runtime/home projections. */
|
||||
export function resolveAgentHostManagedToolchainAgentAction(
|
||||
agent: AgentHostManagedToolchainAgent,
|
||||
managedAgentsState: AgentHostManagedAgentsState | null
|
||||
): AgentHostManagedToolchainActionKind {
|
||||
const stateItemIndex = findAgentHostManagedAgentsStateItemIndex(
|
||||
agent,
|
||||
managedAgentsState
|
||||
);
|
||||
const pendingStateItem =
|
||||
stateItemIndex >= 0 ? managedAgentsState?.items[stateItemIndex] : undefined;
|
||||
return resolveAgentHostManagedToolchainAction(
|
||||
agent,
|
||||
pendingStateItem,
|
||||
managedAgentsState
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAgentHostManagedToolchainAction(
|
||||
agent: AgentHostManagedToolchainAgent,
|
||||
pendingStateItem: AgentHostManagedAgentsStateItem | undefined,
|
||||
managedAgentsState: AgentHostManagedAgentsState | null
|
||||
): AgentHostManagedToolchainActionKind {
|
||||
// Managed Agents show Installed only when the agent is ready for AgentGUI use.
|
||||
if (!managedAgentsState) {
|
||||
return "install";
|
||||
}
|
||||
|
||||
const readyAgentIds = new Set(
|
||||
(managedAgentsState.readyAgentIds ?? []).map(normalizeKey)
|
||||
);
|
||||
if (agent.id === "openclaw") {
|
||||
if (readyAgentIds.has("openclaw")) {
|
||||
return "installed";
|
||||
}
|
||||
|
||||
return hasHostConfig(pendingStateItem) ? "sync" : "install";
|
||||
}
|
||||
|
||||
if (hasAnyAgentState(readyAgentIds, agent)) {
|
||||
return "installed";
|
||||
}
|
||||
|
||||
if (pendingStateItem) {
|
||||
return hasHostConfig(pendingStateItem) ? "sync" : "install";
|
||||
}
|
||||
|
||||
return "install";
|
||||
}
|
||||
|
||||
export function getAgentHostManagedToolchainAgentByName(
|
||||
name: string
|
||||
): AgentHostManagedToolchainAgent | null {
|
||||
const normalized = normalizeKey(name);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
AGENT_HOST_MANAGED_TOOLCHAIN_AGENTS.find((agent) =>
|
||||
[
|
||||
agent.id,
|
||||
agent.label,
|
||||
agent.actionAgentId,
|
||||
...agent.agentIds,
|
||||
...(agent.aliases ?? [])
|
||||
].some((candidate) => normalizeKey(candidate) === normalized)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
const PREFERRED_STRUCTURED_TEXT_KEYS = [
|
||||
"plan",
|
||||
"text",
|
||||
"summary",
|
||||
"result",
|
||||
"output",
|
||||
"error",
|
||||
"message",
|
||||
"detail",
|
||||
"details",
|
||||
"reason",
|
||||
"description",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"query",
|
||||
"path",
|
||||
"file",
|
||||
"filePath",
|
||||
"file_path",
|
||||
"url",
|
||||
"cmd",
|
||||
"command",
|
||||
"content",
|
||||
"data",
|
||||
"response",
|
||||
"status",
|
||||
"account",
|
||||
"usage"
|
||||
] as const;
|
||||
|
||||
const MAX_STRUCTURED_TEXT_DEPTH = 5;
|
||||
const MAX_STRUCTURED_TEXT_ITEMS = 32;
|
||||
const MAX_STRUCTURED_TEXT_LENGTH = 16_000;
|
||||
|
||||
/**
|
||||
* Extracts a bounded human-readable value from provider-shaped tool data.
|
||||
* Provider error envelopes are not stable across adapters, so this helper
|
||||
* intentionally follows a small set of semantic keys instead of serializing
|
||||
* arbitrary payloads. Nested provider envelopes are covered by the same keys
|
||||
* at each bounded depth.
|
||||
*/
|
||||
export function structuredToolText(value: unknown): string | null {
|
||||
const text = visitStructuredToolText(value, 0, new WeakSet<object>());
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
return text.length > MAX_STRUCTURED_TEXT_LENGTH
|
||||
? text.slice(0, MAX_STRUCTURED_TEXT_LENGTH)
|
||||
: text;
|
||||
}
|
||||
|
||||
function visitStructuredToolText(
|
||||
value: unknown,
|
||||
depth: number,
|
||||
seen: WeakSet<object>
|
||||
): string | null {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
depth > MAX_STRUCTURED_TEXT_DEPTH
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return null;
|
||||
}
|
||||
seen.add(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.slice(0, MAX_STRUCTURED_TEXT_ITEMS)
|
||||
.map((entry) => visitStructuredToolText(entry, depth + 1, seen))
|
||||
.filter((entry): entry is string => Boolean(entry));
|
||||
return parts.length > 0 ? parts.join("\n\n") : null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of PREFERRED_STRUCTURED_TEXT_KEYS) {
|
||||
const text = visitStructuredToolText(record[key], depth + 1, seen);
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
const ALLOWED_WEBSITE_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
export const DEFAULT_WEBSITE_NODE_URL = "https://www.google.com/";
|
||||
const GOOGLE_SEARCH_BASE_URL = "https://www.google.com/search";
|
||||
const LIKELY_HOST_PATTERN =
|
||||
/^(localhost|(\d{1,3}\.){3}\d{1,3}|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::\d{1,5})?(?:[/?#][^\s]*)?$/i;
|
||||
const EXPLICIT_PROTOCOL_PATTERN = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//;
|
||||
const LOOPBACK_HOST_PATTERN =
|
||||
/^(localhost|127(?:\.\d{1,3}){3})(?::\d{1,5})?(?:[/?#][^\s]*)?$/i;
|
||||
|
||||
function defaultSchemeForHostInput(value: string): "http" | "https" {
|
||||
return LOOPBACK_HOST_PATTERN.test(value) ? "http" : "https";
|
||||
}
|
||||
|
||||
export function resolveWebsiteNavigationUrl(rawUrl: string): {
|
||||
url: string | null;
|
||||
error: string | null;
|
||||
} {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { url: null, error: null };
|
||||
}
|
||||
|
||||
if (EXPLICIT_PROTOCOL_PATTERN.test(trimmed)) {
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (!ALLOWED_WEBSITE_PROTOCOLS.has(parsed.protocol)) {
|
||||
return { url: null, error: `Unsupported protocol: ${parsed.protocol}` };
|
||||
}
|
||||
|
||||
return { url: parsed.toString(), error: null };
|
||||
} catch {
|
||||
return { url: null, error: "Invalid URL" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!LIKELY_HOST_PATTERN.test(trimmed)) {
|
||||
return { url: null, error: "Invalid URL" };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(
|
||||
`${defaultSchemeForHostInput(trimmed)}://${trimmed}`
|
||||
);
|
||||
return { url: parsed.toString(), error: null };
|
||||
} catch {
|
||||
return { url: null, error: "Invalid URL" };
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebsiteUrlAllowedForNavigation(rawUrl: string): boolean {
|
||||
const resolved = resolveWebsiteNavigationUrl(rawUrl);
|
||||
return resolved.url !== null && resolved.error === null;
|
||||
}
|
||||
|
||||
export function resolveWebsiteAddressInput(rawInput: string): {
|
||||
url: string | null;
|
||||
error: string | null;
|
||||
} {
|
||||
const trimmed = rawInput.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { url: null, error: null };
|
||||
}
|
||||
|
||||
const navigation = resolveWebsiteNavigationUrl(trimmed);
|
||||
if (navigation.url) {
|
||||
return navigation;
|
||||
}
|
||||
|
||||
const searchUrl = new URL(GOOGLE_SEARCH_BASE_URL);
|
||||
searchUrl.searchParams.set("q", trimmed);
|
||||
return { url: searchUrl.toString(), error: null };
|
||||
}
|
||||
|
||||
export function normalizeWebsiteComparableUrl(rawUrl: string): string | null {
|
||||
const resolved = resolveWebsiteNavigationUrl(rawUrl);
|
||||
return resolved.error === null ? resolved.url : null;
|
||||
}
|
||||
Reference in New Issue
Block a user