import { stringToTa, uxToHex } from "@urbit/api"; import { parseToRgba } from "color2k"; import { isValidPatp } from "urbit-ob"; export function encodeLookup(value: string | undefined) { if (!value) { return ''; } return stringToTa(value).replace('~.', '~~'); } export function isColor(color: string): boolean { try { parseToRgba(color); return true; } catch (error) { return false; } } export function getColor(image: string): string | undefined { return isColor(image) ? image : undefined; } export function getImage(image: string): string | undefined { return isColor(image) ? undefined : image; } export function normalizeColor(color: string): string { if (color.startsWith('#')) { return color; } return `#${uxToHex(color)}` } export function normalizeLink(link: string): string { // ref or URL format if (!link.startsWith('web+urbitgraph://') || link.startsWith('http')) { return link; } // old format const normLink = link.replace('web+urbitgraph://', ''); const parts = normLink.split('/'); const first = parts[0]; if (first !== 'group') { return `/1/desk/${normLink}`; } if (parts.length === 3) { return `/1/group/${parts[1]}/${parts[2]}`; } return `/1/chan/diary/${parts[4]}/${parts[5]}/note/${parts[6]}` } // Tlon web routes (tlon-apps packages/app/navigation/linking.ts): // /apps/groups/group/:groupId[/channel/:channelId], ids URI-encoded. export function groupURL(flag: string): string { return `/apps/groups/group/${encodeURIComponent(flag)}`; } export function channelURL(flag: string, nest: string): string { return `${groupURL(flag)}/channel/${encodeURIComponent(nest)}`; } export function refToURL(cite: string): string | { nest: string, where: string } { // plain web links (e.g. manual listings) are used as they are if (/^https?:\/\//i.test(cite)) { return cite; } const parts = cite.slice(1).split('/'); if (parts[0] !== '1') { return ''; } const type = parts[1]; if (type === 'desk') { const ship = parts[2]; const desk = parts[3]; return `/apps/landscape/search/${ship}/apps/${ship}/${desk}`; } if (type === 'group') { const ship = parts[2]; const name = parts[3]; return groupURL(`${ship}/${name}`); } const chanType = parts[2]; const ship = parts[3]; const name = parts[4]; return { nest: `${chanType}/${ship}/${name}`, where: parts.slice(5).join('/'), } } // a number as Hoon prints @ud: `slav %ud` reads 1.000 but not 1000 // (@urbit/api's decToUd turns 0 into '') export function formatUd(n: number): string { return String(n).replace(/\B(?=(\d{3})+$)/g, '.'); } const encoder = new TextEncoder(); // lib/sphinx +lengths-valid limits, in UTF-8 bytes export const MAX_TITLE_BYTES = 77; export const MAX_DESCRIPTION_BYTES = 256; export const MAX_URL_BYTES = 1024; export const MAX_TAGS = 8; // the longest tag +catalog indexes export const MAX_TAG_BYTES = 64; export function byteLength(text: string): number { return encoder.encode(text).length; } // lib/validate-url (de-purl:html in zuse) as regular expressions: an optional // scheme://host[:port] (host a DNS name or IPv4, or a ~ship without a port), // then an optional /path and ?key=value&key=value query. No #fragment, no // spaces or non-ASCII, none of | [ ] { } ^ ` \ and no '=' inside a value. // %sphinx silently drops a listing whose image or link fails this. const QUERY_CHAR = "(?:[A-Za-z0-9_.!~*'()+/?:,-]|%[0-9A-Fa-f]{2})"; const PATH_CHAR = "(?:[A-Za-z0-9_.!~*'()$&+,;=:@/-]|%[0-9A-Fa-f]{2})"; const PAIR = `${QUERY_CHAR}+(?:=${QUERY_CHAR}*)?`; const PATH_QUERY = `(?:/${PATH_CHAR}*)?(?:[?](?:${PAIR}(?:[&;]${PAIR})*)?)?`; const SCHEME = '[A-Za-z][A-Za-z0-9+.-]*://'; const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?'; const LAST_LABEL = '[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?'; const OCTET = '(?:0|[1-9][0-9]{0,2})'; const HOST = `(?:(?:${LABEL}[.])*${LAST_LABEL}|${OCTET}(?:[.]${OCTET}){3})(?::(?:0|[1-9][0-9]*))?`; // the shape of a @p; isListingLink checks the syllables with isValidPatp const SHIP = '~[a-z]{3}(?:[a-z]{3})?(?:--?[a-z]{6})*'; const IMAGE_RE = new RegExp(`^${SCHEME}${HOST}${PATH_QUERY}$`); const LINK_RE = new RegExp(`^(?:${SCHEME}(?:${HOST}|(${SHIP})))?${PATH_QUERY}$`); export function isListingImage(image: string): boolean { return !image || (byteLength(image) <= MAX_URL_BYTES && IMAGE_RE.test(image)); } export function isListingLink(link: string): boolean { if (byteLength(link) > MAX_URL_BYTES) { return false; } const match = LINK_RE.exec(link); return !!match && (!match[1] || isValidPatp(match[1])); } // A listing image %sphinx accepts, or '' (so the listing goes out without // one): http(s) URLs are percent-encoded by URL and lose their #fragment; // anything else, data: URIs included (docket images may be one), is dropped. export function listingImage(image: string | null | undefined): string { if (!image) { return ''; } try { const url = new URL(image); if (url.protocol !== 'http:' && url.protocol !== 'https:') { return ''; } url.hash = ''; return isListingImage(url.href) ? url.href : ''; } catch (e) { return ''; } } // %sphinx measures listing fields in UTF-8 bytes (lib/sphinx +lengths-valid) // and silently drops listings that are too long, so cut text to at most `max` // bytes on a code point boundary, marking the cut with an ellipsis. export function truncateBytes(text: string, max: number): string { if (encoder.encode(text).length <= max) { return text; } const ellipsis = '...'; let out = ''; let size = 0; for (const char of text) { size += encoder.encode(char).length; if (size > max - ellipsis.length) { break; } out += char; } return `${out.trimEnd()}${ellipsis}`; }