From 19bdfe74d02d23ed99b6de1d734c584ac254d391 Mon Sep 17 00:00:00 2001
From: thatmattlove <matt@stunninglyclear.com>
Date: Sun, 24 Mar 2024 16:50:31 -0400
Subject: [PATCH] fix PeeringDB link rendering

---
 hyperglass/models/config/params.py         | 14 ++++-
 hyperglass/ui/components/footer/footer.tsx | 63 +++++++++-------------
 hyperglass/ui/types/globals.d.ts           |  3 ++
 3 files changed, 42 insertions(+), 38 deletions(-)

diff --git a/hyperglass/models/config/params.py b/hyperglass/models/config/params.py
index 4375ce76..6d67ae85 100644
--- a/hyperglass/models/config/params.py
+++ b/hyperglass/models/config/params.py
@@ -3,9 +3,10 @@
 # Standard Library
 import typing as t
 from pathlib import Path
+import urllib.parse
 
 # Third Party
-from pydantic import Field, ConfigDict, ValidationInfo, field_validator
+from pydantic import Field, ConfigDict, ValidationInfo, field_validator, HttpUrl
 
 # Project
 from hyperglass.settings import Settings
@@ -108,6 +109,17 @@ def validate_plugins(cls: "Params", value: t.List[str]) -> t.List[str]:
             return [str(f) for f in matching_plugins]
         return []
 
+    @field_validator("web", mode="after")
+    @classmethod
+    def validate_web(cls, web: Web, info: ValidationInfo) -> Web:
+        """String-format Link URLs."""
+        for link in web.links:
+            url = urllib.parse.unquote(str(link.url), encoding="utf-8", errors="replace").format(
+                primary_asn=info.data.get("primary_asn", "65000")
+            )
+            link.url = HttpUrl(url)
+        return web
+
     def common_plugins(self) -> t.Tuple[Path, ...]:
         """Get all validated external common plugins as Path objects."""
         return tuple(Path(p) for p in self.plugins)
diff --git a/hyperglass/ui/components/footer/footer.tsx b/hyperglass/ui/components/footer/footer.tsx
index 006aa048..17d3fea4 100644
--- a/hyperglass/ui/components/footer/footer.tsx
+++ b/hyperglass/ui/components/footer/footer.tsx
@@ -2,7 +2,7 @@ import { Flex, HStack, useToken } from '@chakra-ui/react';
 import { useMemo } from 'react';
 import { useConfig } from '~/context';
 import { DynamicIcon } from '~/elements';
-import { useBreakpointValue, useColorValue, useMobile, useStrf } from '~/hooks';
+import { useBreakpointValue, useColorValue, useMobile } from '~/hooks';
 import { isLink, isMenu } from '~/types';
 import { FooterButton } from './button';
 import { ColorModeToggle } from './color-mode';
@@ -11,7 +11,9 @@ import { FooterLink } from './link';
 import type { ButtonProps, LinkProps } from '@chakra-ui/react';
 import type { Link, Menu } from '~/types';
 
-function buildItems(links: Link[], menus: Menu[]): [(Link | Menu)[], (Link | Menu)[]] {
+type MenuItems = (Link | Menu)[];
+
+function buildItems(links: Link[], menus: Menu[]): [MenuItems, MenuItems] {
   const leftLinks = links.filter(link => link.side === 'left');
   const leftMenus = menus.filter(menu => menu.side === 'left');
   const rightLinks = links.filter(link => link.side === 'right');
@@ -22,8 +24,23 @@ function buildItems(links: Link[], menus: Menu[]): [(Link | Menu)[], (Link | Men
   return [left, right];
 }
 
+const LinkOnSide = (props: { item: ArrayElement<MenuItems>; side: 'left' | 'right' }) => {
+  const { item, side } = props;
+  if (isLink(item)) {
+    const icon: Partial<ButtonProps & LinkProps> = {};
+
+    if (item.showIcon) {
+      icon.rightIcon = <DynamicIcon icon={{ go: 'GoLinkExternal' }} />;
+    }
+    return <FooterLink key={item.title} href={item.url} title={item.title} {...icon} />;
+  }
+  if (isMenu(item)) {
+    return <FooterButton key={item.title} side={side} content={item.content} title={item.title} />;
+  }
+};
+
 export const Footer = (): JSX.Element => {
-  const { web, content, primaryAsn } = useConfig();
+  const { web, content } = useConfig();
 
   const footerBg = useColorValue('blackAlpha.50', 'whiteAlpha.100');
   const footerColor = useColorValue('black', 'white');
@@ -34,8 +51,6 @@ export const Footer = (): JSX.Element => {
 
   const [left, right] = useMemo(() => buildItems(web.links, web.menus), [web.links, web.menus]);
 
-  const strF = useStrf();
-
   return (
     <HStack
       px={6}
@@ -51,39 +66,13 @@ export const Footer = (): JSX.Element => {
       overflowY={{ base: 'auto', lg: 'unset' }}
       justifyContent={{ base: 'center', lg: 'space-between' }}
     >
-      {left.map(item => {
-        if (isLink(item)) {
-          const url = strF(item.url, { primaryAsn }, '/');
-          const icon: Partial<ButtonProps & LinkProps> = {};
-
-          if (item.showIcon) {
-            icon.rightIcon = <DynamicIcon icon={{ go: 'GoLinkExternal' }} />;
-          }
-          return <FooterLink key={item.title} href={url} title={item.title} {...icon} />;
-        }
-        if (isMenu(item)) {
-          return (
-            <FooterButton key={item.title} side="left" content={item.content} title={item.title} />
-          );
-        }
-      })}
+      {left.map(item => (
+        <LinkOnSide key={item.title} item={item} side="left" />
+      ))}
       {!isMobile && <Flex p={0} flex="1 0 auto" maxWidth="100%" mr="auto" />}
-      {right.map(item => {
-        if (isLink(item)) {
-          const url = strF(item.url, { primaryAsn }, '/');
-          const icon: Partial<ButtonProps & LinkProps> = {};
-
-          if (item.showIcon) {
-            icon.rightIcon = <DynamicIcon icon={{ go: 'GoLinkExternal' }} />;
-          }
-          return <FooterLink key={item.title} href={url} title={item.title} {...icon} />;
-        }
-        if (isMenu(item)) {
-          return (
-            <FooterButton key={item.title} side="right" content={item.content} title={item.title} />
-          );
-        }
-      })}
+      {right.map(item => (
+        <LinkOnSide key={item.title} item={item} side="right" />
+      ))}
       {web.credit.enable && (
         <FooterButton
           key="credit"
diff --git a/hyperglass/ui/types/globals.d.ts b/hyperglass/ui/types/globals.d.ts
index 4f4acee8..f920e599 100644
--- a/hyperglass/ui/types/globals.d.ts
+++ b/hyperglass/ui/types/globals.d.ts
@@ -9,6 +9,9 @@ export declare global {
 
   type Swap<T, K extends keyof T, V> = Record<K, V> & Omit<T, K>;
 
+  type ArrayElement<ArrayType extends readonly unknown[]> =
+    ArrayType extends readonly (infer ElementType)[] ? ElementType : never;
+
   type RPKIState = 0 | 1 | 2 | 3;
 
   type ResponseLevel = 'success' | 'warning' | 'error' | 'danger';