diff --git a/convex/repoTags.ts b/convex/repoTags.ts index a8c12b5..5ef3726 100644 --- a/convex/repoTags.ts +++ b/convex/repoTags.ts @@ -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 = { - 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, + } }, }) diff --git a/src/lib/readmeAssetUrl.ts b/src/lib/readmeAssetUrl.ts new file mode 100644 index 0000000..e7786d8 --- /dev/null +++ b/src/lib/readmeAssetUrl.ts @@ -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 + } +} diff --git a/src/pages/RepoPage.tsx b/src/pages/RepoPage.tsx index 3a41535..2a98a79 100644 --- a/src/pages/RepoPage.tsx +++ b/src/pages/RepoPage.tsx @@ -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(null) + const [readmeDownloadUrl, setReadmeDownloadUrl] = useState(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) => ( + + ), + a: ({ href, children, ...props }: AnchorHTMLAttributes) => { + const resolved = resolveReadmeRelativeUrl(href, readmeDownloadUrl) ?? href + if (resolved?.startsWith("/")) { + return ( + + {children} + + ) + } + return ( + + {children} + + ) + }, + }), + [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 ? (

Loading…

) : ( - + {readmeMd || "*No README.*"} )} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo new file mode 100644 index 0000000..35f5dd3 --- /dev/null +++ b/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file