├── .gitignore ├── README.md ├── TODO.md ├── demo.mov ├── globals.d.ts ├── main.ts ├── mdToJson.ts ├── package-lock.json ├── package.json ├── thumb.png ├── tsconfig.json ├── urlux.png └── wrangler.toml /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .wrangler 3 | .DS_Store 4 | .dev.vars -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GitHub Tools Powered By URL UX 2 | 3 | Here's a list of tools that use the same URL structure as github enabling you to use it by just changing your url. 4 | 5 | To navigate to your desired page more easily, go from any 'github.com/_' url to 'forgithub.com/_' and you'll get **quick links**! You can star your favorites, or after you remember them you can visit them directly. 6 | 7 | ## Editors 8 | 9 | - bolt.new - [open in bolt․new](https://bolt.new/github.com) 10 | - stackblitz.com - [open in stackblitz](https://stackblitz.com/github.com) 11 | - pr.new - [open with codeflow](https://pr.new/github.com) 12 | - github.dev - [VSCode in the browser](https://github.dev) 13 | 14 | ## LLM Context 15 | 16 | - gitingest.com - [prompt-friendly codebase](https://gitingest.com) 17 | - uithub.com - [prompt-friendly codebase](https://uithub.com) 18 | - github.gg - [chat with codebase](https://github.gg) 19 | - githuq.com - [chat with code](https://githuq.com) 20 | 21 | ## Various 22 | 23 | - log.forgithub.com - [get last commits and contributor info](https://log.forgithub.com) 24 | - gitpodcast.com - [codebase to podcast](https://gitpodcast.com) 25 | - gitdiagram.com - [codebase to diagram](https://gitdiagram.com) 26 | - githubtracker.com [track commits, issues, prs and more](https://githubtracker.com) 27 | 28 | # Contributing 29 | 30 | Please make a PR for any additional tools with the following requirements: 31 | 32 | - follow the same structure in the README 33 | - the tool must at least support the `/[owner]/[repo]/[page]/[brancg]/[...path]` url structure or a subset thereof 34 | - the tool should have a freemium business model and not require sign-in at first 35 | - bonus points if the tool exposes an `openapi.json` at its root 36 | 37 | # Why? 38 | 39 | [![](thumb.png)](https://github.com/janwilmake/forgithub/raw/refs/heads/main/demo.mov) 40 | 41 | URL UX makes tools highly accessible. Since a lot of what devs do evolves around repos, I figured that's a good one to focus on. 'Repo to Anything' is the dream! 42 | 43 | ![](urlux.png) 44 | 45 | # CHANGELOG 46 | 47 | - 2025-01-12 - created the initial version of forgithub.com 48 | - 2025-01-18 - improved layout 49 | - 2025-01-27 - added ogimage and added columns and favicons to website. fixed ci/cd -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | SEO 2 | 3 | - analyse stars to get top repos 4 | - every day generate sitemap.xml in kv for top repos 5 | - generate better og:image by making that a custom worker. ensure it works similar to the og for uithub, making it look like githubs one. 6 | 7 | Misc 8 | 9 | - every day generate a vectorization of the hostnames and link texts. use this locally with a local vector search 10 | - add a kv store that counts hostname clicks, cache that daily, and sort the items, in their category, based on this. 11 | -------------------------------------------------------------------------------- /demo.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janwilmake/forgithub/71192968480eeeb6f4e5e66719b3bd7293c44728/demo.mov -------------------------------------------------------------------------------- /globals.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.md" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | // Import README content as a markdown string and mdToJson function to parse it. 2 | import readme from "./README.md"; 3 | import { mdToJson } from "./mdToJson"; 4 | 5 | // Define an interface representing an API endpoint with URL, description, category, and starred state. 6 | interface ApiEndpoint { 7 | url: string; 8 | description: string; 9 | category?: string; 10 | isStarred?: boolean; 11 | } 12 | 13 | /** 14 | * Retrieves the user's starred items from localStorage. 15 | * Returns an object where keys are URL-encoded tool URLs and values are booleans. 16 | */ 17 | function getStarredItems(): Record { 18 | try { 19 | const starred = localStorage.getItem("starredTools"); 20 | return starred ? JSON.parse(starred) : {}; 21 | } catch (e) { 22 | console.warn("Error reading starred items:", e); 23 | return {}; 24 | } 25 | } 26 | 27 | /** 28 | * Saves the updated starred items object to localStorage. 29 | * @param items - Record mapping URL-encoded tool URLs to their starred state. 30 | */ 31 | function saveStarredItems(items: Record) { 32 | try { 33 | localStorage.setItem("starredTools", JSON.stringify(items)); 34 | } catch (e) { 35 | console.warn("Error saving starred items:", e); 36 | } 37 | } 38 | 39 | /** 40 | * Renders an HTML tool card for a given URL, description, and starred state. 41 | * @param url - The base URL of the tool. 42 | * @param description - A text description of the tool. 43 | * @param pathname - The current path name used for linking purposes. 44 | * @param isStarred - Boolean flag indicating if the tool is starred. 45 | * @returns A HTML string representing the tool card. 46 | */ 47 | function renderToolCard( 48 | url: string, 49 | description: string, 50 | pathname: string, 51 | isStarred: boolean, 52 | ): string { 53 | const domain = new URL(url).hostname; 54 | const fullUrl = `${url}${pathname.slice(1)}`; 55 | const urlKey = encodeURIComponent(url); 56 | 57 | return ` 58 |
61 | 62 | ${domain} favicon 65 | ${description} 66 | 67 | 75 |
76 | `; 77 | } 78 | 79 | /** 80 | * Parses markdown content looking for Markdown links. 81 | * Each link is converted into an ApiEndpoint object with URL and description. 82 | * @param content - The markdown content to parse. 83 | * @returns An array of ApiEndpoint objects. 84 | */ 85 | function parseUrlsFromReadme(content: string): ApiEndpoint[] { 86 | // Regex to match markdown links: [description](url) 87 | const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; 88 | const endpoints: ApiEndpoint[] = []; 89 | let match; 90 | 91 | while ((match = linkRegex.exec(content)) !== null) { 92 | const [_, text, url] = match; 93 | try { 94 | const parsedUrl = new URL(url); 95 | if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") { 96 | endpoints.push({ 97 | // Ensure URL ends with a "/" 98 | url: parsedUrl.toString().endsWith("/") 99 | ? parsedUrl.toString() 100 | : parsedUrl.toString() + "/", 101 | description: text.trim(), 102 | }); 103 | } 104 | } catch (e) { 105 | console.warn(`Skipping invalid URL: ${url}`); 106 | } 107 | } 108 | 109 | return endpoints; 110 | } 111 | 112 | /** 113 | * Renders an HTML category section provided a title and markdown content. 114 | * @param title - The category title. 115 | * @param content - The markdown content for that category. 116 | * @param pathname - The current path name to be used by tool card links. 117 | * @returns A HTML string representing the complete category block. 118 | */ 119 | function renderCategory(title: string, content: any, pathname: string): string { 120 | if (!content) { 121 | return ""; 122 | } 123 | 124 | const urls = parseUrlsFromReadme(content); 125 | const starredItems = getStarredItems(); 126 | 127 | // Map endpoints to include the isStarred property from localStorage. 128 | const urlsWithStarred = urls.map((endpoint) => ({ 129 | ...endpoint, 130 | isStarred: starredItems[encodeURIComponent(endpoint.url)] || false, 131 | })); 132 | 133 | // Sort items so that starred items are rendered first. 134 | const sortedUrls = urlsWithStarred.sort((a, b) => { 135 | if (a.isStarred === b.isStarred) return 0; 136 | return a.isStarred ? -1 : 1; 137 | }); 138 | 139 | // Generate HTML for each tool card. 140 | const items = sortedUrls 141 | .map((item) => 142 | renderToolCard(item.url, item.description, pathname, item.isStarred), 143 | ) 144 | .join("\n"); 145 | 146 | return ` 147 |
148 |

