diff --git a/generate-index.go b/generate-index.go index 0901df7..5492b64 100644 --- a/generate-index.go +++ b/generate-index.go @@ -59,6 +59,11 @@ func main() { os.Exit(1) } + if err := generate404(); err != nil { + fmt.Fprintf(os.Stderr, "Error generating 404.html: %v\n", err) + os.Exit(1) + } + // Copy CNAME and static root files to site directory if cname, err := os.ReadFile("CNAME"); err == nil { os.WriteFile("site/CNAME", cname, 0644) @@ -71,7 +76,14 @@ func main() { for _, cat := range cfg.Categories { totalRepos += len(cat.Repos) } - fmt.Printf("Generated index.html and sitemap.xml (%d repos)\n", totalRepos) + fmt.Printf("Generated site (%d repos)\n", totalRepos) +} + +func generate404() error { + return os.WriteFile("site/404.html", []byte(` +Redirecting… +

Redirecting to homepage

+`), 0644) } func generateSitemap(cfg Config) error { @@ -196,18 +208,6 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b .site-nav { display: flex; gap: 16px; align-items: center; } .site-nav a { color: var(--text-muted); font-size: 14px; font-weight: 500; white-space: nowrap; } .site-nav a:hover { color: var(--text); text-decoration: none; } -.request-btn { - color: var(--accent-light) !important; - border: 1px solid var(--accent); - border-radius: var(--radius); - padding: 6px 12px; - transition: background 0.2s, color 0.2s; -} -.request-btn:hover { - background: var(--accent); - color: #fff !important; - text-decoration: none !important; -} .hero { padding: 64px 0 48px; text-align: center; @@ -395,14 +395,21 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b pointer-events: auto; } .submit-btn.active:hover { background: var(--accent-light); } -.submit-preview { - margin-top: 8px; +.submit-btn.loading { + opacity: 0.6; + pointer-events: none; +} +.submit-feedback { + margin-top: 10px; font-size: 13px; - color: var(--green); font-family: var(--mono); display: none; } -.submit-preview.visible { display: block; } +.submit-feedback.visible { display: block; } +.submit-feedback.preview { color: var(--text-muted); } +.submit-feedback.success { color: var(--green); } +.submit-feedback.success a { color: var(--green); text-decoration: underline; } +.submit-feedback.error { color: var(--red); } @media (max-width: 768px) { .container { padding: 0 16px; } .hero { padding: 40px 0 32px; } @@ -432,7 +439,6 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b Website GitHub X - + Request a Repo @@ -462,9 +468,9 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b
Don't see your repo? Paste a URL to generate arch docs:
- +
-
+
@@ -510,10 +516,10 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b var noResults = document.getElementById('no-results'); var submitInput = document.getElementById('submit-url'); var submitBtn = document.getElementById('submit-btn'); - var submitPreview = document.getElementById('submit-preview'); + var feedback = document.getElementById('submit-feedback'); var noResultsRequest = document.getElementById('no-results-request'); - var issueBase = 'https://github.com/supermodeltools/supermodeltools.github.io/issues/new?template=request-repo.yml'; + var API_URL = '/api/request'; // --- Search --- searchInput.addEventListener('input', function() { @@ -546,36 +552,65 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b return null; } + function showFeedback(msg, type) { + feedback.className = 'submit-feedback visible ' + type; + feedback.innerHTML = msg; + } + submitInput.addEventListener('input', function() { var parsed = parseRepo(this.value); if (parsed) { var name = parsed.split('/')[1]; - submitPreview.textContent = '\u2192 Docs will be at repos.supermodeltools.com/' + name + '/'; - submitPreview.classList.add('visible'); + showFeedback('\u2192 repos.supermodeltools.com/' + name + '/', 'preview'); submitBtn.classList.add('active'); } else { - submitPreview.classList.remove('visible'); + feedback.className = 'submit-feedback'; submitBtn.classList.remove('active'); } }); - function submitRequest() { + async function submitRequest() { var parsed = parseRepo(submitInput.value); if (!parsed) return; + var repoUrl = 'https://github.com/' + parsed; var name = parsed.split('/')[1]; - var url = issueBase - + '&repo_url=' + encodeURIComponent(repoUrl) - + '&title=' + encodeURIComponent('[Repo Request] ' + name); - window.open(url, '_blank'); + + // Loading state + submitBtn.classList.add('loading'); + submitBtn.textContent = 'Generating...'; + showFeedback('Setting up ' + name + '...', 'preview'); + + try { + var resp = await fetch(API_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: repoUrl }), + }); + var data = await resp.json(); + + if (!resp.ok || !data.success) { + showFeedback(data.error || 'Something went wrong. Please try again.', 'error'); + submitBtn.classList.remove('loading'); + submitBtn.textContent = 'Generate'; + return; + } + + // Redirect to the skeleton loading page — served by the worker + window.location.href = data.generating_url; + } catch (e) { + showFeedback('Network error. Please try again.', 'error'); + submitBtn.classList.remove('loading'); + submitBtn.textContent = 'Generate'; + } } submitBtn.addEventListener('click', submitRequest); submitInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter') submitRequest(); + if (e.key === 'Enter' && submitBtn.classList.contains('active')) submitRequest(); }); - // "No results" request link: pre-fill with search query as a guess + // "No results" link: scroll up and focus the submit input noResultsRequest.addEventListener('click', function() { var q = searchInput.value.trim(); submitInput.value = q; @@ -588,3 +623,4 @@ a:focus-visible { outline: 2px solid var(--accent-light); outline-offset: 2px; b ` + diff --git a/worker/index.js b/worker/index.js new file mode 100644 index 0000000..030b807 --- /dev/null +++ b/worker/index.js @@ -0,0 +1,428 @@ +/** + * Cloudflare Worker — repo request proxy + loading page + * + * POST /api/request — creates GitHub issue, returns docs URL + * GET /generating/* — serves skeleton loading page + * + * Environment secrets (set via wrangler secret put): + * GITHUB_TOKEN — fine-grained PAT with issues:write on supermodeltools.github.io + */ + +const CORS_HEADERS = { + 'Access-Control-Allow-Origin': 'https://repos.supermodeltools.com', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +const REPO_RE = /github\.com\/([a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+)/; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + + // CORS preflight + if (request.method === 'OPTIONS') { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + // POST /api/request — create issue + if (url.pathname === '/api/request' && request.method === 'POST') { + return handleRequest(request, env); + } + + // GET /generating/{name} — serve loading page + const genMatch = url.pathname.match(/^\/generating\/([a-zA-Z0-9._-]+)/); + if (genMatch && request.method === 'GET') { + return serveSkeleton(genMatch[1]); + } + + return new Response('Not found', { status: 404 }); + }, +}; + +async function handleRequest(request, env) { + let body; + try { + body = await request.json(); + } catch { + return jsonResponse({ error: 'Invalid JSON' }, 400); + } + + const rawUrl = (body.url || '').trim().replace(/\/+$/, '').replace(/\.git$/, ''); + const match = rawUrl.match(REPO_RE); + if (!match) { + return jsonResponse({ error: 'Invalid GitHub repository URL' }, 400); + } + + const upstream = match[1]; + const name = upstream.split('/')[1]; + + const ghResponse = await fetch( + 'https://api.github.com/repos/supermodeltools/supermodeltools.github.io/issues', + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.GITHUB_TOKEN}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'supermodel-request-bot', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: `[Repo Request] ${name}`, + body: `### Repository URL\n\nhttps://github.com/${upstream}`, + labels: ['repo-request'], + }), + } + ); + + if (!ghResponse.ok) { + console.error('GitHub API error:', ghResponse.status, await ghResponse.text()); + return jsonResponse({ error: 'Failed to submit. Please try again.' }, 502); + } + + return jsonResponse({ + success: true, + name, + upstream, + generating_url: `/generating/${name}`, + docs_url: `https://repos.supermodeltools.com/${name}/`, + }); +} + +function serveSkeleton(name) { + const html = SKELETON_HTML.replaceAll('{{NAME}}', escapeHtml(name)); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }, + }); +} + +function jsonResponse(data, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json', ...CORS_HEADERS }, + }); +} + +function escapeHtml(s) { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// --------------------------------------------------------------------------- +// Skeleton page — mirrors the real arch-docs layout with shimmer placeholders +// --------------------------------------------------------------------------- +const SKELETON_HTML = ` + + + + + {{NAME}} — Architecture Documentation + + + + + + + + + + +
+
+ + +
+

{{NAME}}

+
+ View on GitHub + Star + Fork +
+

Architecture documentation generated from code analysis. Explore every file, function, class, and domain.

+
+
Total Entities
+
Node Types
+
Languages
+
Domains
+
Subdomains
+
Top Directories
+
+
+ + +
+

Architecture Overview

+
+
+ + +
+

Codebase Composition

+
+
+ + +
+

Node Types

+
+
+
+
+
+
+
+
+
+ +
+

Domains

+
+
+
+
+
+
+
+ +
+

Languages

+
+
+
+
+
+
+
+
+ + + + +
+
+
Generating docs — analyzing codebase…
+
+ + + +`; diff --git a/worker/wrangler.toml b/worker/wrangler.toml new file mode 100644 index 0000000..e3bac6d --- /dev/null +++ b/worker/wrangler.toml @@ -0,0 +1,10 @@ +name = "repo-request" +main = "index.js" +compatibility_date = "2024-01-01" + +# Routes: intercept /api/* and /generating/* on the custom domain +# Requires the domain to be proxied through Cloudflare +# routes = [ +# { pattern = "repos.supermodeltools.com/api/*", zone_name = "supermodeltools.com" }, +# { pattern = "repos.supermodeltools.com/generating/*", zone_name = "supermodeltools.com" } +# ]