Something went wrong. Try again.
A web component for embedding code repository information on the web. npmx.dev/package/repository-embed
sourcehut gitea web-component git forgejo repository gitlab codeberg embed github bitbucket tangled
Something went wrong. Try again.
repository-embed repository-embed.js
75 kB · 2137 lines
JavaScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138(() => { if (typeof HTMLElement === "undefined" || typeof customElements === "undefined") return;
const FORGE_DEFAULT_HOSTS = { github: "github.com", gitlab: "gitlab.com", gitea: "gitea.com", forgejo: "codeberg.org", bitbucket: "bitbucket.org", tangled: "tangled.org", sourcehut: "git.sr.ht", azure: "dev.azure.com", generic: "", }; const SHORTHAND_HOSTS = { github: "github.com", gitlab: "gitlab.com", bitbucket: "bitbucket.org", tangled: "tangled.org", codeberg: "codeberg.org", gitea: "gitea.com", forgejo: "codeberg.org", sourcehut: "git.sr.ht", azure: "dev.azure.com", }; const SHORTHAND_PATTERN = /^(github|gitlab|bitbucket|tangled|codeberg|gitea|forgejo|sourcehut|azure):(.+)$/i; const TANGLED_URL = "https://tangled.org/"; const TANGLED_API = "https://api.tangled.org/xrpc"; const TANGLED_PUBLIC_API = "https://public.api.bsky.app/xrpc"; const CACHE_PREFIX = "repo_"; const CACHE_TTL = 15 * 60 * 1000; const REQUEST_TIMEOUT = 10 * 1000; const PROBE_TIMEOUT = 5 * 1000;
const GITHUB_PATH = "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"; const GITLAB_PATH = "m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z"; const CODEBERG_PATH = "M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648zz"; const GITEA_PATH = "M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z"; const FORGEJO_PATH = "M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z"; const BITBUCKET_PATH = "M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"; const SOURCEHUT_PATH = "M12 0C5.371 0 0 5.371 0 12s5.371 12 12 12 12-5.371 12-12S18.629 0 12 0Zm0 21.677A9.675 9.675 0 0 1 2.323 12 9.675 9.675 0 0 1 12 2.323 9.675 9.675 0 0 1 21.677 12 9.675 9.675 0 0 1 12 21.677Z"; const TANGLED_PATH = "M15.823 23.573c-.78-.006-1.376-.233-2.022-.629-.926-.489-1.633-1.31-2.139-2.215-.803 1-1.876 1.606-3.078 1.949-.512.149-1.408.3-2.893-.24-2.141-.721-3.7-2.956-3.524-5.232-.032-.944.31-1.87.797-2.667-1.297-.698-2.355-1.87-2.76-3.308-.246-.787-.236-1.635-.145-2.444.325-1.91 1.754-3.571 3.593-4.168.734-1.678 2.336-2.93 4.15-3.184a5.5 5.5 0 0 1 3.507.699C12.845.425 15.521-.08 17.565.97c1.558.746 2.673 2.304 2.944 4.002 1.483.596 2.732 1.812 3.222 3.354.328.956.339 2.008.126 2.989-.379 1.53-1.456 2.833-2.85 3.545.003.272.896 2.236.746 3.718-.032 1.853-1.204 3.609-2.829 4.462-.948.555-2.071.544-3.101.533M11.38 18.24c1.314-.147 2.176-1.296 2.845-2.331.316-.472.558-1 .798-1.501.311.286.575.825 1.068.954.518.162 1.126.03 1.444-.442.608-1.137.308-2.51-.046-3.687-.218-.677-.504-1.371-1.048-1.85.116-.822-.369-1.655-1.058-2.085-.588.47-1.482.467-2.048-.036-1.087 1.112-2.082 1.075-3.044.194-.216-.198-.629 1.208-2.076.412-.831.698-1.476 1.37-2.047 2.339-.555 1.043-1.137 1.97-1.187 3.103-.024.662.492 1.356 1.191 1.302.699.063 1.174-.627 1.703-.914.08.925.168 1.918.48 2.82.357 1.168 1.616 1.91 2.808 1.74zm.682-3.486c-.64-.393-.332-1.246-.357-1.866.063-.747.115-1.533.45-2.214.354-.485 1.219-.298 1.257.325-.025.626-.312 1.25-.278 1.9-.074.542.054 1.151-.185 1.647-.197.274-.596.354-.887.208m-2.792-.357c-.602-.328-.41-1.153-.505-1.726.079-.663.013-1.505.567-1.97.542-.38 1.28.27 1.023.865-.273.753-.094 1.576-.088 2.339a.708.708 0 0 1-.997.492"; const GIT_PATH = "M13.09 23.549a1.54 1.54 0 0 1-2.18 0L.451 13.089a1.54 1.54 0 0 1 0-2.179l7.191-7.19 2.733 2.733a1.85 1.85 0 0 0 .964 2.326v6.66a1.849 1.849 0 1 0 1.54 0V8.957l2.508 2.508a1.85 1.85 0 1 0 1.09-1.09l-2.634-2.634a1.85 1.85 0 0 0-2.378-2.377L8.73 2.63 10.91.451a1.54 1.54 0 0 1 2.179 0l10.459 10.46a1.54 1.54 0 0 1 0 2.179z";
const logo = (path) => `<svg class="forge-logo" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="${path}"/></svg>`;
const GITHUB_LOGO = logo(GITHUB_PATH); const GITLAB_LOGO = logo(GITLAB_PATH); const CODEBERG_LOGO = logo(CODEBERG_PATH); const GITEA_LOGO = logo(GITEA_PATH); const FORGEJO_LOGO = logo(FORGEJO_PATH); const BITBUCKET_LOGO = logo(BITBUCKET_PATH); const SOURCEHUT_LOGO = logo(SOURCEHUT_PATH); const TANGLED_LOGO = logo(TANGLED_PATH); const GIT_LOGO = logo(GIT_PATH);
const CARD_CSS = ` :host { --repo-bg: #ffffff; --repo-bg: light-dark(oklch(100% 0 0), oklch(17.1% 0 0)); --repo-border: #cecece; --repo-border: light-dark(oklch(85.14% 0 0), oklch(26.9% 0 0)); --repo-border-width: 1px; --repo-border-hover: #a3a3a3; --repo-border-hover: light-dark(oklch(71.5% 0 0), oklch(37.1% 0 0)); --repo-border-strong: #a3a3a3; --repo-border-strong: light-dark(oklch(71.5% 0 0), oklch(37.1% 0 0)); --repo-bg-muted: #f0f0f1; --repo-bg-muted: light-dark(oklch(95.5% 0.001 286.76), oklch(23.6% 0 0)); --repo-text: #000000; --repo-text: light-dark(oklch(4.6% 0 0), oklch(98.2% 0 0)); --repo-muted: #161616; --repo-muted: light-dark(oklch(19.8% 0 0), oklch(84.9% 0 0)); --repo-subtle: #292929; --repo-subtle: light-dark(oklch(28% 0 0), oklch(77.3% 0 0)); --repo-accent: #b54708; --repo-accent: light-dark(oklch(52% 0.14 48), oklch(79% 0.12 58)); --repo-danger: #c10007; --repo-danger: light-dark(oklch(50.5% 0.213 27.518), oklch(70.4% 0.191 22.216)); --repo-radius: 0.5rem; --repo-radius-sm: 0.375rem; --repo-radius-xs: 0.25rem; --repo-font: "Geist", "IBM Plex Sans Arabic", system-ui, -apple-system, sans-serif; --repo-font-mono: "Geist Mono", "IBM Plex Sans Arabic", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; display: block; inline-size: 100%; max-inline-size: 32rem; box-sizing: border-box; color-scheme: light dark; color: var(--repo-text); font-family: var(--repo-font); font-size: 1rem; line-height: 1.5; -webkit-font-smoothing: antialiased; }
:host([data-repo-style="github"]) { --repo-bg: light-dark(#ffffff, #0d1117); --repo-bg-muted: light-dark(#f6f8fa, #161b22); --repo-border: light-dark(#d0d7de, #30363d); --repo-border-hover: light-dark(#8c959f, #8b949e); --repo-border-strong: light-dark(#afb8c1, #6e7681); --repo-text: light-dark(#1f2328, #e6edf3); --repo-muted: light-dark(#656d76, #8b949e); --repo-subtle: light-dark(#6e7781, #7d8590); --repo-accent: light-dark(#0a852c, #0fbf3e); --repo-danger: light-dark(#cf222e, #f85149); }
:host([data-repo-style="gitlab"]) { --repo-bg: light-dark(#ffffff, #18171d); --repo-bg-muted: light-dark(#fbfafd, #28272d); --repo-border: light-dark(#dcdcde, #3a383f); --repo-border-hover: light-dark(#bfbfc3, #4c4b51); --repo-border-strong: light-dark(#a4a3a8, #626168); --repo-text: light-dark(#28272d, #ececef); --repo-muted: light-dark(#626168, #bfbfc3); --repo-subtle: light-dark(#737278, #a4a3a8); --repo-accent: light-dark(#c74816, #fb8017); --repo-danger: light-dark(#c52042, #fc7988); }
:host([data-repo-style="codeberg"]) { --repo-bg: light-dark(#ffffff, #171e26); --repo-bg-muted: light-dark(#fafafa, #1d262f); --repo-border: light-dark(#d4d4d8, #374351); --repo-border-hover: light-dark(#a1a1aa, #5f6e80); --repo-border-strong: light-dark(#babac1, #6d7d8f); --repo-text: light-dark(#18181b, #d2e0f0); --repo-muted: light-dark(#3f3f46, #c0cfe0); --repo-subtle: light-dark(#52525b, #aebed0); --repo-accent: light-dark(oklch(0.5534 0.1529 245.94), oklch(0.7576 0.1529 245.94)); --repo-danger: light-dark(#dc2626, #f87171); }
:host([data-repo-style="gitea"]) { --repo-bg: light-dark(#ffffff, #1c1f25); --repo-bg-muted: light-dark(#f8f9fb, #151a1e); --repo-border: light-dark(#d0d7de, #3b444a); --repo-border-hover: light-dark(#b9c0c7, #59646c); --repo-border-strong: light-dark(#99a0a7, #78858f); --repo-text: light-dark(#181c21, #d1d5d8); --repo-muted: light-dark(#40474d, #bdc3c7); --repo-subtle: light-dark(#5b6167, #a8afb5); --repo-accent: light-dark(#31699f, #7aa8d6); --repo-danger: light-dark(#db2828, #e45e5e); }
:host([data-repo-style="forgejo"]) { --repo-bg: light-dark(#ffffff, #171e26); --repo-bg-muted: light-dark(#fafafa, #1d262f); --repo-border: light-dark(#d4d4d8, #374351); --repo-border-hover: light-dark(#a1a1aa, #5f6e80); --repo-border-strong: light-dark(#babac1, #6d7d8f); --repo-text: light-dark(#18181b, #d2e0f0); --repo-muted: light-dark(#3f3f46, #c0cfe0); --repo-subtle: light-dark(#52525b, #aebed0); --repo-accent: light-dark(#c2410c, #fb923c); --repo-danger: light-dark(#dc2626, #f87171); }
:host([data-repo-style="bitbucket"]) { --repo-bg: light-dark(#ffffff, #1d2125); --repo-bg-muted: light-dark(#f7f8f9, #22272b); --repo-border: light-dark(#dcdfe4, #454f59); --repo-border-hover: light-dark(#b3b9c4, #738496); --repo-border-strong: light-dark(#8590a2, #8c9bab); --repo-text: light-dark(#172b4d, #b6c2cf); --repo-muted: light-dark(#44546f, #9fadbc); --repo-subtle: light-dark(#626f86, #8c9bab); --repo-accent: light-dark(#0c66e4, #579dff); --repo-danger: light-dark(#ae2a19, #f87168); }
:host([data-repo-style="tangled"]) { --repo-bg: light-dark(#ffffff, #111827); --repo-bg-muted: light-dark(#f9fafb, #1f2937); --repo-border: light-dark(#e5e7eb, #374151); --repo-border-hover: light-dark(#d1d5db, #4b5563); --repo-border-strong: light-dark(#9ca3af, #6b7280); --repo-text: light-dark(#111827, #f3f4f6); --repo-muted: light-dark(#4b5563, #d1d5db); --repo-subtle: light-dark(#6b7280, #9ca3af); --repo-accent: light-dark(#4f46e5, #818cf8); --repo-danger: light-dark(#dc2626, #f87171); }
:host([data-repo-style="sourcehut"]) { --repo-bg: light-dark(#ffffff, #212529); --repo-bg-muted: light-dark(#f8f9fa, #131618); --repo-border: light-dark(#dee2e6, #495057); --repo-border-hover: light-dark(#adb5bd, #6c757d); --repo-border-strong: light-dark(#ced4da, #adb5bd); --repo-text: light-dark(#212529, #f8f9fa); --repo-muted: light-dark(#6c757d, #ced4da); --repo-subtle: light-dark(#495057, #adb5bd); --repo-accent: light-dark(#0242f2, #3395ff); --repo-danger: light-dark(#dc3545, #ff5c5c); }
:host([data-repo-style="azure"]) { --repo-bg: light-dark(#ffffff, #1f1f1f); --repo-bg-muted: light-dark(#faf9f8, #252423); --repo-border: light-dark(#edebe9, #3b3a39); --repo-border-hover: light-dark(#d2d0ce, #605e5c); --repo-border-strong: light-dark(#a19f9d, #8a8886); --repo-text: light-dark(#201f1e, #f3f2f1); --repo-muted: light-dark(#605e5c, #c8c6c4); --repo-subtle: light-dark(#605e5c, #a19f9d); --repo-accent: light-dark(#0078d4, #2899f5); --repo-danger: light-dark(#a4262c, #f1707b); }
:host([hidden]) { display: none; }
*, *::before, *::after { box-sizing: border-box; }
.card { padding-block: 1rem; padding-inline: 1.125rem; border: var(--repo-border-width) solid var(--repo-border); border-radius: var(--repo-radius); background: var(--repo-bg); }
.card:not(.skeleton):not(.error):hover, .card:not(.skeleton):not(.error):focus-within { border-color: var(--repo-border-hover); }
.header { display: flex; align-items: center; gap: 0.625rem; min-inline-size: 0; }
.title { flex: 1 1 auto; min-inline-size: 0; margin: 0; overflow: hidden; font-family: var(--repo-font-mono); font-size: 1.125rem; font-weight: 500; line-height: 1.25; text-overflow: ellipsis; white-space: nowrap;
a { border-radius: var(--repo-radius-xs); color: inherit; text-decoration: none;
&:hover { color: var(--repo-accent); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 0.2rem; }
&:focus-visible { outline: 2px solid var(--repo-accent); outline-offset: 3px; } } }
.version { flex: none; padding-block: 0.1rem; padding-inline: 0.4rem; border-radius: var(--repo-radius-xs); background: color-mix(in srgb, var(--repo-accent) 10%, transparent); color: var(--repo-accent); font-family: var(--repo-font-mono); font-size: 0.75rem; font-weight: 500; font-variant-numeric: tabular-nums; line-height: 1.4; }
.description { display: -webkit-box; margin: 0; margin-block-start: 0.4rem; overflow: hidden; color: var(--repo-muted); overflow-wrap: anywhere; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; }
.og-image { display: block; max-inline-size: 100%; block-size: auto; margin-block-start: 0.75rem; border: 1px solid var(--repo-border); border-radius: var(--repo-radius-sm); background: var(--repo-bg-muted); }
.warning { margin: 0; margin-block-start: 0.75rem; padding-block: 0.45rem; padding-inline: 0.6rem; border-radius: var(--repo-radius-sm); background: color-mix(in srgb, var(--repo-danger) 8%, var(--repo-bg)); color: var(--repo-danger); font-size: 0.875rem; overflow-wrap: anywhere; }
.notice { margin: 0; margin-block-start: 0.75rem; padding-block: 0.45rem; padding-inline: 0.6rem; border-radius: var(--repo-radius-sm); background: var(--repo-bg-muted); color: var(--repo-muted); font-size: 0.875rem; overflow-wrap: anywhere; }
.card-footer { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.5rem 0.75rem; margin-block-start: 0.875rem; padding-block-start: 0.75rem; border-block-start: 1px solid var(--repo-border); }
.meta { display: flex; flex: 1 1 14rem; flex-wrap: wrap; align-items: center; gap: 0.45rem 0.8rem; color: var(--repo-text); font-family: var(--repo-font-mono); font-size: 0.875rem; font-variant-numeric: tabular-nums; }
.metric { white-space: nowrap; }
.detail { display: inline-flex; align-items: baseline; gap: 0.3rem; min-inline-size: 0; max-inline-size: 100%; }
.detail-label { flex: none; color: var(--repo-subtle); font-family: var(--repo-font); font-size: 0.75rem; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; }
.detail-value { min-inline-size: 0; overflow-wrap: anywhere; }
.detail-link { color: var(--repo-text); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--repo-text) 30%, transparent); text-decoration-thickness: 1px; text-underline-offset: 0.2rem;
&:hover { color: var(--repo-accent); text-decoration-color: var(--repo-accent); }
&:focus-visible { border-radius: var(--repo-radius-xs); outline: 2px solid var(--repo-accent); outline-offset: 2px; } }
.keywords { align-items: flex-start; }
.keyword-list { display: inline-flex; flex-wrap: wrap; gap: 0.25rem; }
.keyword { padding-block: 0.1rem; padding-inline: 0.4rem; border: 1px solid var(--repo-border); border-radius: var(--repo-radius-sm); font-family: var(--repo-font-mono); font-size: 0.75rem; line-height: 1.5; }
.view-link { display: inline-flex; flex: none; align-items: center; align-self: flex-end; gap: 0.35rem; margin-inline-start: auto; color: var(--repo-text); font-family: var(--repo-font-mono); font-size: 0.75rem; font-weight: 500; text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--repo-text) 30%, transparent); text-decoration-thickness: 1px; text-underline-offset: 0.2rem;
&:hover { color: var(--repo-accent); text-decoration-color: var(--repo-accent); }
&:focus-visible { border-radius: var(--repo-radius-xs); outline: 2px solid var(--repo-accent); outline-offset: 3px; } }
.forge-logo { display: block; flex: none; block-size: 1rem; inline-size: auto; margin-inline-start: .5ch; color: var(--repo-accent); }
.badge { display: inline-block; padding-block: 0.1rem; padding-inline: 0.4rem; border: 1px solid var(--repo-border-strong); border-radius: var(--repo-radius-sm); background: var(--repo-bg-muted); color: var(--repo-muted); font-family: var(--repo-font-mono); font-size: 0.75rem; font-weight: 500; line-height: 1.5; }
.error { display: grid; gap: 0.2rem; }
.error-title { margin: 0; font-weight: 500; }
.error-message { margin: 0; color: var(--repo-muted); font-size: 0.875rem; }
.skeleton { display: grid; gap: 0.55rem; pointer-events: none; }
.skeleton-head { display: flex; align-items: center; gap: 0.75rem; }
.skeleton-line { display: block; block-size: 0.75rem; border-radius: var(--repo-radius-xs); background: var(--repo-border); background: linear-gradient( 90deg, var(--repo-border) 25%, color-mix(in srgb, var(--repo-border) 45%, var(--repo-bg)) 50%, var(--repo-border) 75% ); background-size: 200% 100%; animation: repository-shimmer 1.2s ease-in-out infinite; }
.skeleton-title { inline-size: 42%; block-size: 0.9rem; } .skeleton-pill { inline-size: 3.25rem; block-size: 1.1rem; margin-inline-start: auto; } .skeleton-desc { inline-size: 92%; } .skeleton-desc.short { inline-size: 68%; }
.skeleton-meta { display: flex; gap: 0.5rem; margin-block-start: 0.25rem; padding-block-start: 0.75rem; border-block-start: 1px solid var(--repo-border); }
.skeleton-meta .skeleton-line { inline-size: 4.5rem; block-size: 0.7rem; } .skeleton-meta .skeleton-line:last-child { inline-size: 2.5rem; margin-inline-start: auto; }
.sr-only { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@keyframes repository-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
@media (max-width: 22rem) { .card { padding: 0.875rem; } }
@media (prefers-reduced-motion: reduce) { .card { transition: none; } .skeleton-line { animation: none; } } `;
let cardSheet = null;
const getCardSheet = () => { if (!cardSheet) { cardSheet = new CSSStyleSheet(); cardSheet.replaceSync(CARD_CSS); }
return cardSheet; };
const integerFormatter = new Intl.NumberFormat(); const compactFormatter = new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }); const sizeFormatter = new Intl.NumberFormat(undefined, { maximumFractionDigits: 1 }); const dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" });
const LANGUAGE_NAMES = { c: "C", "c++": "C++", "c#": "C#", "f#": "F#", "objective-c": "Objective-C", javascript: "JavaScript", typescript: "TypeScript", html: "HTML", css: "CSS", scss: "SCSS", sass: "Sass", less: "Less", shell: "Shell", bash: "Bash", powershell: "PowerShell", python: "Python", ruby: "Ruby", rust: "Rust", go: "Go", java: "Java", kotlin: "Kotlin", swift: "Swift", php: "PHP", dart: "Dart", elixir: "Elixir", erlang: "Erlang", haskell: "Haskell", lua: "Lua", r: "R", scala: "Scala", perl: "Perl", clojure: "Clojure", vue: "Vue", svelte: "Svelte", astro: "Astro", dockerfile: "Dockerfile", makefile: "Makefile", nix: "Nix", zig: "Zig", julia: "Julia", matlab: "MATLAB", assembly: "Assembly", cmake: "CMake", groovy: "Groovy", tex: "TeX", "jupyter notebook": "Jupyter Notebook", "vim script": "Vim Script", "emacs lisp": "Emacs Lisp", "visual basic .net": "Visual Basic .NET", batchfile: "Batchfile", hcl: "HCL", };
const FORGE_ALIASES = { github: "github", gh: "github", gitlab: "gitlab", gl: "gitlab", gitea: "gitea", gogs: "gitea", forgejo: "forgejo", codeberg: "forgejo", bitbucket: "bitbucket", bb: "bitbucket", tangled: "tangled", sourcehut: "sourcehut", srht: "sourcehut", "sr.ht": "sourcehut", azure: "azure", "azure-devops": "azure", azuredevops: "azure", generic: "generic", git: "generic", };
const FORGE_META = { github: { label: "GitHub", logo: GITHUB_LOGO }, gitlab: { label: "GitLab", logo: GITLAB_LOGO }, gitea: { label: "Gitea", logo: GITEA_LOGO }, forgejo: { label: "Forgejo", logo: FORGEJO_LOGO }, bitbucket: { label: "Bitbucket", logo: BITBUCKET_LOGO }, tangled: { label: "Tangled", logo: TANGLED_LOGO }, sourcehut: { label: "SourceHut", logo: SOURCEHUT_LOGO }, azure: { label: "Azure DevOps", logo: GIT_LOGO }, generic: { label: "Git", logo: GIT_LOGO }, };
const STATIC_FORGES = new Set(["sourcehut", "azure", "generic"]); const probeCache = new Map();
const escapeHTML = (value) => String(value ?? "").replace( /[&<>"']/g, (character) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[character], );
const sanitiser = (() => { if (typeof Sanitizer !== "function") return null;
try { return new Sanitizer({ allowAttributes: { class: ["*"], part: ["*"], title: ["*"], href: ["a"], target: ["a"], rel: ["a"], role: ["*"], "aria-hidden": ["*"], focusable: ["svg"], viewBox: ["svg"], fill: ["path"], d: ["path"], transform: ["g", "path"], src: ["img"], alt: ["img"], loading: ["img"], decoding: ["img"], }, }); } catch { return null; } })();
const setContent = (element, markup) => { if (sanitiser && typeof element.setHTML === "function") { element.setHTML(markup, { sanitizer: sanitiser }); } else { element.innerHTML = markup; } };
async function request(url, signal, timeoutMs = REQUEST_TIMEOUT) { const timeout = AbortSignal.timeout(timeoutMs); const response = await fetch(url, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: { Accept: "application/json" }, });
if (!response.ok) { const error = new Error(`Request failed with status ${response.status}`); error.status = response.status; error.rateLimited = response.status === 429 || (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0"); error.retryAfter = response.headers.get("retry-after"); throw error; }
return response; }
const fetchJSON = async (url, signal, timeoutMs) => (await request(url, signal, timeoutMs)).json();
const fetchJSONWithResponse = async (url, signal, timeoutMs) => { const response = await request(url, signal, timeoutMs); return { data: await response.json(), headers: response.headers }; };
const cache = { read(key) { try { const value = sessionStorage.getItem(key); return value ? JSON.parse(value) : null; } catch { return null; } }, write(key, value) { try { sessionStorage.setItem(key, JSON.stringify(value)); } catch { // Storage can be unavailable or full. } }, };
const cleanText = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
const safeDecode = (value) => { try { return decodeURIComponent(value); } catch { return value; } };
const normaliseHost = (value) => { let host = (value || "").trim(); if (!host) return "";
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(host)) { try { host = new URL(host).hostname; } catch { return ""; } }
return host .replace(/^www\./i, "") .replace(/\/.*$/, "") .toLowerCase(); };
const normaliseForge = (value) => { const key = (value || "").trim().toLowerCase(); return key ? FORGE_ALIASES[key] || null : null; };
const detectForge = (host) => { const value = host.toLowerCase().replace(/^www\./, "");
if (value === "github.com" || value.endsWith(".github.com") || value.includes("github")) return "github"; if (value === "gitlab.com" || value.includes("gitlab")) return "gitlab"; if (value === "codeberg.org" || value.includes("codeberg")) return "forgejo"; if (value === "gitea.com" || value.includes("gitea")) return "gitea"; if (value.includes("forgejo")) return "forgejo"; if (value === "bitbucket.org") return "bitbucket"; if (value === "tangled.org" || value === "tangled.sh" || value.endsWith(".tangled.org")) return "tangled"; if (value === "git.sr.ht" || value.endsWith(".sr.ht")) return "sourcehut"; if (value === "dev.azure.com" || value.endsWith(".visualstudio.com")) return "azure";
return "generic"; };
const STATIC_HOSTS = new Set(["git.kernel.org", "git.savannah.gnu.org", "git.savannah.nongnu.org", "sourceware.org", "git.zx2c4.com", "git.musl-libc.org"]); const isStaticHost = (host) => host === "googlesource.com" || host.endsWith(".googlesource.com") || host.endsWith(".sourceware.org") || STATIC_HOSTS.has(host);
const detectForgeFromApi = (value) => { const api = value.toLowerCase(); if (api.includes("api.github.com")) return "github"; if (api.includes("/api/v4")) return "gitlab"; if (api.includes("/api/v1")) return "gitea"; if (api.includes("/api/v3")) return "github"; if (api.includes("api.bitbucket.org")) return "bitbucket"; if (api.includes("api.tangled.org") || api.includes("/xrpc")) return "tangled"; return null; };
const forgeMeta = (forge, host) => { if (host && host.toLowerCase() === "codeberg.org") return { label: "Codeberg", logo: CODEBERG_LOGO }; if (forge === "generic" && host) return { label: host, logo: GIT_LOGO }; return FORGE_META[forge] || { label: host || "Git", logo: GIT_LOGO }; };
const hostStyleKey = (forge, host) => { const detected = detectForge(host);
if (detected === "generic") return forge; if (detected === "forgejo" && (host || "").toLowerCase() === "codeberg.org") return "codeberg"; return detected; };
const hasHostInInput = (value) => { const text = (value || "").trim().replace(/^git\+/, ""); if (!text) return false; if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) return true; if (SHORTHAND_PATTERN.test(text)) return true; if (/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/.test(text)) return true;
const parts = text.replace(/^\/+/, "").replace(/\/+$/, "").split("/").filter(Boolean); return parts.length >= 3 && (parts[0].includes(".") || parts[0].includes(":") || parts[0].toLowerCase() === "localhost"); };
const parseRepositoryInput = (input, hostOverride, defaultHost = "") => { let value = (input || "").trim(); if (!value) return null;
value = value.replace(/^git\+/, "");
let host = ""; let path = "";
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value); const shorthandMatch = !hasScheme ? value.match(SHORTHAND_PATTERN) : null; const scpMatch = !hasScheme && !shorthandMatch ? value.match(/^(?:[^@/\s]+@)?([^:/\s]+):(.+)$/) : null;
if (shorthandMatch) { host = SHORTHAND_HOSTS[shorthandMatch[1].toLowerCase()]; path = shorthandMatch[2]; } else if (scpMatch) { host = scpMatch[1]; path = scpMatch[2]; } else if (hasScheme) { try { const url = new URL(value); if (!/^(https?|ssh|git):$/i.test(url.protocol)) return null; host = url.hostname; path = url.pathname; } catch { return null; } } else { const trimmed = value.replace(/^\/+/, "").replace(/\/+$/, ""); const parts = trimmed.split("/").filter(Boolean); if (!parts.length) return null;
if (parts.length >= 3 && (parts[0].includes(".") || parts[0].includes(":") || ["localhost"].includes(parts[0].toLowerCase()))) { host = parts[0]; path = parts.slice(1).join("/"); } else { host = normaliseHost(hostOverride) || defaultHost; path = trimmed; } }
host = normaliseHost(host); path = path.split(/[?#]/)[0].replace(/^\/+/, "").replace(/\/+$/, ""); path = path.replace(/\.git$/i, "").replace(/\/+$/, "");
const parts = path.split("/").filter(Boolean).map(safeDecode); if (!host || !parts.length) return null;
return { host, path: parts.join("/"), parts }; };
const shapeRepository = (parsed, forge, apiAttribute, branchAttribute) => { const parts = parsed.parts.slice(); let owner = ""; let name = "";
if (forge === "gitlab") { const marker = parts.indexOf("-"); if (marker >= 0) parts.length = marker; if (parts.length < 2) return null; name = parts.pop(); owner = parts.join("/"); } else if (forge === "sourcehut") { if (parts.length < 2) return null; owner = parts[0]; name = parts[1]; } else if (forge === "azure") { const marker = parts.findIndex((part) => part.toLowerCase() === "_git"); if (marker >= 2 && parts[marker + 1]) { owner = parts.slice(0, marker).join("/"); name = parts[marker + 1]; } else { name = parts[parts.length - 1]; owner = parts.slice(0, -1).join("/"); } } else if (forge === "generic" && parts.length === 1) { name = parts[0]; } else { if (parts.length < 2) return null; if (forge === "generic" && isStaticHost(parsed.host)) { name = parts[parts.length - 1]; owner = parts[parts.length - 2]; } else { owner = parts[0]; name = parts[1]; } }
if (!name) return null; if (!owner && !(forge === "generic" && parts.length === 1)) return null;
const api = (apiAttribute || "").trim().replace(/\/+$/, "") || null;
return { host: parsed.host, forge, owner, name, fullName: owner ? `${owner}/${name}` : name, path: parsed.path, parts: parsed.parts, api, branch: cleanText(branchAttribute), }; };
const probeForge = async (host, signal) => { if (probeCache.has(host)) return probeCache.get(host);
const base = `https://${host}`; const checks = [ ["gitea", `${base}/api/v1/version`], ["gitlab", `${base}/api/v4/projects?per_page=1`], ["github", `${base}/api/v3`], ];
const results = await Promise.all( checks.map(async ([forge, url]) => { try { await fetchJSON(url, signal, PROBE_TIMEOUT); return forge; } catch { return null; } }), );
const detected = results.find(Boolean) || "generic"; if (!signal?.aborted) probeCache.set(host, detected); return detected; };
const pluralise = (count, singular, plural) => (count === 1 ? singular : plural || `${singular}s`);
const formatLanguage = (value) => { const text = cleanText(value); if (!text) return null; return LANGUAGE_NAMES[text.toLowerCase()] || text; };
const normaliseLicence = (value) => { if (typeof value === "string") return cleanText(value); if (!value || typeof value !== "object") return null;
const name = cleanText(value.name); if (name && name.toLowerCase() !== "other") return name;
const spdx = cleanText(value.spdx_id) || cleanText(value.spdxId); if (spdx && spdx.toUpperCase() !== "NOASSERTION") return spdx;
return cleanText(value.key) || cleanText(value.nickname) || name; };
const normaliseUrl = (value) => { if (typeof value !== "string") return null;
let url = value.trim().replace(/^git\+/, ""); if (!url) return null;
const sshMatch = url.match(/^git@([^:]+):(.+)$/); if (sshMatch) { url = `https://${sshMatch[1]}/${sshMatch[2]}`; } else if (/^github:/i.test(url)) { url = `https://github.com/${url.slice(7)}`; } else if (/^gitlab:/i.test(url)) { url = `https://gitlab.com/${url.slice(7)}`; } else if (/^bitbucket:/i.test(url)) { url = `https://bitbucket.org/${url.slice(10)}`; } else if (url.startsWith("git://")) { url = `https://${url.slice(6)}`; } else if (url.startsWith("ssh://git@")) { url = `https://${url.slice(10)}`; }
try { const parsed = new URL(url); return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null; } catch { return null; } };
const resolveImageUrl = (value, base) => { const text = cleanText(value); if (!text) return null; if (text.startsWith("/")) { try { return normaliseUrl(new URL(text, base).href); } catch { return null; } } return normaliseUrl(text); };
const displayUrl = (value) => { try { const url = new URL(value); const path = url.pathname.replace(/\.git$/, "").replace(/\/$/, ""); return `${url.hostname.replace(/^www\./, "")}${path}`; } catch { return value; } };
const formatSize = (bytes) => { if (!Number.isFinite(bytes) || bytes < 0) return "";
const units = ["B", "KB", "MB", "GB", "TB"]; let value = bytes; let unit = 0;
while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit += 1; }
return `${sizeFormatter.format(value)} ${units[unit]}`; };
const formatDate = (value) => { const date = new Date(value); return value && !Number.isNaN(date.getTime()) ? dateFormatter.format(date) : ""; };
const pageCount = (headers, body) => { const link = headers.get("link"); if (link) { const match = link.match(/[?&]page=(\d+)>;\s*rel="last"/i); if (match) return Number(match[1]); }
const total = headers.get("x-total"); if (total && /^\d+$/.test(total)) return Number(total);
return Array.isArray(body) ? body.length : null; };
const renderLabelledDetail = (label, value, part, title = "") => { if (value === null || value === undefined || value === "") return ""; return `<span class="detail" part="${part}"${title ? ` title="${escapeHTML(title)}"` : ""}><span class="detail-label">${escapeHTML(label)}</span><span class="detail-value">${escapeHTML(String(value))}</span></span>`; };
const renderLinkDetail = (label, href, text, part) => { if (!href) return ""; return `<span class="detail" part="${part}" title="${escapeHTML(href)}"><span class="detail-label">${escapeHTML(label)}</span><a class="detail-link" href="${escapeHTML(href)}" target="_blank" rel="noopener">${escapeHTML(text)}</a></span>`; };
const renderKeywords = (keywords, label) => { if (!Array.isArray(keywords) || !keywords.length) return "";
const visible = keywords.slice(0, 5); const remaining = keywords.length - visible.length; const chips = visible.map((keyword) => `<span class="keyword">${escapeHTML(keyword)}</span>`); if (remaining) chips.push(`<span class="keyword">+${remaining}</span>`);
return `<span class="detail keywords" part="topics" title="${escapeHTML(keywords.join(", "))}"><span class="detail-label">${escapeHTML(label)}</span><span class="keyword-list">${chips.join("")}</span></span>`; };
const renderCountMetric = (count, noun, part, description) => { if (!Number.isFinite(count)) return "";
const label = pluralise(count, noun); const full = `${integerFormatter.format(count)} ${label}`; const title = description ? `${full} · ${description}` : full;
return `<span class="metric" part="${part}" title="${escapeHTML(title)}">${escapeHTML(`${compactFormatter.format(count)} ${label}`)}</span>`; };
const renderImage = (data) => { if (!data.imageUrl) return "";
const alt = data.imageAlt || `${data.fullName} repository preview`; return `<img class="og-image" part="image" src="${escapeHTML(data.imageUrl)}" alt="${escapeHTML(alt)}" loading="lazy" decoding="async">`; };
const detailRenderers = { stars: (data) => renderCountMetric(data.stars, data.starNoun || "star", "stars", ""), forks: (data) => renderCountMetric(data.forks, "fork", "forks", ""), watchers: (data) => renderCountMetric(data.watchers, "watcher", "watchers", ""), issues: (data) => renderCountMetric(data.issues, "issue", "issues", data.issueDescription || ""), pulls: (data) => renderCountMetric(data.pulls, data.pullNoun || "pull", "pulls", data.pullDescription || ""), language: (data) => (data.language ? `<span class="badge" part="language" title="Primary language">${escapeHTML(data.language)}</span>` : ""), licence: (data) => renderLabelledDetail("Licence", data.licence, "licence"), release: (data) => { if (!data.release) return ""; return data.releaseUrl ? renderLinkDetail("Release", data.releaseUrl, data.release, "release") : renderLabelledDetail("Release", data.release, "release"); }, branch: (data) => renderLabelledDetail("Branch", data.branch, "branch"), updated: (data) => renderLabelledDetail("Updated", formatDate(data.updated), "updated", data.updated || ""), created: (data) => renderLabelledDetail("Created", formatDate(data.created), "created", data.created || ""), pushed: (data) => renderLabelledDetail("Pushed", formatDate(data.pushed), "pushed", data.pushed || ""), size: (data) => { const size = formatSize(data.size); return size ? `<span class="metric" part="size" title="Repository size">${escapeHTML(size)}</span>` : ""; }, topics: (data) => renderKeywords(data.topics, "Topics"), homepage: (data) => renderLinkDetail("Homepage", data.homepage, displayUrl(data.homepage), "homepage"), clone: (data) => renderLinkDetail("Clone", data.cloneUrl, displayUrl(data.cloneUrl), "clone"), owner: (data) => renderLabelledDetail("Owner", data.author || data.owner, "owner"), forge: (data) => { const meta = forgeMeta(data.forge, data.host); return `<span class="badge" part="forge" title="Forge">${escapeHTML(meta.label)}</span>`; }, archived: (data) => (typeof data.archived === "boolean" ? renderLabelledDetail("Archived", data.archived ? "Yes" : "No", "archived") : ""), mirror: (data) => (typeof data.mirror === "boolean" ? renderLabelledDetail("Mirror", data.mirror ? "Yes" : "No", "mirror") : ""), visibility: (data) => renderLabelledDetail("Visibility", data.visibility, "visibility"), image: () => "", };
const DEFAULT_DETAILS = ["stars", "forks", "language"]; const ALL_DETAILS = Object.keys(detailRenderers); const DETAIL_ALIASES = { license: "licence", };
const requirementsFor = (forge, details, branch) => { const requirements = new Set(); const has = (detail) => details.includes(detail);
if (has("release")) requirements.add("release"); if (has("pulls") && ["github", "gitlab", "bitbucket", "tangled"].includes(forge)) requirements.add("pulls"); if (has("issues") && (forge === "gitlab" || forge === "tangled")) requirements.add("issues"); if (has("language") && (forge === "gitlab" || forge === "tangled")) requirements.add("language"); if ((has("stars") || has("forks") || has("watchers")) && forge === "bitbucket") requirements.add("counts"); if (has("stars") && forge === "tangled") requirements.add("stars"); if (forge === "tangled" || (branch && !STATIC_FORGES.has(forge))) requirements.add("branch"); if (has("size") && forge === "tangled") requirements.add("language");
return requirements; };
const branchError = (branch, fullName) => { const error = new Error(`Branch "${branch}" could not be found in "${fullName}".`); error.code = "branch"; error.branch = branch; return error; };
const createStaticRepository = (target) => { const meta = forgeMeta(target.forge, target.host);
return { cachedAt: Date.now(), static: true, fetched: [], forge: target.forge, host: target.host, owner: target.owner, name: target.name, fullName: target.fullName, description: null, url: `https://${target.host}/${target.parts.join("/")}`, homepage: null, cloneUrl: null, stars: null, starNoun: "star", forks: null, watchers: null, issues: null, pulls: null, language: null, licence: null, release: null, releaseUrl: null, branch: target.branch || null, created: null, updated: null, pushed: null, size: null, topics: [], archived: null, mirror: null, visibility: null, author: target.owner, imageUrl: null, imageAlt: null, imageFallbackUrl: null, notice: `${meta.label} does not expose repository metadata through a public cross-origin API, so only the repository link is shown.`, }; };
const githubApiBase = (target) => target.api || (target.host === "github.com" ? "https://api.github.com" : `https://${target.host}/api/v3`);
const loadGitHub = async (target, requirements, signal) => { const api = githubApiBase(target); const repoPath = `${encodeURIComponent(target.owner)}/${encodeURIComponent(target.name)}`; const repoUrl = `${api}/repos/${repoPath}`; const raw = await fetchJSON(repoUrl, signal); const url = normaliseUrl(raw.html_url) || `https://github.com/${target.fullName}`; const fetched = []; let release = null; let releaseUrl = null; let pulls = null; let branch = cleanText(raw.default_branch); let updated = cleanText(raw.updated_at); let pushed = cleanText(raw.pushed_at);
if (requirements.has("release")) { const latest = await fetchJSON(`${repoUrl}/releases/latest`, signal).catch(() => null);
if (latest?.tag_name) { release = latest.tag_name; releaseUrl = normaliseUrl(latest.html_url) || `${url}/releases/tag/${encodeURIComponent(release)}`; } else { const tags = await fetchJSON(`${repoUrl}/tags?per_page=1`, signal).catch(() => null); if (Array.isArray(tags) && tags[0]?.name) { release = tags[0].name; releaseUrl = `${url}/releases/tag/${encodeURIComponent(release)}`; } }
fetched.push("release"); }
if (requirements.has("pulls")) { const { data, headers } = await fetchJSONWithResponse(`${repoUrl}/pulls?state=open&per_page=1`, signal).catch(() => ({ data: null, headers: new Headers(), }));
pulls = pageCount(headers, data); fetched.push("pulls"); }
if (target.branch) { branch = target.branch;
const branchData = await fetchJSON(`${repoUrl}/branches/${encodeURIComponent(target.branch)}`, signal).catch((error) => { if (error.status === 404 || error.status === 422) throw branchError(target.branch, raw.full_name || target.fullName); return null; }); const date = cleanText(branchData?.commit?.commit?.committer?.date) || cleanText(branchData?.commit?.commit?.author?.date);
if (date) { updated = date; pushed = date; }
fetched.push("branch"); }
return { cachedAt: Date.now(), fetched, forge: "github", host: target.host, owner: raw.owner?.login || target.owner, name: raw.name || target.name, fullName: raw.full_name || target.fullName, description: cleanText(raw.description), url, homepage: normaliseUrl(raw.homepage), cloneUrl: normaliseUrl(raw.clone_url), stars: Number.isFinite(raw.stargazers_count) ? raw.stargazers_count : null, starNoun: "star", forks: Number.isFinite(raw.forks_count) ? raw.forks_count : null, watchers: Number.isFinite(raw.subscribers_count) ? raw.subscribers_count : null, issues: Number.isFinite(raw.open_issues_count) ? raw.open_issues_count : null, issueDescription: "open issues and pull requests", pulls, pullNoun: "pull", pullDescription: "open pull requests", language: formatLanguage(raw.language), licence: normaliseLicence(raw.license), release, releaseUrl, branch, created: cleanText(raw.created_at), updated, pushed, size: Number.isFinite(raw.size) ? raw.size * 1024 : null, topics: Array.isArray(raw.topics) ? raw.topics.filter((topic) => typeof topic === "string" && topic.trim()).map((topic) => topic.trim()) : [], archived: Boolean(raw.archived), mirror: Boolean(raw.mirror_url), visibility: cleanText(raw.visibility) || (raw.private ? "private" : "public"), author: raw.owner?.login || target.owner, imageUrl: target.host === "github.com" ? `https://opengraph.githubassets.com/0/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.name)}` : null, imageAlt: `${raw.full_name || target.fullName} repository preview`, imageFallbackUrl: resolveImageUrl(raw.owner?.avatar_url, url), }; };
const gitlabApiBase = (target) => target.api || `https://${target.host}/api/v4`;
const loadGitLab = async (target, requirements, signal) => { const api = gitlabApiBase(target); const projectPath = encodeURIComponent(target.fullName); const projectUrl = `${api}/projects/${projectPath}`; const raw = await fetchJSON(`${projectUrl}?license=true`, signal); const url = normaliseUrl(raw.web_url) || `https://${target.host}/${target.fullName}`; const fetched = []; let language = null; let issues = null; let pulls = null; let release = null; let releaseUrl = null; let branch = cleanText(raw.default_branch); let updated = cleanText(raw.last_activity_at); let pushed = cleanText(raw.last_activity_at);
if (requirements.has("language")) { const languages = await fetchJSON(`${projectUrl}/languages`, signal).catch(() => null); if (languages && typeof languages === "object") { const top = Object.entries(languages).sort((a, b) => Number(b[1]) - Number(a[1]))[0]; language = formatLanguage(top?.[0]); } fetched.push("language"); }
if (requirements.has("issues")) { const stats = await fetchJSON(`${projectUrl}/issues_statistics`, signal).catch(() => null); issues = Number.isFinite(stats?.statistics?.counts?.opened) ? stats.statistics.counts.opened : null; fetched.push("issues"); }
if (requirements.has("pulls")) { const { data, headers } = await fetchJSONWithResponse(`${projectUrl}/merge_requests?state=opened&per_page=1`, signal).catch(() => ({ data: null, headers: new Headers(), }));
pulls = pageCount(headers, data); fetched.push("pulls"); }
if (requirements.has("release")) { const tags = await fetchJSON(`${projectUrl}/repository/tags?per_page=1`, signal).catch(() => null); if (Array.isArray(tags) && tags[0]?.name) { release = tags[0].name; releaseUrl = `${url}/-/tags/${encodeURIComponent(release)}`; } fetched.push("release"); }
if (target.branch) { branch = target.branch;
const commits = await fetchJSON(`${projectUrl}/repository/commits?ref_name=${encodeURIComponent(target.branch)}&per_page=1`, signal).catch((error) => { if (error.status === 404) throw branchError(target.branch, raw.path_with_namespace || target.fullName); return null; });
if (Array.isArray(commits)) { if (!commits.length) throw branchError(target.branch, raw.path_with_namespace || target.fullName);
const date = cleanText(commits[0].committed_date) || cleanText(commits[0].created_at); if (date) { updated = date; pushed = date; } }
fetched.push("branch"); }
return { cachedAt: Date.now(), fetched, forge: "gitlab", host: target.host, owner: raw.namespace?.full_path || target.owner, name: raw.path || raw.name || target.name, fullName: raw.path_with_namespace || target.fullName, description: cleanText(raw.description), url, homepage: null, cloneUrl: normaliseUrl(raw.http_url_to_repo), stars: Number.isFinite(raw.star_count) ? raw.star_count : null, starNoun: "star", forks: Number.isFinite(raw.forks_count) ? raw.forks_count : null, watchers: null, issues, issueDescription: "open issues", pulls, pullNoun: "merge request", pullDescription: "open merge requests", language, licence: normaliseLicence(raw.license), release, releaseUrl, branch, created: cleanText(raw.created_at), updated, pushed, size: null, topics: (Array.isArray(raw.topics) && raw.topics.length ? raw.topics : Array.isArray(raw.tag_list) ? raw.tag_list : []) .filter((topic) => typeof topic === "string" && topic.trim()) .map((topic) => topic.trim()), archived: typeof raw.archived === "boolean" ? raw.archived : null, mirror: typeof raw.mirror === "boolean" ? raw.mirror : null, visibility: cleanText(raw.visibility), author: raw.namespace?.name || raw.namespace?.full_path || target.owner, imageUrl: resolveImageUrl(raw.avatar_url, url), imageAlt: `${raw.path_with_namespace || target.fullName} avatar`, imageFallbackUrl: null, }; };
const giteaApiBase = (target) => target.api || `https://${target.host}/api/v1`;
const loadGitea = async (target, requirements, signal) => { const api = giteaApiBase(target); const repoUrl = `${api}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.name)}`; const raw = await fetchJSON(repoUrl, signal); const url = normaliseUrl(raw.html_url) || `https://${target.host}/${target.fullName}`; const isForgejo = target.forge !== "gitea"; const fetched = []; let release = null; let releaseUrl = null; let branch = cleanText(raw.default_branch); let updated = cleanText(raw.updated_at); let pushed = cleanText(raw.updated_at);
if (requirements.has("release")) { const releases = await fetchJSON(`${repoUrl}/releases?limit=1`, signal).catch(() => null); const latest = Array.isArray(releases) ? releases[0] : null;
if (latest?.tag_name) { release = latest.tag_name; releaseUrl = normaliseUrl(latest.html_url) || `${url}/releases/tag/${encodeURIComponent(release)}`; } else { const tags = await fetchJSON(`${repoUrl}/tags?limit=1`, signal).catch(() => null); if (Array.isArray(tags) && tags[0]?.name) { release = tags[0].name; releaseUrl = `${url}/releases/tag/${encodeURIComponent(release)}`; } }
fetched.push("release"); }
if (target.branch) { branch = target.branch;
const branchData = await fetchJSON(`${repoUrl}/branches/${encodeURIComponent(target.branch)}`, signal).catch((error) => { if (error.status === 404) throw branchError(target.branch, raw.full_name || target.fullName); return null; }); const date = cleanText(branchData?.commit?.timestamp) || cleanText(branchData?.commit?.committer?.date);
if (date) { updated = date; pushed = date; }
fetched.push("branch"); }
return { cachedAt: Date.now(), fetched, forge: isForgejo ? "forgejo" : "gitea", host: target.host, owner: raw.owner?.login || target.owner, name: raw.name || target.name, fullName: raw.full_name || target.fullName, description: cleanText(raw.description), url, homepage: normaliseUrl(raw.website), cloneUrl: normaliseUrl(raw.clone_url), stars: Number.isFinite(raw.stars_count) ? raw.stars_count : null, starNoun: "star", forks: Number.isFinite(raw.forks_count) ? raw.forks_count : null, watchers: Number.isFinite(raw.watchers_count) ? raw.watchers_count : null, issues: Number.isFinite(raw.open_issues_count) ? raw.open_issues_count : null, issueDescription: "open issues", pulls: Number.isFinite(raw.open_pr_counter) ? raw.open_pr_counter : null, pullNoun: "pull", pullDescription: "open pull requests", language: formatLanguage(raw.language), licence: normaliseLicence(raw.license), release, releaseUrl, branch, created: cleanText(raw.created_at), updated, pushed, size: Number.isFinite(raw.size) ? raw.size * 1024 : null, topics: Array.isArray(raw.topics) ? raw.topics.filter((topic) => typeof topic === "string" && topic.trim()).map((topic) => topic.trim()) : [], archived: Boolean(raw.archived), mirror: Boolean(raw.mirror), visibility: raw.private ? "private" : raw.internal ? "internal" : "public", author: raw.owner?.login || target.owner, imageUrl: isForgejo ? `${url}/-/summary-card` : resolveImageUrl(raw.avatar_url, url), imageAlt: `${raw.full_name || target.fullName} ${isForgejo ? "summary card" : "avatar"}`, imageFallbackUrl: isForgejo ? resolveImageUrl(raw.avatar_url, url) : null, }; };
const bitbucketApiBase = (target) => target.api || "https://api.bitbucket.org/2.0";
const loadBitbucket = async (target, requirements, signal) => { const api = bitbucketApiBase(target); const repoUrl = `${api}/repositories/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.name)}`; const raw = await fetchJSON(repoUrl, signal); const url = normaliseUrl(raw.links?.html?.href) || `https://bitbucket.org/${raw.full_name || target.fullName}`; const fetched = []; let watchers = null; let forks = null; let pulls = null; let release = null; let releaseUrl = null; let branch = cleanText(raw.mainbranch?.name); let updated = cleanText(raw.updated_on); let pushed = cleanText(raw.updated_on);
if (requirements.has("counts")) { const [watchersData, forksData] = await Promise.all([fetchJSON(`${repoUrl}/watchers?pagelen=0`, signal).catch(() => null), fetchJSON(`${repoUrl}/forks?pagelen=0`, signal).catch(() => null)]);
watchers = Number.isFinite(watchersData?.size) ? watchersData.size : null; forks = Number.isFinite(forksData?.size) ? forksData.size : null; fetched.push("counts"); }
if (requirements.has("pulls")) { const pullRequests = await fetchJSON(`${repoUrl}/pullrequests?state=OPEN&pagelen=0`, signal).catch(() => null); pulls = Number.isFinite(pullRequests?.size) ? pullRequests.size : null; fetched.push("pulls"); }
if (requirements.has("release")) { const tags = await fetchJSON(`${repoUrl}/refs/tags?pagelen=1&sort=-target.date`, signal).catch(() => null); const tag = Array.isArray(tags?.values) ? tags.values[0] : null;
if (tag?.name) { release = tag.name; releaseUrl = `${url}/src/${encodeURIComponent(release)}`; }
fetched.push("release"); }
if (target.branch) { branch = target.branch;
const commits = await fetchJSON(`${repoUrl}/commits/${encodeURIComponent(target.branch)}?pagelen=1`, signal).catch((error) => { if (error.status === 404) throw branchError(target.branch, raw.full_name || target.fullName); return null; }); const values = Array.isArray(commits?.values) ? commits.values : null;
if (values) { if (!values.length) throw branchError(target.branch, raw.full_name || target.fullName);
const date = cleanText(values[0].date); if (date) { updated = date; pushed = date; } }
fetched.push("branch"); }
return { cachedAt: Date.now(), fetched, forge: "bitbucket", host: target.host, owner: raw.workspace?.slug || raw.owner?.username || target.owner, name: raw.name || raw.slug || target.name, fullName: raw.full_name || target.fullName, description: cleanText(raw.description), url, homepage: normaliseUrl(raw.website), cloneUrl: normaliseUrl((Array.isArray(raw.links?.clone) ? raw.links.clone : []).find((entry) => entry?.name === "https")?.href), stars: watchers, starNoun: "watcher", forks, watchers, issues: null, pulls, pullNoun: "pull request", pullDescription: "open pull requests", language: formatLanguage(raw.language), licence: null, release, releaseUrl, branch, created: cleanText(raw.created_on), updated, pushed, size: Number.isFinite(raw.size) ? raw.size : null, topics: [], archived: null, mirror: null, visibility: raw.is_private ? "private" : "public", author: raw.owner?.display_name || raw.owner?.nickname || raw.workspace?.name || target.owner, imageUrl: resolveImageUrl(raw.links?.avatar?.href, url), imageAlt: `${raw.full_name || target.fullName} avatar`, imageFallbackUrl: null, }; };
const tangledWebUrl = (target) => `${TANGLED_URL}${target.owner}/${target.name}`;
const normaliseKnot = (value) => { const text = cleanText(value); return text ? text .replace(/^https?:\/\//i, "") .replace(/\/.*$/, "") .toLowerCase() : null; };
const resolveTangledOwner = async (owner, signal) => { if (/^did:/i.test(owner)) return owner;
const response = await fetchJSON(`${TANGLED_PUBLIC_API}/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(owner)}`, signal); return cleanText(response?.did); };
const loadTangled = async (target, requirements, signal) => { const ownerDid = await resolveTangledOwner(target.owner, signal);
if (!ownerDid) { const error = new Error(`Could not resolve the Tangled handle "${target.owner}".`); error.status = 404; throw error; }
const recordUri = `at://${ownerDid}/sh.tangled.repo/${target.name}`; const result = await fetchJSON(`${TANGLED_API}/sh.tangled.repo.getRepo?repo=${encodeURIComponent(recordUri)}`, signal); const record = result?.value || {}; const repoDid = cleanText(record.repoDid); const knot = normaliseKnot(record.knot); const fetched = []; let stars = null; let issues = null; let pulls = null; let language = null; let branch = cleanText(target.branch); let updated = null; let size = null; let release = null; let releaseUrl = null;
if (requirements.has("stars")) { if (repoDid) { const count = await fetchJSON(`${TANGLED_API}/sh.tangled.feed.countStars?subject=${encodeURIComponent(repoDid)}`, signal).catch(() => null); stars = Number.isFinite(count?.count) ? count.count : null; } fetched.push("stars"); }
if (requirements.has("issues")) { if (repoDid) { const count = await fetchJSON(`${TANGLED_API}/sh.tangled.repo.countIssues?subject=${encodeURIComponent(repoDid)}`, signal).catch(() => null); issues = Number.isFinite(count?.count) ? count.count : null; } fetched.push("issues"); }
if (requirements.has("pulls")) { if (repoDid) { const count = await fetchJSON(`${TANGLED_API}/sh.tangled.repo.countPulls?subject=${encodeURIComponent(repoDid)}`, signal).catch(() => null); pulls = Number.isFinite(count?.count) ? count.count : null; } fetched.push("pulls"); }
if (knot) { const encodedRepo = encodeURIComponent(`${ownerDid}/${target.name}`);
if (requirements.has("language")) { const languages = await fetchJSON(`https://${knot}/xrpc/sh.tangled.repo.languages?repo=${encodedRepo}`, signal).catch(() => null); const top = Array.isArray(languages?.languages) ? languages.languages[0] : null; language = formatLanguage(top?.name); size = Number.isFinite(languages?.totalSize) ? languages.totalSize : null; fetched.push("language"); }
if (requirements.has("branch")) { if (target.branch) { let log = null;
try { log = await fetchJSON(`https://${knot}/xrpc/sh.tangled.repo.log?repo=${encodedRepo}&ref=${encodeURIComponent(target.branch)}&limit=1`, signal); } catch (error) { if (error.status === 404) throw branchError(target.branch, target.fullName); }
if (log) { const commit = Array.isArray(log.commits) ? log.commits[0] : null; if (!commit) throw branchError(target.branch, target.fullName);
updated = cleanText(commit.committer?.When) || cleanText(commit.author?.When); } } else { const defaultBranch = await fetchJSON(`https://${knot}/xrpc/sh.tangled.repo.getDefaultBranch?repo=${encodedRepo}`, signal).catch(() => null); branch = cleanText(defaultBranch?.name); const when = cleanText(defaultBranch?.when); updated = when && when !== "1970-01-01T00:00:00Z" ? when : null; }
fetched.push("branch"); }
if (requirements.has("release")) { const tags = await fetchJSON(`https://${knot}/xrpc/sh.tangled.repo.tags?repo=${encodedRepo}`, signal).catch(() => null); const tag = Array.isArray(tags?.tags) ? tags.tags[0] : null;
if (tag?.name) { release = tag.name; releaseUrl = `${tangledWebUrl(target)}/tags/${encodeURIComponent(release)}`; }
fetched.push("release"); } } else { if (requirements.has("language")) fetched.push("language"); if (requirements.has("branch")) fetched.push("branch"); if (requirements.has("release")) fetched.push("release"); }
return { cachedAt: Date.now(), fetched, forge: "tangled", host: target.host, owner: target.owner, name: target.name, fullName: `${target.owner}/${target.name}`, description: cleanText(record.description), url: tangledWebUrl(target), homepage: normaliseUrl(record.website), cloneUrl: `https://${knot || "tangled.org"}/${target.owner}/${target.name}`, stars, starNoun: "star", forks: null, watchers: null, issues, issueDescription: "open issues", pulls, pullNoun: "pull", pullDescription: "open pull requests", language, licence: null, release, releaseUrl, branch, created: cleanText(record.createdAt), updated, pushed: updated, size, topics: Array.isArray(record.topics) ? record.topics.filter((topic) => typeof topic === "string" && topic.trim()).map((topic) => topic.trim()) : [], archived: null, mirror: null, visibility: "public", author: target.owner, imageUrl: `${tangledWebUrl(target)}/opengraph`, imageAlt: `${target.fullName} preview`, imageFallbackUrl: null, }; };
const loadRepository = (target, requirements, signal) => { switch (target.forge) { case "github": return loadGitHub(target, requirements, signal); case "gitlab": return loadGitLab(target, requirements, signal); case "gitea": case "forgejo": return loadGitea(target, requirements, signal); case "bitbucket": return loadBitbucket(target, requirements, signal); case "tangled": return loadTangled(target, requirements, signal); default: return Promise.resolve(createStaticRepository(target)); } };
class RepoCard extends HTMLElement { static observedAttributes = ["repo", "host", "forge", "api", "details", "branch"];
#content; #data = null; #controller = null; #requestId = 0; #warned = new Set(); #connected = false;
constructor() { super(); this.attachShadow({ mode: "open" });
this.shadowRoot.adoptedStyleSheets = [getCardSheet()]; this.shadowRoot.innerHTML = `<div id="content"></div>`; this.#content = this.shadowRoot.querySelector("#content"); }
connectedCallback() { this.#connected = true; this.#load(); }
disconnectedCallback() { this.#connected = false; this.#requestId += 1; this.#controller?.abort(); this.#controller = null; }
attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue || !this.#connected) return;
if (name === "details") { if (!this.#data) { this.#load(); return; }
const branch = (this.getAttribute("branch") || "").trim(); const requirements = requirementsFor(this.#data.forge, this.#details(), branch); if (this.#data.static || [...requirements].every((requirement) => Array.isArray(this.#data.fetched) && this.#data.fetched.includes(requirement))) { this.#render(this.#data); } else { this.#load(); } return; }
this.#load(); }
#warnUnavailable(detail, data) { const key = `${data.forge}:${data.fullName}:${detail}`; if (this.#warned.has(key)) return; this.#warned.add(key); console.warn(`[repo-card] Detail "${detail}" is unavailable for ${data.fullName}.`); }
async #load() { const requestId = ++this.#requestId; const repoInput = (this.getAttribute("repo") || "").trim(); const hostOverride = (this.getAttribute("host") || "").trim(); const forgeAttribute = (this.getAttribute("forge") || "").trim(); const apiAttribute = (this.getAttribute("api") || "").trim(); const branchAttribute = (this.getAttribute("branch") || "").trim(); const details = this.#details();
this.#controller?.abort(); this.#controller = null;
if (!repoInput) { this.#data = null; this.#renderError("No repository specified", "Add a repo attribute to display repository metadata."); return; }
let explicitForge = null;
if (forgeAttribute) { explicitForge = normaliseForge(forgeAttribute);
if (!explicitForge) { this.#data = null; this.#renderError("Unknown forge", `"${forgeAttribute}" is not a supported forge.`); return; } } else if (apiAttribute) { explicitForge = detectForgeFromApi(apiAttribute); }
const apiHost = (() => { if (!apiAttribute) return ""; try { return new URL(apiAttribute).hostname; } catch { return ""; } })(); const defaultHost = normaliseHost(hostOverride) || normaliseHost(apiHost) || (explicitForge ? FORGE_DEFAULT_HOSTS[explicitForge] : "") || "";
if (!defaultHost && !hasHostInInput(repoInput)) { this.#data = null; this.#renderError("No host specified", "Add a host attribute, use a forge prefix such as codeberg:owner/repo, or use a full repository URL."); return; }
const parsed = parseRepositoryInput(repoInput, hostOverride, defaultHost);
if (!parsed) { this.#data = null; this.#renderError("Invalid repository", "Use owner/repo with a host, a forge prefix, a repository URL, or a clone URL."); return; }
this.#data = null; this.#renderLoading();
const controller = new AbortController(); this.#controller = controller; let target = null;
try { let forge = explicitForge || detectForge(parsed.host);
if (forge === "generic" && !explicitForge && !isStaticHost(parsed.host)) { forge = await probeForge(parsed.host, controller.signal); if (requestId !== this.#requestId) return; }
target = shapeRepository(parsed, forge, apiAttribute, branchAttribute);
if (!target) { this.#renderError("Invalid repository", "The repository path could not be understood."); return; }
const requirements = requirementsFor(forge, details, target.branch); const cacheKey = `${CACHE_PREFIX}${forge}:${target.host.toLowerCase()}/${target.fullName}${target.branch ? `#${target.branch}` : ""}${target.api ? `@${target.api}` : ""}`; const cached = cache.read(cacheKey); const cacheAge = cached && Number.isFinite(cached.cachedAt) ? Date.now() - cached.cachedAt : Infinity; const cacheValid = cacheAge < CACHE_TTL && (cached?.static || [...requirements].every((requirement) => Array.isArray(cached?.fetched) && cached.fetched.includes(requirement)));
if (cacheValid) { this.#data = cached; this.#render(cached); return; }
const data = STATIC_FORGES.has(forge) ? createStaticRepository(target) : await loadRepository(target, requirements, controller.signal);
if (requestId !== this.#requestId) return;
cache.write(cacheKey, data); this.#data = data; this.#render(data); } catch (error) { if (error.name === "AbortError" || requestId !== this.#requestId) return;
this.#data = null; const label = target ? forgeMeta(target.forge, target.host).label : explicitForge ? forgeMeta(explicitForge, parsed.host).label : parsed.host;
if (error.code === "branch") { this.#renderError("Branch not found", error.message); } else if (error.status === 404) { this.#renderError("Repository not found", `"${target?.fullName || parsed.path}" could not be found on ${label}.`); } else if (error.status === 401 || error.status === 403 || error.status === 429 || error.rateLimited) { this.#renderError("Unable to access repository", `${label} rejected the request. This may be a rate limit or a private repository.`); } else { this.#renderError("Unable to load repository", `${label} could not be reached. Please try again later.`); } } finally { if (this.#controller === controller) this.#controller = null; } }
#details() { const attribute = this.getAttribute("details"); if (attribute === null) return DEFAULT_DETAILS;
const requested = new Set( attribute .split(/[\s,]+/) .map((detail) => detail.trim().toLowerCase()) .map((detail) => (Object.hasOwn(DETAIL_ALIASES, detail) ? DETAIL_ALIASES[detail] : detail)) .filter(Boolean), );
if (requested.has("all")) return ALL_DETAILS; return ALL_DETAILS.filter((detail) => requested.has(detail)); }
#render(data) { const styleKey = hostStyleKey(data.forge, data.host); this.dataset.repoStyle = styleKey;
const meta = forgeMeta(data.forge, data.host); const name = escapeHTML(data.fullName || `${data.owner}/${data.name}`); const branch = cleanText(data.branch); const branchAttribute = (this.getAttribute("branch") || "").trim(); const release = cleanText(data.release); const pill = branch || release; const pillTitle = branch ? (branchAttribute ? "Selected branch" : "Default branch") : release ? "Latest release" : ""; const description = (data.description || "").trim(); const href = escapeHTML(data.url); const details = this.#details(); const showImage = details.includes("image"); const image = showImage ? renderImage(data) : ""; const visibleDetails = details.filter((detail) => detail !== "image" && !(data.starNoun === "watcher" && detail === "watchers" && details.includes("stars"))); const metadata = visibleDetails .map((detail) => { const rendered = detailRenderers[detail]?.(data) || ""; if (!rendered && !data.static) this.#warnUnavailable(detail, data); return rendered; }) .filter(Boolean) .join("");
if (showImage && !image && !data.static) this.#warnUnavailable("image", data);
setContent( this.#content, ` <article class="card ${styleKey}" part="card"> <header class="header"> <h2 class="title" part="title"> <a href="${href}" target="_blank" rel="noopener" title="View ${name} on ${escapeHTML(meta.label)}" >${name}</a> </h2> ${pill ? `<span class="version" part="version" title="${escapeHTML(pillTitle)}">${escapeHTML(pill)}</span>` : ""} </header> ${image} ${description ? `<p class="description" part="description" title="${escapeHTML(description)}">${escapeHTML(description)}</p>` : ""} ${data.notice ? `<p class="notice" part="notice" role="note">${escapeHTML(data.notice)}</p>` : ""} ${data.archived ? `<p class="warning" part="warning" role="note"><strong>Archived:</strong> This repository is read-only.</p>` : ""} <footer class="card-footer" part="footer"> ${metadata ? `<div class="meta" part="meta">${metadata}</div>` : ""} <a class="view-link" part="link" href="${href}" target="_blank" rel="noopener" title="Open ${name} on ${escapeHTML(meta.label)}" > <span>View on ${escapeHTML(meta.label)}</span> ${meta.logo} </a> </footer> </article> `, );
if (image && data.imageFallbackUrl) { const element = this.#content.querySelector(".og-image");
if (element) { const applyFallback = () => { if (element.src === data.imageFallbackUrl) return; element.src = data.imageFallbackUrl; element.alt = `${data.fullName} avatar`; };
element.addEventListener("error", applyFallback, { once: true }); if (element.complete && element.naturalWidth === 0) applyFallback(); } } }
#renderLoading() { setContent( this.#content, ` <article class="card skeleton" part="card" role="status"> <span class="sr-only">Loading repository information...</span> <div class="skeleton-head" aria-hidden="true"> <span class="skeleton-line skeleton-title"></span> <span class="skeleton-line skeleton-pill"></span> </div> <span class="skeleton-line skeleton-desc" aria-hidden="true"></span> <span class="skeleton-line skeleton-desc short" aria-hidden="true"></span> <div class="skeleton-meta" aria-hidden="true"> <span class="skeleton-line"></span> <span class="skeleton-line"></span> <span class="skeleton-line"></span> </div> </article> `, ); }
#renderError(title, message) { setContent( this.#content, ` <article class="card error" part="card" role="alert"> <p class="error-title" part="error-title">${escapeHTML(title)}</p> <p class="error-message" part="error-message">${escapeHTML(message)}</p> </article> `, ); } }
if (!customElements.get("repo-card")) { customElements.define("repo-card", RepoCard); }})();