${title}

149 |
150 | ${items} 151 |
152 |
153 | `; 154 | } 155 | 156 | /** 157 | * A simple tagged template literal function to compose HTML strings. 158 | * @param strings - Template literal string segments. 159 | * @param values - Values to be interpolated. 160 | * @returns Combined HTML string. 161 | */ 162 | export const html = (strings: TemplateStringsArray, ...values: any[]) => { 163 | return strings.reduce( 164 | (result, str, i) => result + str + (values[i] || ""), 165 | "", 166 | ); 167 | }; 168 | 169 | /** 170 | * Main export with fetch handler serving an HTML page. 171 | */ 172 | export default { 173 | async fetch(request: Request): Promise { 174 | // Parse the request URL and extract pathname and possible owner/repo segments. 175 | const url = new URL(request.url); 176 | const pathname = url.pathname; 177 | const [owner, repo] = pathname.slice(1).split("/"); 178 | const name = owner && repo ? `${owner}/${repo}` : "GitHub"; 179 | const title = `Tools For ${name}`; 180 | // Generate an Open Graph image URL based on the current pathname. 181 | const ogImageUrl = `https://quickog.com/screenshot/forgithub.com${pathname}`; 182 | 183 | // Parse the README markdown into sections. 184 | const sections = mdToJson(readme); 185 | // Assumes the first header in the README holds the tool categories. 186 | const firstH1 = Object.keys(sections)[1]; 187 | // Render each category section into HTML. 188 | const categoriesHtml = Object.entries(sections[firstH1]) 189 | .filter(([key]) => key !== "_content") 190 | .map(([category, content]) => 191 | renderCategory(category, (content as any)._content, pathname), 192 | ) 193 | .join("\n"); 194 | 195 | // Compose the complete HTML page. 196 | const htmlString = html` 197 | 198 | 199 | 200 | 204 | ${title} 205 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 219 | 220 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 236 | 237 | 238 | 239 | 243 | 299 | 372 | 373 | 374 |
375 |
376 |

