Files
logos-web/apps/web/lib/rfps-github.ts
T
Jinho Jang 96be50d8d0 refactor: make @repo/content the single source for home, get-started & movement page copy (#62)
* docs: add page-copy single-source spec and implementation plan

* feat(content): add typed home section schemas and migrate home copy into content

Add 7 bespoke home* section schemas (+ techStack ctas) to the page section
union and populate content/pages/en/home.json with home copy moved verbatim
from messages.

* refactor(web): source home page copy from @repo/content; drop home namespace from messages

Feed every home section from content via findSection/data props (replacing
next-intl getTranslations), remove the home namespace from messages/en.json,
and add a regression guard plus content-based tests. Render output unchanged.

* docs: add get-started/movement page-copy dedup spec and plan

* feat(content): add get-started & movement section schemas and content docs

Move get-started/movement page copy verbatim into content/pages so it is
single-sourced in @repo/content.

* refactor(web): source get-started/movement copy from content; drop those namespaces from messages

Feed both pages from content via findSection/data props (replacing next-intl),
switch them to content-based metadata, delete pages.getStarted/movement from
messages, and add content-based tests + ownership guard. Render unchanged.

* docs: add navbar-pages CMS copy spec and plan

* feat(content): add navbar page section schemas + Pages docs (book, brand-kit, research, node-programme, lambda-prize, manifesto, media, podcast, broadcast, tech-stack explorer)

* refactor(web): source navbar pages copy from content; drop those namespaces from messages

Migrate book, brand-kit, research (incl. rich-text via a tagged-text renderer),
node-programme, lambda-prize, manifesto, media, podcast, logos-broadcast-network,
and the tech-stack explorer to read copy from content via findSection/data props
and content-based metadata; remove the migrated pages.* namespaces from messages;
update guards + contracts. Render output unchanged.

* feat(web): source activist/coalition pages copy from content (CMS title/description)

Migrate activistBuilder, activistLeaderSteward, and coalitionPartner page
metadata from next-intl messages.pages.* into @repo/content PageCopy JSON,
following the established bookCopy pattern.

- Add activistBuilderCopySectionSchema, activistLeaderStewardCopySectionSchema,
  coalitionPartnerCopySectionSchema to packages/content/src/schemas/pages.ts
  and wire them into the pageSectionSchema discriminated union
- Create content/pages/en/activist-builder.json,
  activist-leader-steward.json, coalition-partner.json with verbatim
  title/description/heading values from the old messages namespace
- Switch all three route pages from createTranslatedPageMetadata to
  createPageMetadata(ROUTE), removing the NAMESPACE constant
- Remove activistBuilder, activistLeaderSteward, coalitionPartner from
  apps/web/messages/en.json pages object
- Add schema parse tests, renderToStaticMarkup page tests, messages ownership
  guards, and content-route contract entries for all three pages

* chore(web): remove dead faq (pages.faq + unused FaqSection components)

* fix: remove stale faq links

* fix: pass payload env through turbo build

* fix: preserve lambda prize support labels

* fix: preserve live copy after master rebase

* fix: address page copy review feedback

* refactor: move design guide copy to content

* feat: add schemas for home and page copy sections

- Introduced home section schemas including homeSocialProof, homeChoosePath, homeDecide, homeStartBuilding, homeAbout, homeUseCases, and homeBuilderPortal.
- Added page copy section schemas such as getStartedCopy, movementCopy, bookCopy, designGuideCopy, activistBuilderCopy, activistLeaderStewardCopy, coalitionPartnerCopy, researchCopy, nodeProgrammeCopy, lambdaPrizeCopy, mediaCopy, podcastCopy, broadcastCopy, and manifestoCopy.
- Created shared schema for section keys to maintain consistency across sections.

* chore(web): remove dead pages.faq from messages

* fix: resolve page copy migration conflicts

* fix: source connect form page copy from content

* fix: remove duplicated afform page copy exports

* fix: stop generating afform page copy exports

* fix(cms): require only server actions key

* fix(cms): load env files before build validation

* fix(cms): make env loader available during build

* fix(cms): load next env via require

* test(cms): isolate missing env validation

* feat: add homepage testnet highlight

* fix: restore lambda prize support links
2026-07-06 19:09:02 +09:00

374 lines
12 KiB
TypeScript

import matter from 'gray-matter'
import { cache } from 'react'
import { ROUTES } from '@/constants/routes'
import type { RfpListItem } from '@/lib/rfp-types'
/**
* Build-time RFP source. RFPs live as markdown in the public GitHub repo
* `logos-co/rfp` (one file per RFP under `RFPs/`). This module fetches and
* parses them at build time so the `/builders-hub/rfps` listing and the
* `[slug]` detail pages render from a single live source — mirroring
* build.logos.co's `pages/rfp.tsx`, but server-side instead of client-side.
*
* Fields GitHub doesn't carry (reward, image, tags) are intentionally absent;
* the listing UI hides whatever is missing.
*/
const RFP_CONTENTS_URL =
'https://api.github.com/repos/logos-co/rfp/contents/RFPs'
/** Where the detail-page "Apply" CTA points (GitHub issue template). */
export const RFP_APPLY_URL =
'https://github.com/logos-co/rfp/issues/new?template=proposal.yml'
/** The RFP repo, linked from the listing header and detail footer. */
export const RFP_REPO_URL = 'https://github.com/logos-co/rfp'
/**
* GitHub's API rejects unauthenticated requests without a User-Agent. A token
* (optional) lifts the 60 req/hr unauthenticated rate limit during CI builds.
*/
const githubHeaders = (): Record<string, string> => {
const headers: Record<string, string> = {
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'logos-web-build',
}
const token = process.env.GITHUB_TOKEN
if (token) headers.Authorization = `token ${token}`
return headers
}
export type GithubRfp = RfpListItem & {
/** RFP identifier, e.g. `RFP-001`. */
number: string
category: string
status: string
tier: string
/** Canonical GitHub `blob` URL for this RFP's markdown file. */
githubUrl: string
/** Full markdown body, rendered verbatim on the detail page. */
rawMarkdown: string
}
type GithubContentEntry = {
name: string
download_url: string | null
html_url: string | null
git_url?: string | null
}
const isContentEntry = (value: unknown): value is GithubContentEntry =>
typeof value === 'object' &&
value !== null &&
typeof (value as GithubContentEntry).name === 'string'
type GithubBlobResponse = {
content?: string
encoding?: string
}
const fetchTextWithRetry = async (
url: string,
headers: Readonly<Record<string, string>>,
attempts = 3
): Promise<string | null> => {
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
const res = await fetch(url, { headers })
if (res.ok) return await res.text()
if (attempt === attempts) return null
} catch {
if (attempt === attempts) return null
}
}
return null
}
const fetchGithubBlob = async (
url: string,
headers: Readonly<Record<string, string>>
): Promise<string | null> => {
try {
const res = await fetch(url, { headers })
if (!res.ok) return null
const body = (await res.json()) as GithubBlobResponse
if (body.encoding !== 'base64' || typeof body.content !== 'string') {
return null
}
return Buffer.from(body.content.replace(/\s/g, ''), 'base64').toString(
'utf8'
)
} catch {
return null
}
}
const rawUrlFromHtmlUrl = (htmlUrl: string | null): string | null => {
if (!htmlUrl) return null
try {
const url = new URL(htmlUrl)
if (url.hostname !== 'github.com') return null
url.hostname = 'raw.githubusercontent.com'
url.pathname = url.pathname.replace('/blob/', '/')
return url.href
} catch {
return null
}
}
const fetchRfpMarkdownEntry = async (
entry: GithubContentEntry
): Promise<string | null> => {
const headers = githubHeaders()
if (entry.download_url) {
const raw = await fetchTextWithRetry(entry.download_url, headers)
if (raw) return raw
}
const rawUrl = rawUrlFromHtmlUrl(entry.html_url)
if (rawUrl && rawUrl !== entry.download_url) {
const raw = await fetchTextWithRetry(rawUrl, headers)
if (raw) return raw
}
if (entry.git_url) {
return fetchGithubBlob(entry.git_url, headers)
}
return null
}
export const fetchRfpMarkdownEntryForTest = fetchRfpMarkdownEntry
/** `RFP-001-admin-authority-lib.md` → `admin-authority-lib`. */
const toSlug = (filename: string, fallback: string): string => {
const base = filename
.replace(/\.md$/i, '')
.replace(/^RFP-\d+[-_\s]*/i, '')
const slug = base
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return slug || fallback.toLowerCase()
}
/** Matches the URL target of a markdown inline link/image: `](target)`. */
const MARKDOWN_LINK_TARGET = /\]\(\s*(<[^>]+>|[^)\s]+)\s*\)/g
const isAbsoluteOrAnchor = (href: string): boolean =>
/^(https?:\/\/|\/|#|mailto:)/i.test(href)
/**
* Rewrites repo-relative markdown links so RFP detail pages don't 404. Upstream
* RFP files cross-reference each other with paths relative to the repo's
* `RFPs/` directory (e.g. `./RFP-008-lending-borrowing-protocol.md`), which the
* browser would otherwise resolve against the page URL and 404.
*
* - `RFP-NNN-*.md` references become the internal detail route
* (`/builders-hub/rfps/<slug>`), using the same slug the pages are built with.
* - Other repo-relative `.md` links (e.g. `../appendix/…`) have no site page, so
* they resolve to their absolute GitHub URL (anchors preserved).
* - Absolute, external, and anchor links are left untouched.
*
* `fileHtmlUrl` is the GitHub `blob` URL of the file being parsed; it anchors
* the resolution of the remaining relative links.
*/
export const rewriteRfpMarkdownLinks = (
markdown: string,
fileHtmlUrl: string
): string =>
markdown.replace(MARKDOWN_LINK_TARGET, (match, rawTarget: string) => {
const href = rawTarget.replace(/^<|>$/g, '')
if (isAbsoluteOrAnchor(href)) return match
const [path] = href.split('#')
if (!/\.md$/i.test(path)) return match
const filename = path.split('/').pop() ?? ''
if (/^RFP-\d+/i.test(filename)) {
return `](${ROUTES.rfps}/${toSlug(filename, filename)})`
}
try {
return `](${new URL(href, fileHtmlUrl).href})`
} catch {
return match
}
})
const firstMatch = (raw: string, patterns: RegExp[]): string | undefined => {
for (const pattern of patterns) {
const value = raw.match(pattern)?.[1]?.replace(/[`*]/g, '').trim()
if (value) return value
}
return undefined
}
const extractSummary = (raw: string): string => {
const overview = raw.match(/##\s*(?:🧭\s*)?Overview\s*\n+([\s\S]*?)(?=\n##)/i)
if (overview) {
return overview[1]
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.join(' ')
.slice(0, 200)
}
const lines = raw
.split('\n')
.filter(
(line) =>
line.trim() &&
!line.startsWith('#') &&
!line.startsWith('|') &&
!line.startsWith('---') &&
!line.startsWith('**')
)
return lines.slice(0, 3).join(' ').slice(0, 200)
}
/** Trimmed non-empty string from an untyped frontmatter value, else undefined. */
const str = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined
/**
* Frontmatter parse that never throws. Upstream RFP files occasionally carry
* invalid YAML (e.g. an unquoted colon in `title:`), which `gray-matter` would
* throw on. Rather than dropping the whole RFP, we strip the frontmatter block
* and parse the body only, so the markdown-heuristic path (`# heading`,
* `**Field**` lines) still populates the listing — matching the documented
* predates-frontmatter fallback.
*/
const safeMatter = (
raw: string,
filename: string
): { data: Record<string, unknown>; content: string } => {
try {
return matter(raw)
} catch (error) {
console.error(`Invalid frontmatter in ${filename}, parsing body only:`, error)
const body = raw.replace(/^\uFEFF?\s*---\r?\n[\s\S]*?\r?\n---\r?\n?/, '')
return { data: {}, content: body }
}
}
/**
* Parses one RFP markdown file. RFP files carry YAML frontmatter
* (`id`, `title`, `tier`, `funding`, `status`, `category`) followed by the
* body. `gray-matter` strips the frontmatter so it never renders as text, and
* its parsed fields are preferred over markdown heuristics; the `**Field**`
* regexes remain as a fallback for any file that predates frontmatter.
*/
const parseRfpMarkdown = (
raw: string,
filename: string,
htmlUrl: string
): GithubRfp | null => {
const number = filename.match(/^(RFP-\d+)/)?.[1]
if (!number || number === 'RFP-000') return null
const { data, content } = safeMatter(raw, filename)
const headingTitle = content
.match(/^#\s+(.+)/m)?.[1]
?.replace(/^RFP-\d+[:\s\-—]+/, '')
.trim()
const title = str(data.title) ?? headingTitle ?? filename
const status =
str(data.status) ??
firstMatch(content, [/\*\*Status\*\*[:\s]*(.+)/i]) ??
'open'
const category =
str(data.category) ?? firstMatch(content, [/\*\*Category\*\*[:\s]*(.+)/i])
const tier = str(data.tier) ?? firstMatch(content, [/\*\*Tier\*\*[:\s]*(.+)/i])
return {
number,
slug: toSlug(filename, number),
title,
category: category ?? '',
summary: extractSummary(content),
status,
tier: tier ?? '',
githubUrl: htmlUrl,
rawMarkdown: rewriteRfpMarkdownLinks(content.trim(), htmlUrl),
}
}
/**
* Fetches and parses every published RFP from the GitHub repo, sorted by RFP
* number. Drafts and the `RFP-000` template are excluded. Wrapped in React
* `cache()` so repeated calls within a build pass dedupe; Next's fetch Data
* Cache dedupes the underlying network requests across passes.
*
* On failure the build does not crash — it logs and returns `[]`, matching the
* reference behaviour. A failed fetch yields an empty listing, never a broken
* build.
*/
export const fetchGithubRfps = cache(async (): Promise<GithubRfp[]> => {
let entries: unknown
try {
const res = await fetch(RFP_CONTENTS_URL, { headers: githubHeaders() })
if (!res.ok) {
console.error(
`Failed to list RFPs from GitHub: ${res.status} ${res.statusText}`
)
return []
}
entries = await res.json()
} catch (error) {
console.error('Failed to fetch RFP listing from GitHub:', error)
return []
}
if (!Array.isArray(entries)) {
console.error('GitHub RFP listing returned a non-array response')
return []
}
const mdFiles = entries
.filter(isContentEntry)
.filter(
(entry) =>
entry.name.endsWith('.md') &&
entry.name.startsWith('RFP-') &&
!entry.name.startsWith('RFP-000') &&
entry.download_url
)
const parsed = await Promise.all(
mdFiles.map(async (entry): Promise<GithubRfp | null> => {
try {
const raw = await fetchRfpMarkdownEntry(entry)
if (!raw) {
console.error(`Failed to fetch ${entry.name}`)
return null
}
return parseRfpMarkdown(raw, entry.name, entry.html_url ?? RFP_REPO_URL)
} catch (error) {
console.error(`Failed to fetch ${entry.name}:`, error)
return null
}
})
)
return parsed
.filter((rfp): rfp is GithubRfp => rfp !== null)
.filter((rfp) => !rfp.status.toLowerCase().includes('draft'))
.sort((a, b) => a.number.localeCompare(b.number))
})
/** Single-RFP lookup by slug, reusing the cached full fetch. */
export const fetchGithubRfpBySlug = async (
slug: string
): Promise<GithubRfp | null> => {
const rfps = await fetchGithubRfps()
return rfps.find((rfp) => rfp.slug === slug) ?? null
}
/** Strips the leading `# …` heading so the detail header isn't duplicated. */
export const stripLeadingHeading = (raw: string): string =>
raw.replace(/^\s*#\s+.+(\r?\n)+/, '')