csr tangled client in solid-js
15

Configure Feed

Select the types of activity you want to include in your feed.

fix spa navigate on tangled.org / tangled.sh, resolve relative files properly on repo root

dawn (May 30, 2026, 3:09 PM +0300) 23eb6740 00267248

+115 -14
+87 -12
src/components/repo.tsx
··· 36 36 import { getRepoStarCount } from '../lib/api/stars'; 37 37 import { getRepoIssueCount } from '../lib/api/issues'; 38 38 import { getRepoPullCount } from '../lib/api/pulls'; 39 - import { formatRelativeTime, languageColor } from '../lib/repo-utils'; 39 + import { formatRelativeTime, languageColor, encodePath } from '../lib/repo-utils'; 40 40 import { getTangledAppviewService } from '../lib/settings'; 41 41 import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; 42 42 ··· 676 676 </div> 677 677 ); 678 678 679 - export const ReadmeCard: Component<{ filename?: string; markdown: string }> = (props) => ( 679 + export const ReadmeCard: Component<{ 680 + filename?: string; 681 + markdown: string; 682 + repo?: RepoContext; 683 + refName?: string; 684 + path?: string; 685 + }> = (props) => ( 680 686 <div class="mt-4 rounded bg-white dark:bg-gray-800 shadow-sm w-full mx-auto overflow-hidden"> 681 687 <Show when={props.filename}> 682 688 <div class="px-4 py-2 border-b border-gray-200 dark:border-gray-600 flex items-center gap-2"> ··· 685 691 </div> 686 692 </Show> 687 693 <section class="px-6 py-6 overflow-auto"> 688 - <MarkdownBlock markdown={props.markdown} variant="readme" /> 694 + <MarkdownBlock 695 + markdown={props.markdown} 696 + variant="readme" 697 + repo={props.repo} 698 + refName={props.refName} 699 + path={props.path} 700 + /> 689 701 </section> 690 702 </div> 691 703 ); ··· 900 912 </Show> 901 913 ); 902 914 903 - export const MarkdownBlock: Component<{ markdown: string; variant?: 'default' | 'readme' | 'code' }> = (props) => { 915 + export const resolveRelativePath = (currentPath: string, relativePath: string): string => { 916 + let normalized = relativePath; 917 + if (normalized.startsWith('/')) { 918 + normalized = normalized.slice(1); 919 + currentPath = ''; 920 + } 921 + 922 + if (normalized.startsWith('./')) { 923 + normalized = normalized.slice(2); 924 + } 925 + 926 + if (!currentPath) { 927 + return normalized; 928 + } 929 + 930 + const currentSegments = currentPath.split('/').filter(Boolean); 931 + const relativeSegments = normalized.split('/'); 932 + 933 + for (const segment of relativeSegments) { 934 + if (segment === '..') { 935 + currentSegments.pop(); 936 + } else if (segment !== '.' && segment !== '') { 937 + currentSegments.push(segment); 938 + } 939 + } 940 + 941 + return currentSegments.join('/'); 942 + }; 943 + 944 + export const MarkdownBlock: Component<{ 945 + markdown: string; 946 + variant?: 'default' | 'readme' | 'code'; 947 + repo?: RepoContext; 948 + refName?: string; 949 + path?: string; 950 + }> = (props) => { 904 951 const navigate = useNavigate(); 905 952 906 - const html = createMemo(() => 907 - DOMPurify.sanitize(marked.parse(props.markdown, { async: false }) as string, { 953 + const html = createMemo(() => { 954 + const options: any = { async: false }; 955 + 956 + if (props.repo && props.refName) { 957 + const repo = props.repo; 958 + const refName = props.refName; 959 + options.walkTokens = (token: any) => { 960 + if (token.type === 'link' || token.type === 'image') { 961 + const href = token.href; 962 + if (href && !href.startsWith('#') && !/^[a-z][a-z0-9+.-]*:/i.test(href)) { 963 + const [pathAndQuery, fragment] = href.split('#'); 964 + const [pathPart, queryPart] = pathAndQuery.split('?'); 965 + 966 + const resolvedPath = resolveRelativePath(props.path ?? '', pathPart); 967 + const typeSegment = token.type === 'image' ? 'raw' : 'tree'; 968 + const newHref = `/${repo.owner.handle}/${repo.slug}/${typeSegment}/${encodeURIComponent(refName)}${resolvedPath ? `/${encodePath(resolvedPath)}` : ''}${queryPart ? `?${queryPart}` : ''}${fragment ? `#${fragment}` : ''}`; 969 + token.href = newHref; 970 + } 971 + } 972 + }; 973 + } 974 + 975 + return DOMPurify.sanitize(marked.parse(props.markdown, options) as unknown as string, { 908 976 ADD_ATTR: ['class'], 909 - }), 910 - ); 977 + }); 978 + }); 979 + 911 980 const classes = createMemo(() => 912 981 props.variant === 'readme' 913 982 ? 'untangled-readme-prose prose prose-sm sm:prose-base min-w-0 max-w-none dark:prose-invert prose-p:text-gray-800 dark:prose-p:text-gray-200 prose-li:text-gray-800 dark:prose-li:text-gray-200 prose-pre:max-w-full prose-pre:overflow-x-auto prose-pre:rounded-md prose-pre:bg-gray-950 dark:prose-pre:bg-gray-950 prose-code:break-words' ··· 919 988 const onMarkdownClick = (event: MouseEvent) => { 920 989 const anchor = (event.target as HTMLElement).closest('a'); 921 990 if (!anchor || event.defaultPrevented) { 991 + return; 992 + } 993 + 994 + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { 922 995 return; 923 996 } 924 997 925 998 const href = anchor.getAttribute('href'); 926 - if (!href || !href.startsWith('https://tangled.org/')) { 999 + if (!href) { 927 1000 return; 928 1001 } 929 1002 930 - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { 1003 + if (href.startsWith('/') && !href.startsWith('//')) { 1004 + event.preventDefault(); 1005 + navigate(href); 931 1006 return; 932 1007 } 933 1008 934 1009 try { 935 - const url = new URL(href); 936 - if (url.hostname === 'tangled.org') { 1010 + const url = new URL(href, window.location.origin); 1011 + if (url.hostname === 'tangled.org' || url.hostname === 'tangled.sh') { 937 1012 event.preventDefault(); 938 1013 navigate(url.pathname + url.search + url.hash); 939 1014 }
+28 -2
src/pages/repo/code.tsx
··· 523 523 524 524 createEffect(() => { 525 525 const repo = repoQuery.data; 526 + const treeData = treeQuery.data; 527 + if (repo && treeData) { 528 + const tree = treeData.tree; 529 + const parent = tree?.parent ?? tree?.dotdot; 530 + if (tree && (!tree.files || tree.files.length === 0) && parent === treeData.path) { 531 + navigate(blobHref(repo, treeData.ref, treeData.path), { replace: true }); 532 + } 533 + } 534 + }); 535 + 536 + createEffect(() => { 537 + const repo = repoQuery.data; 526 538 const langs = languagesQuery.data; 527 539 if (repo && langs?.languages?.length) { 528 540 const normalized = normalizeLanguages(langs.languages, langs.totalSize); ··· 837 849 </section> 838 850 839 851 <Show when={readmeIsAvailable(tree.readme, sortedFiles) && tree.readme}> 840 - {(readme) => <ReadmeCard filename={readme().filename} markdown={readme().contents} />} 852 + {(readme) => ( 853 + <ReadmeCard 854 + filename={readme().filename} 855 + markdown={readme().contents} 856 + repo={repo} 857 + refName={activeRef} 858 + path={treePath} 859 + /> 860 + )} 841 861 </Show> 842 862 </> 843 863 ); ··· 1010 1030 </Match> 1011 1031 <Match when={isMarkdown() && showRendered()}> 1012 1032 <div class="untangled-blob-rendered overflow-auto relative"> 1013 - <MarkdownBlock markdown={text()} variant="readme" /> 1033 + <MarkdownBlock 1034 + markdown={text()} 1035 + variant="readme" 1036 + repo={repo} 1037 + refName={ref()} 1038 + path={getParentPath(path()) ?? ''} 1039 + /> 1014 1040 </div> 1015 1041 </Match> 1016 1042 <Match when={blob.content !== undefined}>