Files
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

695 lines
23 KiB
TypeScript

/**
* Smoke validator for the content fixtures shipped with this repo.
*
* Walks the active locales, calls each loader against the on-disk fixtures,
* and reports pass/fail counts. Exits non-zero on the first failure so CI
* can gate merges on it.
*
* Phase 1 covers the loaders that have fixtures land first; the remaining
* loaders are added as the corresponding fixtures appear.
*/
import type { Language } from '../src/schemas/common'
import {
getAllIdeas,
getAllRfps,
getBuilderHubListingSettings,
getBuilderHubSettings,
getBuilderResources,
resolveBuilderHubHomeIdeas,
resolveBuilderHubHomeRfps,
} from '../src/loaders/builders-hub'
import {
formatEventDateForSurface,
getCircleEvents,
getCircleEventsGroupedByDate,
getCircleInitiatives,
getCircleResources,
getCircles,
getCirclesSettings,
} from '../src/loaders/circles'
import { z } from 'zod'
import {
__resetCustomSectionRegistryForTests,
getCustomSectionSchema,
registerCustomSection,
} from '../src/schemas/custom-sections'
import { customSectionSchema } from '../src/schemas/pages'
import { getPageCopy, parseCustomSectionPayload } from '../src/loaders/pages'
import {
getFooter,
getNavigationContent,
getSiteSettings,
} from '../src/loaders/site'
import { getActiveLocales } from '../src/locales/registry'
type Check = {
name: string
run: () => Promise<unknown>
}
const buildSiteChecks = (locale: Language): Check[] => [
{ name: `site.settings (${locale})`, run: () => getSiteSettings(locale) },
{ name: `site.footer (${locale})`, run: () => getFooter(locale) },
{
name: `site.navigation (${locale})`,
run: () => getNavigationContent(locale),
},
]
const buildBuilderHubChecks = (locale: Language): Check[] => [
{
name: `builders-hub.settings (${locale})`,
run: () => getBuilderHubSettings(locale),
},
{
name: `builders-hub.resources (${locale}) → at least one published item`,
run: async () => {
const items = await getBuilderResources({ locale, status: 'published' })
if (items.length === 0) {
throw new Error('expected at least one published builder resource')
}
return items
},
},
{
name: `builders-hub.listings.ideas (${locale})`,
run: () => getBuilderHubListingSettings({ page: 'ideas', locale }),
},
{
name: `builders-hub.listings.rfps (${locale})`,
run: () => getBuilderHubListingSettings({ page: 'rfps', locale }),
},
{
name: `builders-hub.rfps (${locale}) → at least one published RFP`,
run: async () => {
const rfps = await getAllRfps({ locale, status: 'published' })
if (rfps.length === 0) {
throw new Error('no published RFPs found')
}
return rfps
},
},
{
name: `builders-hub.rfps (${locale}) → canonical sort (order asc, then publishedAt desc)`,
run: async () => {
const rfps = await getAllRfps({ locale, status: 'published' })
for (let i = 1; i < rfps.length; i++) {
const prevOrder = rfps[i - 1].order ?? Number.MAX_SAFE_INTEGER
const curOrder = rfps[i].order ?? Number.MAX_SAFE_INTEGER
if (prevOrder > curOrder) {
throw new Error(
`out-of-order: "${rfps[i - 1].slug}" (order=${prevOrder}) before "${rfps[i].slug}" (order=${curOrder})`
)
}
}
return rfps
},
},
{
name: `builders-hub.home rfps (${locale}) → resolves with terminator card`,
run: async () => {
const result = await resolveBuilderHubHomeRfps(locale)
if (result.rfps.length === 0) {
throw new Error('home grid resolved to zero RFPs')
}
if (!result.terminator) {
throw new Error('terminator card not resolved')
}
if (
result.terminator.kind === 'see-all-ideas' &&
result.terminator.thumbnailIdeas.length === 0
) {
throw new Error(
'see-all-ideas terminator resolved with zero thumbnail ideas'
)
}
return result
},
},
{
name: `builders-hub.ideas (${locale}) → at least one published Idea + reverse refs computed`,
run: async () => {
const ideas = await getAllIdeas({ locale, status: 'published' })
if (ideas.length === 0) {
throw new Error('no published Ideas found')
}
// Every Idea must carry a relatedRfpSlugs array (possibly empty).
for (const idea of ideas) {
if (!Array.isArray(idea.relatedRfpSlugs)) {
throw new Error(`Idea "${idea.slug}" missing relatedRfpSlugs array`)
}
}
// quadratic-voting is referenced by secure-decentralised-frontends.relatedIdeas
// → its reverse ref must contain that RFP slug.
const qv = ideas.find((i) => i.slug === 'quadratic-voting')
if (
qv &&
!qv.relatedRfpSlugs.includes('secure-decentralised-frontends')
) {
throw new Error(
`quadratic-voting.relatedRfpSlugs missing "secure-decentralised-frontends": got [${qv.relatedRfpSlugs.join(', ')}]`
)
}
return ideas
},
},
{
name: `builders-hub.home ideas (${locale}) → resolves`,
run: async () => {
const result = await resolveBuilderHubHomeIdeas(locale)
if (result.ideas.length === 0) {
throw new Error('home table resolved to zero Ideas')
}
return result
},
},
]
const buildCirclesChecks = (locale: Language): Check[] => [
{
name: `circles.settings (${locale})`,
run: () => getCirclesSettings(locale),
},
{
name: `circles.resources (${locale}) → at least one published item`,
run: async () => {
const items = await getCircleResources({ locale, status: 'published' })
if (items.length === 0) {
throw new Error('expected at least one published circle resource')
}
return items
},
},
{
name: `circles (${locale}) → at least one published Circle`,
run: async () => {
const circles = await getCircles({ locale, status: 'published' })
if (circles.length === 0) {
throw new Error('no published Circles found')
}
return circles
},
},
{
name: `circles (${locale}) → canonical sort (order asc, then slug)`,
run: async () => {
const circles = await getCircles({ locale, status: 'published' })
for (let i = 1; i < circles.length; i++) {
const prev = circles[i - 1].order ?? Number.MAX_SAFE_INTEGER
const cur = circles[i].order ?? Number.MAX_SAFE_INTEGER
if (prev > cur) {
throw new Error(
`out-of-order: "${circles[i - 1].slug}" (order=${prev}) before "${circles[i].slug}" (order=${cur})`
)
}
}
return circles
},
},
{
name: `circles (${locale}) → detailBackLink default applied + override honoured + coordinatesGeoJson present`,
run: async () => {
const circles = await getCircles({ locale, status: 'published' })
for (const circle of circles) {
if (!circle.detailBackLink) {
throw new Error(
`circle "${circle.slug}" missing detailBackLink (default should always apply)`
)
}
if (
!Array.isArray(circle.coordinatesGeoJson) ||
circle.coordinatesGeoJson.length !== 2
) {
throw new Error(
`circle "${circle.slug}" missing coordinatesGeoJson [lng, lat]`
)
}
const [lng, lat] = circle.coordinatesGeoJson
if (lng !== circle.coordinates.lng || lat !== circle.coordinates.lat) {
throw new Error(
`circle "${circle.slug}" coordinatesGeoJson does not match coordinates`
)
}
}
// Florianópolis was authored with an explicit detailBackLink override
// ("Back to all circles") — verify the override survives the loader.
const flori = circles.find((c) => c.slug === 'florianopolis')
if (flori && flori.detailBackLink.label !== 'Back to all circles') {
throw new Error(
`florianopolis detailBackLink override lost: got "${flori.detailBackLink.label}"`
)
}
// Any circle without an explicit override should land on the loader default.
const la = circles.find((c) => c.slug === 'los-angeles')
if (la && la.detailBackLink.label !== 'All circles') {
throw new Error(
`los-angeles detailBackLink default not applied: got "${la.detailBackLink.label}"`
)
}
return circles
},
},
{
name: `circle events (${locale}) → at least one published event`,
run: async () => {
const events = await getCircleEvents({ locale, status: 'published' })
if (events.length === 0) {
throw new Error('no published CircleEvents found')
}
// Each event keeps its explicit sequenceNumber after the loader pass.
const expectations: Record<string, number> = {
'los-angeles-circle-4': 4,
'los-angeles-circle-5': 5,
'florianopolis-circle-2': 2,
}
for (const event of events) {
const expected = expectations[event.slug]
if (expected !== undefined && event.sequenceNumber !== expected) {
throw new Error(
`event "${event.slug}" expected sequenceNumber=${expected}, got ${event.sequenceNumber}`
)
}
}
return events
},
},
{
name: `circle events (${locale}) → grouped by event-local date across timezones`,
run: async () => {
const groups = await getCircleEventsGroupedByDate({
locale,
status: 'published',
})
if (groups.length === 0) {
throw new Error('no event groups returned')
}
// Groups must arrive sorted ascending by groupKey (chronological).
for (let i = 1; i < groups.length; i++) {
if (groups[i - 1].groupKey > groups[i].groupKey) {
throw new Error(
`group out-of-order: ${groups[i - 1].groupKey} before ${groups[i].groupKey}`
)
}
}
// The Jan 21 group must contain BOTH the LA event and the Florianópolis
// event (different UTC instants, same event-local date in their own zones).
const jan21 = groups.find((g) => g.groupKey === '2026-01-21')
if (!jan21) {
throw new Error(
`expected a "2026-01-21" group; got [${groups.map((g) => g.groupKey).join(', ')}]`
)
}
const slugsInJan21 = new Set(jan21.events.map((e) => e.slug))
for (const required of [
'los-angeles-circle-4',
'florianopolis-circle-2',
]) {
if (!slugsInJan21.has(required)) {
throw new Error(`Jan 21 group missing "${required}"`)
}
}
return groups
},
},
{
name: `circle events (${locale}) → formatEventDateForSurface for all 4 surfaces`,
run: async () => {
const events = await getCircleEvents({
circleSlug: 'los-angeles',
locale,
status: 'published',
})
const la4 = events.find((e) => e.slug === 'los-angeles-circle-4')
if (!la4) throw new Error('los-angeles-circle-4 not found')
const indexGroup = formatEventDateForSurface(la4, 'index-group', locale)
if (!/January 21\s*\/\s*Wednesday/i.test(indexGroup)) {
throw new Error(`index-group format unexpected: "${indexGroup}"`)
}
const indexCardTime = formatEventDateForSurface(
la4,
'index-card-time',
locale
)
if (!/^7:00\s*PM$/.test(indexCardTime)) {
throw new Error(`index-card-time format unexpected: "${indexCardTime}"`)
}
const detailDate = formatEventDateForSurface(la4, 'detail-date', locale)
if (!/^January 21, 2026$/.test(detailDate)) {
throw new Error(`detail-date format unexpected: "${detailDate}"`)
}
const press = formatEventDateForSurface(la4, 'press', locale)
if (!/^01\.21\.26$/.test(press)) {
throw new Error(`press format unexpected: "${press}"`)
}
return { indexGroup, indexCardTime, detailDate, press }
},
},
{
name: `circle initiatives (${locale}) → at least one published + per-circle filter`,
run: async () => {
const all = await getCircleInitiatives({ locale, status: 'published' })
if (all.length === 0) {
throw new Error('no published CircleInitiatives found')
}
// Per-circle filter: each circleSlug should narrow the result set.
const expectations: Record<string, string[]> = {
'los-angeles': ['logos-powered-nextdoor-app'],
london: ['digital-escape-egress', 'digital-id-replacement'],
}
for (const [circleSlug, expectedSlugs] of Object.entries(expectations)) {
const filtered = await getCircleInitiatives({
circleSlug,
locale,
status: 'published',
})
const got = filtered.map((i) => i.slug).sort()
const want = [...expectedSlugs].sort()
if (
got.length !== want.length ||
got.some((s, idx) => s !== want[idx])
) {
throw new Error(
`circleSlug="${circleSlug}" expected [${want.join(', ')}], got [${got.join(', ')}]`
)
}
for (const initiative of filtered) {
if (initiative.circleSlug !== circleSlug) {
throw new Error(
`filter leak: "${initiative.slug}" returned for circleSlug="${circleSlug}" but its circleSlug is "${initiative.circleSlug}"`
)
}
}
}
return all
},
},
{
name: `circle initiatives (${locale}) → canonical sort (order asc, then slug)`,
run: async () => {
const all = await getCircleInitiatives({ locale, status: 'published' })
for (let i = 1; i < all.length; i++) {
const prev = all[i - 1].order ?? Number.MAX_SAFE_INTEGER
const cur = all[i].order ?? Number.MAX_SAFE_INTEGER
if (prev > cur) {
throw new Error(
`out-of-order: "${all[i - 1].slug}" (order=${prev}) before "${all[i].slug}" (order=${cur})`
)
}
}
return all
},
},
]
const PAGE_ROUTES: ReadonlyArray<{ route: string; minSections: number }> = [
{ route: '/', minSections: 1 },
{ route: '/builders-hub', minSections: 0 },
{ route: '/circles', minSections: 0 },
{ route: '/technology-stack', minSections: 1 },
{ route: '/technology-stack/blockchain', minSections: 1 },
]
const buildPagesChecks = (locale: Language): Check[] => [
{
name: `pages.home (${locale}) → loads + every section componentType valid`,
run: async () => {
const page = await getPageCopy('/', locale)
if (page.sections.length === 0) {
throw new Error('home page has zero sections')
}
const expected: readonly string[] = [
'hero',
'homeSocialProof',
'homeChoosePath',
'homeDecide',
'homeAbout',
'techStackOverview',
'homeStartBuilding',
'homeUseCases',
'homeBuilderPortal',
'relatedArticles',
]
const seen: Set<string> = new Set(
page.sections.map((s) => s.componentType)
)
const missing = expected.filter((t) => !seen.has(t))
if (missing.length > 0) {
throw new Error(
`home.json missing expected section types: [${missing.join(', ')}]`
)
}
// Spot-check the discriminated union narrowing — pulls a typed field
// from each variant so a regression in pageSectionSchema would surface.
for (const section of page.sections) {
switch (section.componentType) {
case 'hero':
if (!section.headline)
throw new Error(`hero "${section.key}" missing headline`)
break
case 'cardGrid':
if (section.cards.length === 0) {
throw new Error(`cardGrid "${section.key}" has zero cards`)
}
break
case 'techStackOverview':
if (section.pillars.length !== 4) {
throw new Error(
`techStackOverview "${section.key}" expected 4 pillars`
)
}
break
case 'gallery':
if (section.items.length === 0) {
throw new Error(`gallery "${section.key}" has zero items`)
}
break
case 'ctaPanel':
// `cta` is optional (sections like the LMN intro render without one).
// Verify the always-required `title` is set instead.
if (!section.title)
throw new Error(`ctaPanel "${section.key}" missing title`)
break
case 'relatedArticles':
if (!section.title)
throw new Error(`relatedArticles "${section.key}" missing title`)
break
case 'richText':
if (!section.body)
throw new Error(`richText "${section.key}" missing body`)
break
}
}
return page
},
},
{
name: `pages — every seed route loads + route-field round-trips + minSections`,
run: async () => {
for (const { route, minSections } of PAGE_ROUTES) {
const page = await getPageCopy(route, locale)
if (page.route !== route) {
throw new Error(
`route mismatch on "${route}": file declares "${page.route}"`
)
}
if (page.sections.length < minSections) {
throw new Error(
`page "${route}" has ${page.sections.length} sections, expected at least ${minSections}`
)
}
if (!page.title || !page.description) {
throw new Error(`page "${route}" missing title or description`)
}
}
return PAGE_ROUTES
},
},
{
name: `pages.technology-stack (${locale}) → giantSwitch present + tags icons enum-validated`,
run: async () => {
const page = await getPageCopy('/technology-stack', locale)
const gs = page.sections.find((s) => s.componentType === 'giantSwitch')
if (!gs || gs.componentType !== 'giantSwitch') {
throw new Error('technology-stack.json missing a giantSwitch section')
}
if (!gs.tags || gs.tags.length === 0) {
throw new Error(
'giantSwitch tags missing — Logos App banner expects at least one tag'
)
}
const allowed = new Set(['wallet', 'chat', 'files', 'explorer', 'lambda'])
for (const tag of gs.tags) {
if (tag.icon !== undefined && !allowed.has(tag.icon)) {
throw new Error(
`giantSwitch tag "${tag.label}" has unknown icon "${tag.icon}"`
)
}
}
return gs
},
},
{
name: `pages.technology-stack/blockchain (${locale}) → expected section types present`,
run: async () => {
const page = await getPageCopy('/technology-stack/blockchain', locale)
const types = new Set(page.sections.map((s) => s.componentType))
for (const required of [
'hero',
'ctaPanel',
'cardGrid',
'relatedArticles',
]) {
if (!types.has(required as never)) {
throw new Error(`blockchain page missing "${required}" section`)
}
}
return page
},
},
]
const buildCustomSectionChecks = (): Check[] => {
// Sample custom-section schema: an announcement banner with a level enum.
// The shape is local to this test — production custom sections register
// their own schemas at app bootstrap.
const announcementBannerSchema = z.object({
level: z.enum(['info', 'warning']),
message: z.string().min(1),
dismissible: z.boolean().default(false),
})
type AnnouncementBanner = z.infer<typeof announcementBannerSchema>
const SCHEMA_ID = 'announcement-banner'
return [
{
name: 'custom-sections → register / lookup / duplicate-throw / unknown-id',
run: async () => {
__resetCustomSectionRegistryForTests()
// Unknown id returns undefined before registration.
if (getCustomSectionSchema(SCHEMA_ID) !== undefined) {
throw new Error('lookup before register should return undefined')
}
registerCustomSection(SCHEMA_ID, announcementBannerSchema)
// After registration the schema is retrievable.
if (getCustomSectionSchema(SCHEMA_ID) !== announcementBannerSchema) {
throw new Error(
'lookup after register did not return the registered schema'
)
}
// Duplicate registration throws.
let duplicateThrew = false
try {
registerCustomSection(SCHEMA_ID, announcementBannerSchema)
} catch {
duplicateThrew = true
}
if (!duplicateThrew) {
throw new Error('duplicate registration must throw but did not')
}
return SCHEMA_ID
},
},
{
name: 'custom-sections → parseCustomSectionPayload returns typed payload',
run: async () => {
__resetCustomSectionRegistryForTests()
registerCustomSection(SCHEMA_ID, announcementBannerSchema)
const section = customSectionSchema.parse({
componentType: 'custom',
key: 'global.announcement',
customSchemaId: SCHEMA_ID,
payload: { level: 'info', message: 'Hello operator.' },
})
const parsed: AnnouncementBanner = parseCustomSectionPayload(
section,
announcementBannerSchema
)
if (parsed.level !== 'info') {
throw new Error(`expected level=info, got ${parsed.level}`)
}
if (parsed.dismissible !== false) {
throw new Error(
'schema default for dismissible should resolve to false'
)
}
return parsed
},
},
{
name: 'custom-sections → invalid payload throws via parseCustomSectionPayload',
run: async () => {
__resetCustomSectionRegistryForTests()
registerCustomSection(SCHEMA_ID, announcementBannerSchema)
const badSection = customSectionSchema.parse({
componentType: 'custom',
key: 'global.announcement',
customSchemaId: SCHEMA_ID,
payload: { level: 'critical', message: 'oops' },
})
let threw = false
try {
parseCustomSectionPayload(badSection, announcementBannerSchema)
} catch {
threw = true
}
if (!threw) {
throw new Error('invalid payload must throw but did not')
}
// Reset so subsequent process exits clean (no stale state for any
// hypothetical follow-up checks).
__resetCustomSectionRegistryForTests()
return null
},
},
]
}
const main = async (): Promise<void> => {
const locales = getActiveLocales()
const checks: Check[] = []
for (const locale of locales) {
checks.push(
...buildSiteChecks(locale),
...buildBuilderHubChecks(locale),
...buildCirclesChecks(locale),
...buildPagesChecks(locale)
)
}
// Locale-agnostic checks (registry behaviour, schema-only).
checks.push(...buildCustomSectionChecks())
let failed = 0
for (const check of checks) {
try {
await check.run()
console.log(`ok ${check.name}`)
} catch (err) {
failed++
const message = err instanceof Error ? err.message : String(err)
console.error(`FAIL ${check.name}: ${message}`)
}
}
console.log('')
console.log(`${checks.length - failed}/${checks.length} checks passed`)
if (failed > 0) process.exit(1)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})