${title}

377 | 395 |
396 | 397 |
398 |

399 | Replace 'github.com' with 'forgithub.com' to find highly 400 | accessible github tools 401 |

402 | 403 |

404 | Current path: ${pathname} 405 | ${owner && repo 406 | ? ` 407 | 409 | (View on GitHub) 410 | 411 | ` 412 | : ""} 413 |

414 |
415 | 416 | 417 |
${categoriesHtml}
418 |
419 | 420 | `; 421 | 422 | // Return the generated HTML page as a Response. 423 | return new Response(htmlString, { 424 | headers: { 425 | "content-type": "text/html;charset=UTF-8", 426 | }, 427 | }); 428 | }, 429 | }; -------------------------------------------------------------------------------- /mdToJson.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | 3 | // Types 4 | interface Section { 5 | _content: string; 6 | [title: string]: Section | string; 7 | } 8 | 9 | interface Token { 10 | type: string; 11 | depth?: number; 12 | text?: string; 13 | raw?: string; 14 | } 15 | 16 | interface ParserState { 17 | currentSection: Section; 18 | sectionStack: Section[]; 19 | currentDepth: number; 20 | content: string[]; 21 | } 22 | 23 | function createInitialState(): ParserState { 24 | return { 25 | currentSection: { _content: "" }, 26 | sectionStack: [{ _content: "" }], 27 | currentDepth: 0, 28 | content: [], 29 | }; 30 | } 31 | 32 | function handleHeader(state: ParserState, token: Token): ParserState { 33 | const depth = token.depth || 0; 34 | const title = token.text || ""; 35 | 36 | let newStack = [...state.sectionStack]; 37 | if (depth <= state.currentDepth) { 38 | const levelsUp = state.currentDepth - depth + 1; 39 | newStack = newStack.slice(0, -levelsUp); 40 | } 41 | 42 | const newSection: Section = { _content: "" }; 43 | const currentParent = newStack[newStack.length - 1]; 44 | currentParent[title] = newSection; 45 | 46 | return { 47 | ...state, 48 | sectionStack: [...newStack, newSection], 49 | currentDepth: depth, 50 | content: [], 51 | }; 52 | } 53 | function normalizeContent(raw: string, type: string = "text"): string { 54 | // First convert all line endings to \n 55 | let content = raw.replace(/\r\n/g, "\n"); 56 | 57 | // For code blocks, only trim trailing/leading blank lines but preserve indentation 58 | if (type === "code") { 59 | return content.replace(/^\n+|\n+$/g, ""); 60 | } 61 | 62 | // Remove trailing/leading whitespace from the whole content 63 | content = content.trim(); 64 | 65 | // Handle list items differently from paragraphs 66 | if (content.includes("\n- ")) { 67 | // For list items, keep the exact format 68 | return content; 69 | } 70 | 71 | // For paragraphs, ensure exactly one blank line between them 72 | return content 73 | .split(/\n+/) 74 | .map((p) => p.trim()) 75 | .join("\n\n"); 76 | } 77 | function handleContent(state: ParserState, token: Token): ParserState { 78 | if (!token.raw) return state; 79 | 80 | const normalized = normalizeContent(token.raw, token.type); 81 | if (!normalized) return state; 82 | 83 | const newStack = [...state.sectionStack]; 84 | const currentSection = newStack[newStack.length - 1]; 85 | 86 | if (state.content.length === 0) { 87 | currentSection._content = normalized; 88 | } else { 89 | // If there's already content, we need to decide how to join it 90 | const existingContent = currentSection._content; 91 | if (normalized.startsWith("- ") || token.type === "code") { 92 | // For lists and code blocks, preserve the exact format 93 | currentSection._content = existingContent + "\n" + normalized; 94 | } else { 95 | // For paragraphs, ensure double newlines 96 | currentSection._content = existingContent + "\n\n" + normalized; 97 | } 98 | } 99 | 100 | return { 101 | ...state, 102 | content: [...state.content, normalized], 103 | sectionStack: newStack, 104 | }; 105 | } 106 | 107 | export function mdToJson(markdown: string): Section { 108 | const normalizedMarkdown = markdown.replace(/\r\n/g, "\n"); 109 | const tokens = marked.lexer(normalizedMarkdown); 110 | 111 | const finalState = tokens.reduce((state: ParserState, token: Token) => { 112 | if (token.type === "heading") { 113 | return handleHeader(state, token); 114 | } else { 115 | return handleContent(state, token); 116 | } 117 | }, createInitialState()); 118 | 119 | return finalState.sectionStack[0]; 120 | } 121 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "forgithub", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "marked": "^15.0.6" 9 | } 10 | }, 11 | "node_modules/marked": { 12 | "version": "15.0.6", 13 | "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.6.tgz", 14 | "integrity": "sha512-Y07CUOE+HQXbVDCGl3LXggqJDbXDP2pArc2C1N1RRMN0ONiShoSsIInMd5Gsxupe7fKLpgimTV+HOJ9r7bA+pg==", 15 | "license": "MIT", 16 | "bin": { 17 | "marked": "bin/marked.js" 18 | }, 19 | "engines": { 20 | "node": ">= 18" 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "marked": "^15.0.6" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /thumb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janwilmake/forgithub/71192968480eeeb6f4e5e66719b3bd7293c44728/thumb.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts"], 3 | "compilerOptions": { 4 | "resolveJsonModule": true, 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "lib": ["es2022", "DOM"], 8 | "strict": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "declaration": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /urlux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/janwilmake/forgithub/71192968480eeeb6f4e5e66719b3bd7293c44728/urlux.png -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | #:schema node_modules/wrangler/config-schema.json 2 | name = "forgithub" 3 | main = "main.ts" 4 | compatibility_date = "2025-01-13" 5 | 6 | routes = [ 7 | { pattern = "forgithub.com", custom_domain = true }, 8 | { pattern = "www.forgithub.com", custom_domain = true } 9 | ] 10 | 11 | rules = [ 12 | { type = "Text", globs = ["**/*.md"], fallthrough = true } 13 | ] --------------------------------------------------------------------------------