1import { stringToTa, uxToHex } from "@urbit/api";
2import { parseToRgba } from "color2k";
3import { isValidPatp } from "urbit-ob";
5export function encodeLookup(value: string | undefined) {
10 return stringToTa(value).replace('~.', '~~');
13export function isColor(color: string): boolean {
22export function getColor(image: string): string | undefined {
23 return isColor(image) ? image : undefined;
26export function getImage(image: string): string | undefined {
27 return isColor(image) ? undefined : image;
30export function normalizeColor(color: string): string {
31 if (color.startsWith('#')) {
35 return `#${uxToHex(color)}`
38export function normalizeLink(link: string): string {
40 if (!link.startsWith('web+urbitgraph://') || link.startsWith('http')) {
45 const normLink = link.replace('web+urbitgraph://', '');
46 const parts = normLink.split('/');
47 const first = parts[0];
49 if (first !== 'group') {
50 return `/1/desk/${normLink}`;
53 if (parts.length === 3) {
54 return `/1/group/${parts[1]}/${parts[2]}`;
57 return `/1/chan/diary/${parts[4]}/${parts[5]}/note/${parts[6]}`
60// Tlon web routes (tlon-apps packages/app/navigation/linking.ts):
61// /apps/groups/group/:groupId[/channel/:channelId], ids URI-encoded.
62export function groupURL(flag: string): string {
63 return `/apps/groups/group/${encodeURIComponent(flag)}`;
66export function channelURL(flag: string, nest: string): string {
67 return `${groupURL(flag)}/channel/${encodeURIComponent(nest)}`;
70export function refToURL(cite: string): string | { nest: string, where: string } {
71 // plain web links (e.g. manual listings) are used as they are
72 if (/^https?:\/\//i.test(cite)) {
76 const parts = cite.slice(1).split('/');
77 if (parts[0] !== '1') {
81 const type = parts[1];
82 if (type === 'desk') {
83 const ship = parts[2];
84 const desk = parts[3];
85 return `/apps/landscape/search/${ship}/apps/${ship}/${desk}`;
88 if (type === 'group') {
89 const ship = parts[2];
90 const name = parts[3];
91 return groupURL(`${ship}/${name}`);
94 const chanType = parts[2];
95 const ship = parts[3];
96 const name = parts[4];
98 nest: `${chanType}/${ship}/${name}`,
99 where: parts.slice(5).join('/'),
103// a number as Hoon prints @ud: `slav %ud` reads 1.000 but not 1000
104// (@urbit/api's decToUd turns 0 into '')
105export function formatUd(n: number): string {
106 return String(n).replace(/\B(?=(\d{3})+$)/g, '.');
109const encoder = new TextEncoder();
111// lib/sphinx +lengths-valid limits, in UTF-8 bytes
112export const MAX_TITLE_BYTES = 77;
113export const MAX_DESCRIPTION_BYTES = 256;
114export const MAX_URL_BYTES = 1024;
115export const MAX_TAGS = 8;
116// the longest tag +catalog indexes
117export const MAX_TAG_BYTES = 64;
119export function byteLength(text: string): number {
120 return encoder.encode(text).length;
123// lib/validate-url (de-purl:html in zuse) as regular expressions: an optional
124// scheme://host[:port] (host a DNS name or IPv4, or a ~ship without a port),
125// then an optional /path and ?key=value&key=value query. No #fragment, no
126// spaces or non-ASCII, none of | [ ] { } ^ ` \ and no '=' inside a value.
127// %sphinx silently drops a listing whose image or link fails this.
128const QUERY_CHAR = "(?:[A-Za-z0-9_.!~*'()+/?:,-]|%[0-9A-Fa-f]{2})";
129const PATH_CHAR = "(?:[A-Za-z0-9_.!~*'()$&+,;=:@/-]|%[0-9A-Fa-f]{2})";
130const PAIR = `${QUERY_CHAR}+(?:=${QUERY_CHAR}*)?`;
131const PATH_QUERY = `(?:/${PATH_CHAR}*)?(?:[?](?:${PAIR}(?:[&;]${PAIR})*)?)?`;
132const SCHEME = '[A-Za-z][A-Za-z0-9+.-]*://';
133const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
134const LAST_LABEL = '[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
135const OCTET = '(?:0|[1-9][0-9]{0,2})';
136const HOST = `(?:(?:${LABEL}[.])*${LAST_LABEL}|${OCTET}(?:[.]${OCTET}){3})(?::(?:0|[1-9][0-9]*))?`;
137// the shape of a @p; isListingLink checks the syllables with isValidPatp
138const SHIP = '~[a-z]{3}(?:[a-z]{3})?(?:--?[a-z]{6})*';
139const IMAGE_RE = new RegExp(`^${SCHEME}${HOST}${PATH_QUERY}$`);
140const LINK_RE = new RegExp(`^(?:${SCHEME}(?:${HOST}|(${SHIP})))?${PATH_QUERY}$`);
142export function isListingImage(image: string): boolean {
143 return !image || (byteLength(image) <= MAX_URL_BYTES && IMAGE_RE.test(image));
146export function isListingLink(link: string): boolean {
147 if (byteLength(link) > MAX_URL_BYTES) {
151 const match = LINK_RE.exec(link);
152 return !!match && (!match[1] || isValidPatp(match[1]));
155// A listing image %sphinx accepts, or '' (so the listing goes out without
156// one): http(s) URLs are percent-encoded by URL and lose their #fragment;
157// anything else, data: URIs included (docket images may be one), is dropped.
158export function listingImage(image: string | null | undefined): string {
164 const url = new URL(image);
165 if (url.protocol !== 'http:' && url.protocol !== 'https:') {
170 return isListingImage(url.href) ? url.href : '';
176// %sphinx measures listing fields in UTF-8 bytes (lib/sphinx +lengths-valid)
177// and silently drops listings that are too long, so cut text to at most `max`
178// bytes on a code point boundary, marking the cut with an ellipsis.
179export function truncateBytes(text: string, max: number): string {
180 if (encoder.encode(text).length <= max) {
184 const ellipsis = '...';
187 for (const char of text) {
188 size += encoder.encode(char).length;
189 if (size > max - ellipsis.length) {
195 return `${out.trimEnd()}${ellipsis}`;
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list