Add README fetching and decoding functionality in RepoPage. Enhanced error handling and improved rendering of README content with support for relative URLs. Updated API response handling to include download URL.

This commit is contained in:
Ben Allfree
2026-04-10 00:59:43 -07:00
parent dc8cbb48ca
commit 8b34ad2d5b
4 changed files with 99 additions and 12 deletions
+22 -4
View File
@@ -5,6 +5,15 @@ import { sortTagEntries, type TagEntry } from "./lib/tagSemver"
const TAG_TTL_MS = 120_000
function decodeBase64Utf8(b64: string): string {
const normalized = b64.replace(/\s/g, "")
if (normalized === "") return ""
const binary = atob(normalized)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return new TextDecoder("utf-8").decode(bytes)
}
const tagEntryValidator = v.object({
name: v.string(),
sha: v.string(),
@@ -115,15 +124,24 @@ export const fetchReadme = action({
handler: async (_ctx, args) => {
const token = process.env.GITHUB_TOKEN
const headers: Record<string, string> = {
Accept: "application/vnd.github.raw+json",
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
if (token) headers.Authorization = `Bearer ${token}`
const enc = encodeURIComponent(args.ref)
const res = await fetch(`https://api.github.com/repos/${args.owner}/${args.repo}/readme?ref=${enc}`, { headers })
if (res.status === 404) return { markdown: "" as string, missing: true as const }
if (res.status === 404) return { markdown: "" as string, readmeDownloadUrl: null as null, missing: true as const }
if (!res.ok) throw new Error(`readme: ${res.status} ${await res.text()}`)
const markdown = await res.text()
return { markdown, missing: false as const }
const json = (await res.json()) as {
content?: string
download_url?: string | null
}
const b64 = json.content ?? ""
const markdown = decodeBase64Utf8(b64)
return {
markdown,
readmeDownloadUrl: (json.download_url ?? null) as string | null,
missing: false as const,
}
},
})
+21
View File
@@ -0,0 +1,21 @@
/** Resolve README-relative URLs the same way GitHub does, using the README file's raw download URL as base. */
export function resolveReadmeRelativeUrl(
hrefOrSrc: string | undefined,
readmeDownloadUrl: string | null
): string | undefined {
if (!hrefOrSrc || !readmeDownloadUrl) return hrefOrSrc
const trimmed = hrefOrSrc.trim()
if (!trimmed) return hrefOrSrc
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return hrefOrSrc
if (trimmed.startsWith("//")) return hrefOrSrc
if (trimmed.startsWith("#")) return hrefOrSrc
try {
const download = new URL(readmeDownloadUrl)
const slash = download.pathname.lastIndexOf("/")
const dirPath = slash >= 0 ? download.pathname.slice(0, slash + 1) : "/"
const base = `${download.origin}${dirPath}`
return new URL(trimmed, base).href
} catch {
return hrefOrSrc
}
}
+55 -8
View File
@@ -3,7 +3,15 @@ import { api } from "@/convex/_generated/api"
import { sortTagNames } from "@/convex/lib/tagSemver"
import { useAction, useMutation, useQuery } from "convex/react"
import { Github, Link2, RefreshCw } from "lucide-react"
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
} from "react"
import ReactMarkdown from "react-markdown"
import { Link, useNavigate, useParams } from "react-router-dom"
import rehypeRaw from "rehype-raw"
@@ -15,6 +23,7 @@ import EspFlasher from "../components/EspFlasher"
import { normalizeBuildKey } from "../lib/buildKey"
import { buildFailurePresentation } from "../lib/formatBuildErrorSummary"
import { homepageHref, homepageLabel } from "../lib/githubHomepage"
import { resolveReadmeRelativeUrl } from "../lib/readmeAssetUrl"
import { buildTreeSplatPath, parseTreeSplat } from "../lib/repoTreeUrl"
const MESH_FORGE_ACTIONS_REPO = "MeshEnvy/mesh-forge"
@@ -92,22 +101,58 @@ export default function RepoPage() {
)
const [readmeMd, setReadmeMd] = useState<string | null>(null)
const [readmeDownloadUrl, setReadmeDownloadUrl] = useState<string | null>(null)
useEffect(() => {
if (!effectiveRef) return
if (!effectiveRef) {
setReadmeMd(null)
setReadmeDownloadUrl(null)
return
}
let cancelled = false
setReadmeMd(null)
setReadmeDownloadUrl(null)
void fetchReadme({ owner, repo, ref: effectiveRef })
.then(r => {
if (!cancelled) setReadmeMd(r.markdown)
if (!cancelled) {
setReadmeMd(r.markdown)
setReadmeDownloadUrl(r.readmeDownloadUrl ?? null)
}
})
.catch(() => {
if (!cancelled) setReadmeMd("*(README could not be loaded.)*")
if (!cancelled) {
setReadmeMd("*(README could not be loaded.)*")
setReadmeDownloadUrl(null)
}
})
return () => {
cancelled = true
}
}, [owner, repo, effectiveRef, fetchReadme])
const readmeMarkdownComponents = useMemo(
() => ({
img: ({ src, ...props }: ImgHTMLAttributes<HTMLImageElement>) => (
<img {...props} src={resolveReadmeRelativeUrl(src, readmeDownloadUrl) ?? src} />
),
a: ({ href, children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
const resolved = resolveReadmeRelativeUrl(href, readmeDownloadUrl) ?? href
if (resolved?.startsWith("/")) {
return (
<Link to={resolved} {...props}>
{children}
</Link>
)
}
return (
<a href={resolved} target="_blank" rel="noreferrer" {...props}>
{children}
</a>
)
},
}),
[readmeDownloadUrl]
)
const envNames = scan?.scanStatus === "complete" ? (scan.envNames ?? []) : []
const resolvedTargetEnv =
hasRef && targetFromUrl && envNames.length > 0 && envNames.includes(targetFromUrl) ? targetFromUrl : ""
@@ -298,9 +343,7 @@ export default function RepoPage() {
const flashButtonLabel = buildInProgress ? "Building…" : "Flash"
const showCiCard =
build &&
(build.status !== "failed" || witnessedCiFailure)
const showCiCard = build && (build.status !== "failed" || witnessedCiFailure)
const targetPlaceholder = !hasRef
? "--target--"
@@ -550,7 +593,11 @@ export default function RepoPage() {
{readmeMd === null ? (
<p className="text-slate-500 not-prose">Loading</p>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw, rehypeSanitize]}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
components={readmeMarkdownComponents}
>
{readmeMd || "*No README.*"}
</ReactMarkdown>
)}
+1
View File
@@ -0,0 +1 @@
{"root":["./types.ts","./vite.config.ts","./components/DiscordButton.tsx","./components/Footer.tsx","./components/Navbar.tsx","./components/RedditButton.tsx","./components/ui/button.tsx","./components/ui/checkbox.tsx","./components/ui/input.tsx","./components/ui/popover.tsx","./components/ui/sonner.tsx","./components/ui/switch.tsx","./constants/targets.ts","./constants/versions.ts","./convex/actions.ts","./convex/admin.ts","./convex/auth.config.ts","./convex/auth.ts","./convex/deviceReports.ts","./convex/helpers.ts","./convex/http.ts","./convex/repoBuildDownloads.ts","./convex/repoBuilds.ts","./convex/repoScans.ts","./convex/repoTags.ts","./convex/schema.ts","./convex/_generated/api.d.ts","./convex/_generated/dataModel.d.ts","./convex/_generated/server.d.ts","./convex/lib/platformioScan.ts","./convex/lib/r2.ts","./convex/lib/tagSemver.ts","./lib/utils.ts","./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/ComboboxField.tsx","./src/components/EspFlasher.tsx","./src/components/MarkdownDoc.tsx","./src/lib/buildKey.ts","./src/lib/espFlashLayout.ts","./src/lib/espFlashRun.ts","./src/lib/formatBuildErrorSummary.ts","./src/lib/githubHomepage.ts","./src/lib/parseGithubUrl.ts","./src/lib/readmeAssetUrl.ts","./src/lib/repoTreeUrl.ts","./src/lib/untarGz.ts","./src/pages/AdminPage.tsx","./src/pages/HomePage.tsx","./src/pages/LegalLicensePage.tsx","./src/pages/LegalPrivacyPage.tsx","./src/pages/LegalTermsPage.tsx","./src/pages/NotFoundPage.tsx","./src/pages/RepoPage.tsx","./vendor/meshscript/examples/types.ts","./vendor/meshscript/examples/ubbs.ts","./vendor/meshscript/src/api.ts","./vendor/meshtastic-firmware/meshtestic/src/discover.ts","./vendor/meshtastic-firmware/meshtestic/src/node.ts","./vendor/meshtastic-firmware/meshtestic/src/setup.ts","./vendor/meshtastic-firmware/meshtestic/src/util.ts","./vendor/meshtastic-firmware/meshtestic/test/config.spec.ts","./vendor/meshtastic-firmware/protobufs/packages/ts/mod.ts"],"errors":true,"version":"5.9.3"}