diff --git a/FrontEnd/styles/images.scss b/FrontEnd/styles/images.scss
index 4d1662990..de3954111 100644
--- a/FrontEnd/styles/images.scss
+++ b/FrontEnd/styles/images.scss
@@ -34,6 +34,7 @@
--image-download: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iIzJmMmYyZiI+PHBhdGggZD0ibTM3LjkzIDI1LjAxLTIuOTgtNC4wMS03LjQ1IDUuNTN2LTIzLjA5aC01djIzLjA5bC03LjQ1LTUuNTMtMi45OCA0LjAxIDEyLjkzIDkuNjF6Ii8+PHBhdGggZD0ibTQyLjMxIDMzLjA5djguNDdoLTM0LjYydi04LjQ3aC01djEzLjQ3aDQ0LjYydi0xMy40N3oiLz48L2c+PC9zdmc+');
--image-error: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzUtMjQtMjQtMjR6bTIuNjYgMzYuMThjLS42NC41OS0xLjUzLjg5LTIuNjguODlzLTIuMDctLjMtMi43Mi0uODktLjk4LTEuNDItLjk4LTIuNDcuMzItMS45Ljk1LTIuNDkgMS41NS0uODkgMi43Ni0uODkgMi4xLjMgMi43Mi44OWMuNjEuNTkuOTIgMS40Mi45MiAyLjQ5cy0uMzIgMS44OC0uOTYgMi40N3ptLjI0LTguNjJoLTUuOGwtLjktMTYuNjNoNy41OWwtLjkgMTYuNjN6IiBmaWxsPSIjYzQ0Ii8+PC9zdmc+');
--image-executables: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzUtMjQtMjQtMjR6bS0xMi45NCAzMy45NSA3LjM5LTkuOTUtNy4zOS05Ljk1IDQuMDEtMi45OCA5LjYxIDEyLjkzLTkuNjEgMTIuOTN6bTI0Ljg4IDMuODNoLTEyLjV2LTVoMTIuNXoiIGZpbGw9IiMyZjJmMmYiLz48L3N2Zz4=');
+ --image-fork: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQzLjUgMTEuODdjMC0zLjU5LTIuOTEtNi41LTYuNS02LjVzLTYuNSAyLjkxLTYuNSA2LjVjMCAyLjQgMS4zMSA0LjUgMy4yNSA1LjYydjExLjM3bC0yMC4xNiA1Ljg1IDEuODEgNi4yNCAyNC44NS03LjIxdi0xNi4yNWMxLjk0LTEuMTIgMy4yNS0zLjIyIDMuMjUtNS42MnoiIGZpbGw9IiMyYjJiMmIiLz48cGF0aCBkPSJtMjIuNSAzNy45M2MwLTMuMjYtMS45NS02LjA2LTQuNzUtNy4zMXYtMjkuNjJoLTYuNXYyOS42M2MtMi44IDEuMjUtNC43NSA0LjA1LTQuNzUgNy4zMXMxLjk1IDYuMDYgNC43NSA3LjMxdjMuNzZoNi41di0zLjc2YzIuOC0xLjI1IDQuNzUtNC4wNSA0Ljc1LTcuMzF6IiBmaWxsPSIjNmI2YjZiIi8+PC9zdmc+');
--image-ghcta-header: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTIwIDUwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0yNSAxLjU5Yy0xMy4yNSAwLTI0IDEwLjc1LTI0IDI0IDAgMTAuNiA2Ljg4IDE5LjYgMTYuNDEgMjIuNzcgMS4yLjIyIDEuNjQtLjUyIDEuNjQtMS4xNiAwLS41Ny0uMDItMi4wOC0uMDMtNC4wOC02LjY4IDEuNDUtOC4wOC0zLjIyLTguMDgtMy4yMi0xLjA5LTIuNzctMi42Ni0zLjUxLTIuNjYtMy41MS0yLjE4LTEuNDkuMTYtMS40Ni4xNi0xLjQ2IDIuNDEuMTcgMy42OCAyLjQ3IDMuNjggMi40NyAyLjE0IDMuNjcgNS42MiAyLjYxIDYuOTggMS45OS4yMi0xLjU1Ljg0LTIuNjEgMS41Mi0zLjIxLTUuMzMtLjYxLTEwLjkzLTIuNjYtMTAuOTMtMTEuODYgMC0yLjYyLjk0LTQuNzYgMi40Ny02LjQ0LS4yNS0uNjEtMS4wNy0zLjA1LjI0LTYuMzUgMCAwIDIuMDItLjY1IDYuNiAyLjQ2IDEuOTEtLjUzIDMuOTctLjggNi4wMS0uODEgMi4wNCAwIDQuMDkuMjggNi4wMS44MSA0LjU4LTMuMTEgNi41OS0yLjQ2IDYuNTktMi40NiAxLjMxIDMuMy40OSA1Ljc0LjI0IDYuMzUgMS41NCAxLjY4IDIuNDcgMy44MiAyLjQ3IDYuNDQgMCA5LjIyLTUuNjEgMTEuMjUtMTAuOTYgMTEuODQuODYuNzQgMS42MyAyLjIxIDEuNjMgNC40NCAwIDMuMjEtLjAzIDUuOC0uMDMgNi41OCAwIC42NC40MyAxLjM5IDEuNjUgMS4xNSA5LjUzLTMuMTggMTYuNC0xMi4xNyAxNi40LTIyLjc3IDAtMTMuMjYtMTAuNzUtMjQtMjQtMjR6IiBmaWxsPSIjMmYyZjJmIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48Y2lyY2xlIGN4PSI5NSIgY3k9IjI1IiBmaWxsPSIjMmYyZjJmIiByPSIyNCIvPjxwYXRoIGQ9Im0xMDIuNjIgMTMuNTFjLTMuMTctLjItNS45OSAxLjMzLTcuNjIgMy43My0xLjY0LTIuNC00LjQ2LTMuOTMtNy42Mi0zLjczLTQuODEuMy04LjM0IDQuNzYtNy45NyA5LjU2LjE5IDIuNDcgMS41NCA3LjIzIDExLjE5IDEzLjQ3bDQuNCAyLjg1IDQuNC0yLjg1YzkuNjQtNi4yMyAxMS0xMSAxMS4xOS0xMy40Ny4zNy00LjgtMy4xNi05LjI2LTcuOTctOS41NnoiIGZpbGw9IiNmNDRlYjIiLz48cGF0aCBkPSJtNTQuNDMgMjMuN2g0LjI2di0zLjk1aDIuNjF2My45NWg0LjI3djIuNjJoLTQuMjd2My45NGgtMi42MXYtMy45NGgtNC4yNnoiIGZpbGw9IiMyZjJmMmYiLz48L3N2Zz4=');
--image-github: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDEuNTljLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjQgMCAxMC42IDYuODggMTkuNiAxNi40MSAyMi43NyAxLjIuMjIgMS42NC0uNTIgMS42NC0xLjE2IDAtLjU3LS4wMi0yLjA4LS4wMy00LjA4LTYuNjggMS40NS04LjA4LTMuMjItOC4wOC0zLjIyLTEuMDktMi43Ny0yLjY2LTMuNTEtMi42Ni0zLjUxLTIuMTgtMS40OS4xNi0xLjQ2LjE2LTEuNDYgMi40MS4xNyAzLjY4IDIuNDcgMy42OCAyLjQ3IDIuMTQgMy42NyA1LjYyIDIuNjEgNi45OCAxLjk5LjIyLTEuNTUuODQtMi42MSAxLjUyLTMuMjEtNS4zMy0uNjEtMTAuOTMtMi42Ni0xMC45My0xMS44NiAwLTIuNjIuOTQtNC43NiAyLjQ3LTYuNDQtLjI1LS42MS0xLjA3LTMuMDUuMjQtNi4zNSAwIDAgMi4wMi0uNjUgNi42IDIuNDYgMS45MS0uNTMgMy45Ny0uOCA2LjAxLS44MSAyLjA0IDAgNC4wOS4yOCA2LjAxLjgxIDQuNTgtMy4xMSA2LjU5LTIuNDYgNi41OS0yLjQ2IDEuMzEgMy4zLjQ5IDUuNzQuMjQgNi4zNSAxLjU0IDEuNjggMi40NyAzLjgyIDIuNDcgNi40NCAwIDkuMjItNS42MSAxMS4yNS0xMC45NiAxMS44NC44Ni43NCAxLjYzIDIuMjEgMS42MyA0LjQ0IDAgMy4yMS0uMDMgNS44LS4wMyA2LjU4IDAgLjY0LjQzIDEuMzkgMS42NSAxLjE1IDkuNTMtMy4xOCAxNi40LTEyLjE3IDE2LjQtMjIuNzcgMC0xMy4yNi0xMC43NS0yNC0yNC0yNHoiIGZpbGw9IiMyZjJmMmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==');
--image-heart: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTM2LjgyIDQuOTZjLTQuOTEtLjMxLTkuMjggMi4wNi0xMS44MiA1Ljc4LTIuNTQtMy43Mi02LjkxLTYuMDktMTEuODItNS43OC03LjQ1LjQ2LTEyLjkzIDcuMzctMTIuMzUgMTQuODIuMjkgMy44MyAyLjM5IDExLjIxIDE3LjM1IDIwLjg4bDYuODMgNC40MSA2LjgzLTQuNDFjMTQuOTUtOS42NiAxNy4wNS0xNy4wNSAxNy4zNS0yMC44OC41Ny03LjQ0LTQuOS0xNC4zNS0xMi4zNS0xNC44MnoiIGZpbGw9IiNmNDRlYjIiLz48L3N2Zz4=');
@@ -73,6 +74,7 @@
--image-download: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0iI2YxZjFmMSI+PHBhdGggZD0ibTM3LjkzIDI1LjAxLTIuOTgtNC4wMS03LjQ1IDUuNTN2LTIzLjA5aC01djIzLjA5bC03LjQ1LTUuNTMtMi45OCA0LjAxIDEyLjkzIDkuNjF6Ii8+PHBhdGggZD0ibTQyLjMxIDMzLjA5djguNDdoLTM0LjYydi04LjQ3aC01djEzLjQ3aDQ0LjYydi0xMy40N3oiLz48L2c+PC9zdmc+');
--image-error: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzUtMjQtMjQtMjR6bTIuNjYgMzYuMThjLS42NC41OS0xLjUzLjg5LTIuNjguODlzLTIuMDctLjMtMi43Mi0uODktLjk4LTEuNDItLjk4LTIuNDcuMzItMS45Ljk1LTIuNDkgMS41NS0uODkgMi43Ni0uODkgMi4xLjMgMi43Mi44OWMuNjEuNTkuOTIgMS40Mi45MiAyLjQ5cy0uMzIgMS44OC0uOTYgMi40N3ptLjI0LTguNjJoLTUuOGwtLjktMTYuNjNoNy41OWwtLjkgMTYuNjN6IiBmaWxsPSIjZmY0MzQzIi8+PC9zdmc+');
--image-executables: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDFjLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjRzMTAuNzUgMjQgMjQgMjQgMjQtMTAuNzUgMjQtMjQtMTAuNzUtMjQtMjQtMjR6bS0xMi45NCAzMy45NSA3LjM5LTkuOTUtNy4zOS05Ljk1IDQuMDEtMi45OCA5LjYxIDEyLjkzLTkuNjEgMTIuOTN6bTI0Ljg4IDMuODNoLTEyLjV2LTVoMTIuNXoiIGZpbGw9IiNmMWYxZjEiLz48L3N2Zz4=');
+ --image-fork: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTQzLjUgMTEuODdjMC0zLjU5LTIuOTEtNi41LTYuNS02LjVzLTYuNSAyLjkxLTYuNSA2LjVjMCAyLjQgMS4zMSA0LjUgMy4yNSA1LjYydjExLjM3bC0yMC4xNiA1Ljg1IDEuODEgNi4yNCAyNC44NS03LjIxdi0xNi4yNWMxLjk0LTEuMTIgMy4yNS0zLjIyIDMuMjUtNS42MnoiIGZpbGw9IiNmMWYxZjEiLz48cGF0aCBkPSJtMjIuNSAzNy45M2MwLTMuMjYtMS45NS02LjA2LTQuNzUtNy4zMXYtMjkuNjJoLTYuNXYyOS42M2MtMi44IDEuMjUtNC43NSA0LjA1LTQuNzUgNy4zMXMxLjk1IDYuMDYgNC43NSA3LjMxdjMuNzZoNi41di0zLjc2YzIuOC0xLjI1IDQuNzUtNC4wNSA0Ljc1LTcuMzF6IiBmaWxsPSIjYjFiMWIxIi8+PC9zdmc+');
--image-ghcta-header: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMTIwIDUwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0yNSAxLjU5Yy0xMy4yNSAwLTI0IDEwLjc1LTI0IDI0IDAgMTAuNiA2Ljg4IDE5LjYgMTYuNDEgMjIuNzcgMS4yLjIyIDEuNjQtLjUyIDEuNjQtMS4xNiAwLS41Ny0uMDItMi4wOC0uMDMtNC4wOC02LjY4IDEuNDUtOC4wOC0zLjIyLTguMDgtMy4yMi0xLjA5LTIuNzctMi42Ni0zLjUxLTIuNjYtMy41MS0yLjE4LTEuNDkuMTYtMS40Ni4xNi0xLjQ2IDIuNDEuMTcgMy42OCAyLjQ3IDMuNjggMi40NyAyLjE0IDMuNjcgNS42MiAyLjYxIDYuOTggMS45OS4yMi0xLjU1Ljg0LTIuNjEgMS41Mi0zLjIxLTUuMzMtLjYxLTEwLjkzLTIuNjYtMTAuOTMtMTEuODYgMC0yLjYyLjk0LTQuNzYgMi40Ny02LjQ0LS4yNS0uNjEtMS4wNy0zLjA1LjI0LTYuMzUgMCAwIDIuMDItLjY1IDYuNiAyLjQ2IDEuOTEtLjUzIDMuOTctLjggNi4wMS0uODEgMi4wNCAwIDQuMDkuMjggNi4wMS44MSA0LjU4LTMuMTEgNi41OS0yLjQ2IDYuNTktMi40NiAxLjMxIDMuMy40OSA1Ljc0LjI0IDYuMzUgMS41NCAxLjY4IDIuNDcgMy44MiAyLjQ3IDYuNDQgMCA5LjIyLTUuNjEgMTEuMjUtMTAuOTYgMTEuODQuODYuNzQgMS42MyAyLjIxIDEuNjMgNC40NCAwIDMuMjEtLjAzIDUuOC0uMDMgNi41OCAwIC42NC40MyAxLjM5IDEuNjUgMS4xNSA5LjUzLTMuMTggMTYuNC0xMi4xNyAxNi40LTIyLjc3IDAtMTMuMjYtMTAuNzUtMjQtMjQtMjR6IiBmaWxsPSIjZjFmMWYxIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiLz48Y2lyY2xlIGN4PSI5NSIgY3k9IjI1IiBmaWxsPSIjZjFmMWYxIiByPSIyNCIvPjxwYXRoIGQ9Im0xMDIuNjIgMTMuNTFjLTMuMTctLjItNS45OSAxLjMzLTcuNjIgMy43My0xLjY0LTIuNC00LjQ2LTMuOTMtNy42Mi0zLjczLTQuODEuMy04LjM0IDQuNzYtNy45NyA5LjU2LjE5IDIuNDcgMS41NCA3LjIzIDExLjE5IDEzLjQ3bDQuNCAyLjg1IDQuNC0yLjg1YzkuNjQtNi4yMyAxMS0xMSAxMS4xOS0xMy40Ny4zNy00LjgtMy4xNi05LjI2LTcuOTctOS41NnoiIGZpbGw9IiNmNDRlYjIiLz48cGF0aCBkPSJtNTQuNDMgMjMuN2g0LjI2di0zLjk1aDIuNjF2My45NWg0LjI3djIuNjJoLTQuMjd2My45NGgtMi42MXYtMy45NGgtNC4yNnoiIGZpbGw9IiNmMWYxZjEiLz48L3N2Zz4=');
--image-github: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTI1IDEuNTljLTEzLjI1IDAtMjQgMTAuNzUtMjQgMjQgMCAxMC42IDYuODggMTkuNiAxNi40MSAyMi43NyAxLjIuMjIgMS42NC0uNTIgMS42NC0xLjE2IDAtLjU3LS4wMi0yLjA4LS4wMy00LjA4LTYuNjggMS40NS04LjA4LTMuMjItOC4wOC0zLjIyLTEuMDktMi43Ny0yLjY2LTMuNTEtMi42Ni0zLjUxLTIuMTgtMS40OS4xNi0xLjQ2LjE2LTEuNDYgMi40MS4xNyAzLjY4IDIuNDcgMy42OCAyLjQ3IDIuMTQgMy42NyA1LjYyIDIuNjEgNi45OCAxLjk5LjIyLTEuNTUuODQtMi42MSAxLjUyLTMuMjEtNS4zMy0uNjEtMTAuOTMtMi42Ni0xMC45My0xMS44NiAwLTIuNjIuOTQtNC43NiAyLjQ3LTYuNDQtLjI1LS42MS0xLjA3LTMuMDUuMjQtNi4zNSAwIDAgMi4wMi0uNjUgNi42IDIuNDYgMS45MS0uNTMgMy45Ny0uOCA2LjAxLS44MSAyLjA0IDAgNC4wOS4yOCA2LjAxLjgxIDQuNTgtMy4xMSA2LjU5LTIuNDYgNi41OS0yLjQ2IDEuMzEgMy4zLjQ5IDUuNzQuMjQgNi4zNSAxLjU0IDEuNjggMi40NyAzLjgyIDIuNDcgNi40NCAwIDkuMjItNS42MSAxMS4yNS0xMC45NiAxMS44NC44Ni43NCAxLjYzIDIuMjEgMS42MyA0LjQ0IDAgMy4yMS0uMDMgNS44LS4wMyA2LjU4IDAgLjY0LjQzIDEuMzkgMS42NSAxLjE1IDkuNTMtMy4xOCAxNi40LTEyLjE3IDE2LjQtMjIuNzcgMC0xMy4yNi0xMC43NS0yNC0yNC0yNHoiIGZpbGw9IiNmMWYxZjEiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvc3ZnPg==');
--image-heart: url('data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNTAgNTAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0ibTM2LjgyIDQuOTZjLTQuOTEtLjMxLTkuMjggMi4wNi0xMS44MiA1Ljc4LTIuNTQtMy43Mi02LjkxLTYuMDktMTEuODItNS43OC03LjQ1LjQ2LTEyLjkzIDcuMzctMTIuMzUgMTQuODIuMjkgMy44MyAyLjM5IDExLjIxIDE3LjM1IDIwLjg4bDYuODMgNC40MSA2LjgzLTQuNDFjMTQuOTUtOS42NiAxNy4wNS0xNy4wNSAxNy4zNS0yMC44OC41Ny03LjQ0LTQuOS0xNC4zNS0xMi4zNS0xNC44MnoiIGZpbGw9IiNmNDRlYjIiLz48L3N2Zz4=');
diff --git a/FrontEnd/styles/package.scss b/FrontEnd/styles/package.scss
index b32d43b54..28ee28c6b 100644
--- a/FrontEnd/styles/package.scss
+++ b/FrontEnd/styles/package.scss
@@ -75,6 +75,11 @@
background-image: var(--image-warning);
}
+ li.forked {
+ grid-column-start: span 2;
+ background-image: var(--image-fork);
+ }
+
li.authors {
grid-column-start: span 2;
background-image: var(--image-authors);
diff --git a/Resources/SVGs/fork~dark.svg b/Resources/SVGs/fork~dark.svg
new file mode 100644
index 000000000..c4ba52a15
--- /dev/null
+++ b/Resources/SVGs/fork~dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Resources/SVGs/fork~light.svg b/Resources/SVGs/fork~light.svg
new file mode 100644
index 000000000..534c94039
--- /dev/null
+++ b/Resources/SVGs/fork~light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift b/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift
index 211e5fb17..f24c59228 100644
--- a/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift
+++ b/Sources/App/Controllers/API/API+PackageController+GetRoute+Model.swift
@@ -50,6 +50,7 @@ extension API.PackageController.GetRoute {
var releaseReferences: [App.Version.Kind: App.Reference]
var fundingLinks: [FundingLink]
var swift6Readiness: Swift6Readiness?
+ var forkedFromInfo: ForkedFromInfo?
internal init(packageId: Package.Id,
repositoryOwner: String,
@@ -81,7 +82,8 @@ extension API.PackageController.GetRoute {
releaseReference: App.Reference?,
preReleaseReference: App.Reference?,
fundingLinks: [FundingLink] = [],
- swift6Readiness: Swift6Readiness?
+ swift6Readiness: Swift6Readiness?,
+ forkedFromInfo: ForkedFromInfo?
) {
self.packageId = packageId
self.repositoryOwner = repositoryOwner
@@ -123,6 +125,7 @@ extension API.PackageController.GetRoute {
}()
self.fundingLinks = fundingLinks
self.swift6Readiness = swift6Readiness
+ self.forkedFromInfo = forkedFromInfo
}
init?(result: API.PackageController.PackageResult,
@@ -132,7 +135,8 @@ extension API.PackageController.GetRoute {
swiftVersionBuildInfo: BuildInfo?,
platformBuildInfo: BuildInfo?,
weightedKeywords: [WeightedKeyword] = [],
- swift6Readiness: Swift6Readiness?) {
+ swift6Readiness: Swift6Readiness?,
+ forkedFromInfo: ForkedFromInfo?) {
// we consider certain attributes as essential and return nil (raising .notFound)
let repository = result.repository
guard
@@ -177,7 +181,8 @@ extension API.PackageController.GetRoute {
releaseReference: result.releaseVersion?.reference,
preReleaseReference: result.preReleaseVersion?.reference,
fundingLinks: result.repository.fundingLinks,
- swift6Readiness: swift6Readiness
+ swift6Readiness: swift6Readiness,
+ forkedFromInfo: forkedFromInfo
)
}
@@ -348,7 +353,14 @@ extension API.PackageController.GetRoute.Model {
}
}
}
-
+
+ enum ForkedFromInfo: Codable, Equatable {
+ case fromSPI(originalOwner: String,
+ originalOwnerName: String,
+ originalRepo: String,
+ originalPackageName: String)
+ case fromGitHub(url: String)
+ }
}
diff --git a/Sources/App/Controllers/API/API+PackageController+GetRoute.swift b/Sources/App/Controllers/API/API+PackageController+GetRoute.swift
index 1a7d6750a..782967b6c 100644
--- a/Sources/App/Controllers/API/API+PackageController+GetRoute.swift
+++ b/Sources/App/Controllers/API/API+PackageController+GetRoute.swift
@@ -45,6 +45,7 @@ extension API.PackageController {
async let buildInfo = API.PackageController.BuildInfo.query(on: database,
owner: owner,
repository: repository)
+ async let forkedFromInfo = forkedFromInfo(on: database, fork: packageResult.repository.forkedFrom)
guard
let model = try await Self.Model(
@@ -55,7 +56,8 @@ extension API.PackageController {
swiftVersionBuildInfo: buildInfo.swiftVersion,
platformBuildInfo: buildInfo.platform,
weightedKeywords: weightedKeywords,
- swift6Readiness: buildInfo.swift6Readiness
+ swift6Readiness: buildInfo.swift6Readiness,
+ forkedFromInfo: forkedFromInfo
),
let schema = API.PackageSchema(result: packageResult)
else {
@@ -84,4 +86,34 @@ extension API.PackageController.GetRoute {
beta: links[1],
latest: links[2])
}
+
+ static func forkedFromInfo(on database: Database, fork: Fork?) async -> Model.ForkedFromInfo? {
+ guard let forkedFrom = fork else { return nil }
+ switch forkedFrom {
+ case .parentId(let id, let fallbackURL):
+ return await Model.ForkedFromInfo.query(on: database, packageId: id, fallbackURL: fallbackURL)
+ case let .parentURL(url):
+ return .fromGitHub(url: url)
+ }
+ }
+}
+
+
+extension API.PackageController.GetRoute.Model.ForkedFromInfo {
+ static func query(on database: Database, packageId: Package.Id, fallbackURL: String) async -> Self? {
+ let model = try? await Joined3
+ .query(on: database, packageId: packageId, version: .defaultBranch)
+ .first()
+
+ guard let repoName = model?.repository.name,
+ let ownerName = model?.repository.ownerName,
+ let owner = model?.repository.owner else {
+ return .fromGitHub(url: fallbackURL)
+ }
+
+ return .fromSPI(originalOwner: owner,
+ originalOwnerName: ownerName,
+ originalRepo: repoName,
+ originalPackageName: model?.version.packageName ?? repoName)
+ }
}
diff --git a/Sources/App/Controllers/API/Types+WithExample.swift b/Sources/App/Controllers/API/Types+WithExample.swift
index 1741e5dec..a15170f81 100644
--- a/Sources/App/Controllers/API/Types+WithExample.swift
+++ b/Sources/App/Controllers/API/Types+WithExample.swift
@@ -247,7 +247,8 @@ extension API.PackageController.GetRoute.Model: WithExample {
defaultBranchReference: .branch("main"),
releaseReference: .tag(1, 2, 3, "1.2.3"),
preReleaseReference: nil,
- swift6Readiness: nil)
+ swift6Readiness: nil,
+ forkedFromInfo: nil)
}
}
diff --git a/Sources/App/Core/Query+Support/Joined3+Package.swift b/Sources/App/Core/Query+Support/Joined3+Package.swift
index 1b9d11a36..9c67e3bac 100644
--- a/Sources/App/Core/Query+Support/Joined3+Package.swift
+++ b/Sources/App/Core/Query+Support/Joined3+Package.swift
@@ -40,4 +40,9 @@ extension Joined3 where M == Package, R1 == Repository, R2 == Version {
.filter(Repository.self, \.$owner, .custom("ilike"), owner)
.filter(Repository.self, \.$name, .custom("ilike"), repository)
}
+
+ static func query(on database: Database, packageId: Package.Id, version: Version.Kind) -> JoinedQueryBuilder {
+ query(on: database, version: version)
+ .filter(Package.self, \Package.$id == packageId)
+ }
}
diff --git a/Sources/App/Models/Repository.swift b/Sources/App/Models/Repository.swift
index 22338a08d..1b252f83b 100644
--- a/Sources/App/Models/Repository.swift
+++ b/Sources/App/Models/Repository.swift
@@ -224,6 +224,7 @@ final class Repository: @unchecked Sendable, Model, Content {
.filter(\.$package.$id == pkgId)
.first() ?? Repository(packageId: pkgId)
}
+
}
diff --git a/Sources/App/Views/PackageController/GetRoute.Model+ext.swift b/Sources/App/Views/PackageController/GetRoute.Model+ext.swift
index d4e77d1ef..b647d1696 100644
--- a/Sources/App/Views/PackageController/GetRoute.Model+ext.swift
+++ b/Sources/App/Views/PackageController/GetRoute.Model+ext.swift
@@ -181,6 +181,50 @@ extension API.PackageController.GetRoute.Model {
return .empty
}
}
+
+ func forkedListItem() -> Node {
+ if let forkedFromInfo {
+ let item: Node = {
+ switch forkedFromInfo {
+ case .fromGitHub(let url):
+ var text = url.replacingOccurrences(of: "https://github.com/", with: "")
+ text = text.removingSuffix(".git")
+ let repoURLNode: Node = .a(
+ .href(url),
+ .text(text)
+ )
+ return .group(
+ .text("Forked from "),
+ repoURLNode,
+ .text(".")
+ )
+ case .fromSPI(_, let ownerName, _, let originalPackageName):
+ let repoURLNode: Node = .a(
+ .href(forkedFromInfo.url),
+ .text("\(originalPackageName)")
+ )
+ let ownerNode: Node = .a(
+ .href(forkedFromInfo.ownerURL ?? ""),
+ .text("\(ownerName)")
+ )
+ return .group(
+ .text("Forked from "),
+ repoURLNode,
+ .text(" by "),
+ ownerNode,
+ .text(".")
+ )
+ }
+ }()
+
+ return .li(
+ .class("forked"),
+ item
+ )
+ } else {
+ return .empty
+ }
+ }
func binaryTargetsItem() -> Node {
guard hasBinaryTargets else { return .empty }
@@ -667,3 +711,24 @@ extension API.PackageController.GetRoute.Model.Swift6Readiness {
return lines.joined(separator: "\n")
}
}
+
+
+extension API.PackageController.GetRoute.Model.ForkedFromInfo {
+ var url: String {
+ switch self {
+ case .fromSPI(let originalOwner, _, let originalRepo, _):
+ return SiteURL.package(.value(originalOwner), .value(originalRepo), nil).relativeURL()
+ case .fromGitHub(let url):
+ return url
+ }
+ }
+
+ var ownerURL: String? {
+ switch self {
+ case .fromSPI(let owner, _, _, _):
+ return SiteURL.author(.value(owner)).relativeURL()
+ case .fromGitHub:
+ return nil
+ }
+ }
+}
diff --git a/Sources/App/Views/PackageController/PackageShow+View.swift b/Sources/App/Views/PackageController/PackageShow+View.swift
index 37f4a4d84..271e68543 100644
--- a/Sources/App/Views/PackageController/PackageShow+View.swift
+++ b/Sources/App/Views/PackageController/PackageShow+View.swift
@@ -167,6 +167,7 @@ extension PackageShow {
.ul(
.class("main-metadata"),
model.archivedListItem(),
+ model.forkedListItem(),
model.authorsListItem(),
model.binaryTargetsItem(),
model.historyListItem(),
diff --git a/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift b/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift
index bff7618a4..f5d242a31 100644
--- a/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift
+++ b/Tests/AppTests/API+PackageController+GetRoute+ModelTests.swift
@@ -42,7 +42,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase {
swiftVersionBuildInfo: nil,
platformBuildInfo: nil,
weightedKeywords: [],
- swift6Readiness: nil)
+ swift6Readiness: nil,
+ forkedFromInfo: nil)
// validate
XCTAssertNotNil(m)
@@ -64,7 +65,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase {
swiftVersionBuildInfo: nil,
platformBuildInfo: nil,
weightedKeywords: [],
- swift6Readiness: nil))
+ swift6Readiness: nil,
+ forkedFromInfo: nil))
// validate
XCTAssertEqual(model.packageIdentity, "swift-bar")
@@ -86,7 +88,8 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase {
swiftVersionBuildInfo: nil,
platformBuildInfo: nil,
weightedKeywords: [],
- swift6Readiness: nil))
+ swift6Readiness: nil,
+ forkedFromInfo: nil))
// validate
XCTAssertEqual(model.documentationTarget, .internal(docVersion: .reference("main"), archive: "archive1"))
@@ -112,11 +115,40 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase {
swiftVersionBuildInfo: nil,
platformBuildInfo: nil,
weightedKeywords: [],
- swift6Readiness: nil))
+ swift6Readiness: nil,
+ forkedFromInfo: nil))
// validate
XCTAssertEqual(model.documentationTarget, .external(url: "https://example.com/package/documentation"))
}
+
+ func test_ForkedFromInfo_query() async throws {
+ let originalPkg = try await savePackage(on: app.db, id: .id0, "https://github.com/original/original")
+ try await Repository(package: originalPkg,
+ name: "original",
+ owner: "original",
+ ownerName: "OriginalOwner").save(on: app.db)
+ try await App.Version(package: originalPkg, latest: .defaultBranch, packageName: "OriginalPkg", reference: .branch("main"))
+ .save(on: app.db)
+
+ // MUT
+ let forkedFrom = await API.PackageController.GetRoute.Model.ForkedFromInfo.query(on: app.db, packageId: .id0, fallbackURL: "https://github.com/original/original.git")
+
+ // validate
+ XCTAssertEqual(forkedFrom, .fromSPI(originalOwner: "original",
+ originalOwnerName: "OriginalOwner",
+ originalRepo: "original",
+ originalPackageName: "OriginalPkg"))
+ }
+
+ func test_ForkedFromInfo_query_fallback() async throws {
+ // when the package can't be found resort to fallback URL
+ // MUT
+ let forkedFrom = await API.PackageController.GetRoute.Model.ForkedFromInfo.query(on: app.db, packageId: .id0, fallbackURL: "https://github.com/original/original.git")
+
+ // validate
+ XCTAssertEqual(forkedFrom, .fromGitHub(url: "https://github.com/original/original.git"))
+ }
func test_gitHubOwnerUrl() throws {
var model = API.PackageController.GetRoute.Model.mock
@@ -144,6 +176,37 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase {
let renderedHistory = model.historyListItem().render(indentedBy: .spaces(2))
assertSnapshot(of: renderedHistory, as: .lines)
}
+
+ func test_forked_from_github() throws {
+ var model = API.PackageController.GetRoute.Model.mock
+ model.forkedFromInfo = .fromGitHub(url: "https://github.com/owner/repository.git")
+ let renderedForkedFrom = model.forkedListItem().render(indentedBy: .spaces(2))
+ assertSnapshot(of: renderedForkedFrom, as: .lines)
+ }
+
+ func test_forked_from_spi_same_package_name() throws {
+ var model = API.PackageController.GetRoute.Model.mock
+ model.forkedFromInfo = .fromSPI(
+ originalOwner: "owner",
+ originalOwnerName: "OriginalOwner",
+ originalRepo: "repo",
+ originalPackageName: "Test"
+ )
+ let renderedForkedFrom = model.forkedListItem().render(indentedBy: .spaces(2))
+ assertSnapshot(of: renderedForkedFrom, as: .lines)
+ }
+
+ func test_forked_from_spi_different_package_name() throws {
+ var model = API.PackageController.GetRoute.Model.mock
+ model.forkedFromInfo = .fromSPI(
+ originalOwner: "owner",
+ originalOwnerName: "OriginalOwner",
+ originalRepo: "repo",
+ originalPackageName: "Different"
+ )
+ let renderedForkedFrom = model.forkedListItem().render(indentedBy: .spaces(2))
+ assertSnapshot(of: renderedForkedFrom, as: .lines)
+ }
func test_binary_targets() throws {
var model = API.PackageController.GetRoute.Model.mock
@@ -329,6 +392,41 @@ class API_PackageController_GetRoute_ModelTests: SnapshotTestCase {
.fromSPIManifest("By Author One, Author Two, and more!")
XCTAssertEqual(model.authorsListItem().render(), "By Author One, Author Two, and more! ")
}
+
+ func test_forkedFrom_github_formatting() throws {
+ var model = API.PackageController.GetRoute.Model.mock
+ model.forkedFromInfo = .fromGitHub(url: "https://github.com/owner/repository.git")
+ let renderedForkedFrom = model.forkedListItem().render()
+ XCTAssertEqual(renderedForkedFrom, "Forked from owner/repository . ")
+ }
+
+ func test_forkedFrom_spi_same_package_name_formatting() throws {
+ var model = API.PackageController.GetRoute.Model.mock
+ model.forkedFromInfo = .fromSPI(
+ originalOwner: "owner",
+ originalOwnerName: "OriginalOwner",
+ originalRepo: "repo",
+ originalPackageName: "Test"
+ )
+ let url = SiteURL.package(.value("owner"), .value("repo"), nil).relativeURL()
+ let ownerUrl = model.forkedFromInfo?.ownerURL ?? ""
+ let renderedForkedFrom = model.forkedListItem().render()
+ XCTAssertEqual(renderedForkedFrom, "Forked from Test by OriginalOwner . ")
+ }
+
+ func test_forkedFrom_spi_different_package_name_formatting() throws {
+ var model = API.PackageController.GetRoute.Model.mock
+ model.forkedFromInfo = .fromSPI(
+ originalOwner: "owner",
+ originalOwnerName: "OriginalOwner",
+ originalRepo: "repo",
+ originalPackageName: "Different"
+ )
+ let url = SiteURL.package(.value("owner"), .value("repo"), nil).relativeURL()
+ let ownerUrl = model.forkedFromInfo?.ownerURL ?? ""
+ let renderedForkedFrom = model.forkedListItem().render()
+ XCTAssertEqual(renderedForkedFrom, "Forked from Different by OriginalOwner . ")
+ }
func test_BuildInfo_init() throws {
// ensure nil propagation when all versions' values are nil
diff --git a/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift b/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift
index cafa1c83d..31da3f38e 100644
--- a/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift
+++ b/Tests/AppTests/Mocks/API.PackageController.GetRoute.Model+mock.swift
@@ -125,7 +125,8 @@ extension API.PackageController.GetRoute.Model {
defaultBranchReference: .branch("main"),
releaseReference: .tag(5, 2, 0),
preReleaseReference: .tag(5, 3, 0, "beta.1"),
- swift6Readiness: nil
+ swift6Readiness: nil,
+ forkedFromInfo: nil
)
}
}
diff --git a/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from.1.txt b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from.1.txt
new file mode 100644
index 000000000..620ce3bbe
--- /dev/null
+++ b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from.1.txt
@@ -0,0 +1,3 @@
+Forked from
+ repository .
+
\ No newline at end of file
diff --git a/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_github.1.txt b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_github.1.txt
new file mode 100644
index 000000000..be633d8e1
--- /dev/null
+++ b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_github.1.txt
@@ -0,0 +1,3 @@
+Forked from
+ owner/repository .
+
\ No newline at end of file
diff --git a/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_spi_different_package_name.1.txt b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_spi_different_package_name.1.txt
new file mode 100644
index 000000000..c97d0da5a
--- /dev/null
+++ b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_spi_different_package_name.1.txt
@@ -0,0 +1,4 @@
+Forked from
+ Different by
+ OriginalOwner .
+
\ No newline at end of file
diff --git a/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_spi_same_package_name.1.txt b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_spi_same_package_name.1.txt
new file mode 100644
index 000000000..eb55eff80
--- /dev/null
+++ b/Tests/AppTests/__Snapshots__/API+PackageController+GetRoute+ModelTests/test_forked_from_spi_same_package_name.1.txt
@@ -0,0 +1,4 @@
+Forked from
+ Test by
+ OriginalOwner .
+
\ No newline at end of file