diff --git a/README.md b/README.md index e44fb7c..91626e3 100644 --- a/README.md +++ b/README.md @@ -1,161 +1,316 @@ -# Study in China Atlas - -An independent, source-led platform for international students comparing Chinese universities, programs, scholarships, cities and application routes. - -The public interface is available in English, Chinese, Russian, German, French and Spanish. German, French and Spanish are the first expansion batch: interface and legal copy are translated, while untranslated record prose is explicitly marked and falls back to English. Arabic and Portuguese remain registered preview locales until their review gates are complete. - -## Current release - -- 39 verified universities across 12 student cities -- 2 verified national scholarship routes -- 5 fully sourced 2026 program pages; 113 candidate drafts and 2 archived records remain private -- Six public language routes with locale-aware dates, CNY values and explicit translation fallbacks -- Search and filters for universities and programs -- Versioned, browser-only favorites and comparison of up to four programs -- Printable comparison sheets (use the browser’s **Save as PDF** option) -- Source, verification and next-review metadata on factual records -- Secure feedback endpoint with Zod, Origin checks, Turnstile, HMAC rate limiting and Resend -- Sitemap, robots, canonical URLs, language alternates and structured detail-page data -- CI, a daily freshness gate, weekly link checks, monthly review records and semester audits - -The repository is being migrated from that conservative JSON release to the automated -Catalog platform. The implemented foundation now includes separate Pipeline and Catalog -D1 schemas, immutable R2 snapshot/release contracts, queue-based ingestion, dual MiniMax -validation, atomic Catalog release cutover, versioned public API routes, and `json` / `shadow` -/ `d1` repository modes. Ten pilot institutions currently have validated Source Manifests -covering 100 registered official sources. This is infrastructure and pilot coverage—not a -claim that the full 120-school catalog has already been collected. - -The initial compatibility dataset remains deliberately conservative. In the automated -Catalog, record identity and individual fact visibility are gated separately: an official -program may remain visible while a stale, conflicting, unavailable, or unannounced fee or -deadline is returned as `null` with explicit `fieldMeta` and an official entry link. Draft, -archived, or unverified identities remain private everywhere, including API, search, SEO, -JSON-LD, sitemap, and favorites. - -## Stack - -| Layer | Choice | +
+ Study in China Atlas +

Study in China Atlas

+

Source-led discovery for international students exploring Chinese universities, programs, scholarships and study cities.

+

+ Explore the live atlas + · Browse programs + · Public API + · Data policy +

+

+ CI + Node 24 + Next.js 16 + TypeScript + Cloudflare + Vercel +

+
+ +> [!IMPORTANT] +> Study in China Atlas is an independent, non-commercial public-interest directory—not a university, scholarship provider or application portal. Always confirm deadlines, fees and eligibility on the linked official source before applying or paying. + +## Why this atlas exists + +International applicants often have to compare hundreds of differently structured university pages, PDF notices and application systems. Study in China Atlas turns those scattered official sources into a searchable, multilingual catalogue while keeping the original evidence one click away. + +The product is built around three promises: + +| Discover broadly | Verify precisely | Understand uncertainty | +|---|---|---| +| Explore a national selection instead of a handful of famous universities. | Dynamic facts carry an official source, check date and publication status. | Unknown, conflicting or stale values stay empty; old cycles and guesses never fill the gap. | + +## Public catalogue snapshot + +
+ +| **257** universities | **1,152** programs | **332** scholarships | **60** cities | +|:---:|:---:|:---:|:---:| + +
+ +The public catalogue also contains **326 published admission-cycle records** and is backed by **1,928 registered official source records**. Snapshot evaluated for **2026-08-07** with `npm run quality:platform-scorecard`. + +
+Open the honest data-depth scorecard + +Record count is not the same as record completeness. These are the current depth and platform-readiness indicators: + +| Quality indicator | Current baseline | Next gate | +|---|---:|---:| +| Programs with a current public cycle | 318 / 1,152 · **27.6%** | ≥ 70% | +| Programs with duration | **61.72%** | ≥ 90% | +| Programs with an official application route | **53.3%** | ≥ 80% | +| Programs with known teaching language | **86.63%** | ≥ 95% | +| Programs with eligibility/language evidence | **6.42%** | ≥ 50% | +| Universities connected to scholarships | 192 / 257 | ≥ 230 | +| Cities with reviewed coordinates | 27 / 60 | 60 / 60 | +| Complete Source Manifests | 10 / 257 | 257 / 257 | +| Complete catalogue reconciliation | 0 / 257 | 257 / 257 | +| Platform quality gates passing | 3 / 14 | 14 / 14 | + +The raw compatibility dataset contains 263 universities, 1,173 programs and 358 scholarships. Draft, archived, identity-conflicting or publication-ineligible records are intentionally excluded from the public numbers above. + +
+ +## What applicants can do + +- Browse universities, programs, scholarships and student cities in one coherent interface. +- Explore programs through a 17-field, applicant-oriented taxonomy, including Chinese language and international Chinese education. +- Share URL-based filters, sorting and pagination without losing search state. +- Save records locally, compare up to four programs and print a compact comparison sheet. +- Inspect field-level source links, last-check dates and uncertainty states. +- Use English, Chinese and Russian public routes; German, French and Spanish are the first reviewed expansion routes. +- Move from discovery to the official university or scholarship application system—the atlas never receives application documents. + +Portuguese and Arabic remain registered preview locales and are not publicly indexed. When translated record prose is unavailable, the interface uses an explicit English fallback rather than presenting machine output as an official translation. + +## Trust model: evidence before completeness + +The catalogue separates **record identity** from **field visibility**. A verified program identity may remain discoverable even when its tuition or deadline cannot safely be shown. Each dynamic field uses one of six explicit states: + +```ts +type FactStatus = + | 'known' + | 'officially_not_announced' + | 'not_applicable' + | 'source_unavailable' + | 'conflict' + | 'stale' +``` + +Publication follows these rules: + +1. Only allowlisted university, government and scholarship-provider sources can support a fact. +2. Every changing fact belongs to a specific academic year and intake. +3. Deterministic parsing extracts links, dates, money and page structure before model-assisted extraction. +4. High-risk fields require two independent MiniMax extractions to agree and point to locatable evidence. +5. Evidence, deterministic rules, freshness and cross-source conflict checks must all pass. +6. A failed field is published as an empty value with status metadata and an official entry link—not as a guess. +7. Immutable releases are relationship-, count- and hash-validated before the public pointer moves atomically. + +Raw HTML, PDFs and screenshots stay private in R2. The public platform exposes only structured facts and the shortest necessary source context. + +## System architecture + +```mermaid +flowchart LR + S["Official university, government and scholarship sources"] + Q["Cloudflare Queues"] + I["Ingestion Worker
fetch, parse, dual validation"] + RS[("Private R2 snapshots")] + P[("Pipeline D1
claims, evidence, quarantine")] + E["Entity Materializer"] + U["Publisher"] + B["Release Builder"] + RR[("Versioned R2 releases")] + C[("Catalog D1
immutable public projection")] + A["Catalog API"] + J["Generated JSON compatibility snapshot"] + R["json / shadow / d1 Repository"] + W["Next.js 16 on Vercel"] + + S --> Q --> I + I --> RS + I --> P + P --> E --> P + P --> U --> P + P --> B + B --> RR + B --> C + C --> A + A --> R + J --> R + R --> W +``` + +### Why the platform is split this way + +| Layer | Responsibility | Engineering reason | +|---|---|---| +| Pipeline D1 | Jobs, candidates, evidence, conflicts and quarantine | Frequent internal writes never compete with public reads. | +| Private R2 | Compressed source snapshots, PDFs, evidence assets and release exports | Cheap immutable storage makes audit and recovery possible. | +| Catalog D1 | Validated, versioned public projection | The website reads a stable release instead of half-written ingestion state. | +| Catalog Repository | `json`, `shadow` and `d1` backends | The application can compare backends and roll back without rewriting pages. | +| Next.js on Vercel | Multilingual pages, SEO, feedback and web delivery | App Router provides server rendering while Vercel supplies previews and production delivery. | + +The platform foundation is implemented, but production remains deliberately compatible with the generated JSON repository while D1 shadow comparison and release-readiness gates continue. MiniMax credentials exist only as Cloudflare secrets; source content never receives database, network or execution privileges. + +## Technology map + +| Concern | Choice | +|---|---| +| Web application | Next.js 16 App Router, React 19, strict TypeScript 5.9 | +| Design system | Accessible custom “academic atlas” CSS, progressive enhancement | +| Collection | Cloudflare Workers, Queues, allowlisted fetch policies | +| Data and evidence | Pipeline D1, Catalog D1, private R2, immutable releases | +| Extraction | Deterministic parsers + MiniMax dual extraction + evidence grounding | +| Validation | Zod schemas, deterministic conflict/freshness/publication gates | +| Testing | Vitest 4, Testing Library, Node test runner, Playwright | +| Delivery | GitHub Actions, Vercel Preview and Production deployments | +| Observability | Release age, queue/DLQ state, source health, backup and cost signals | + +## Public API + +Versioned endpoints use cursor pagination. List endpoints default to `limit=24` and accept at most 100 records per request. + +| Endpoint | Purpose | |---|---| -| Runtime | Node.js 24 LTS | -| Application | Next.js 16 App Router, React 19, strict TypeScript | -| Styling | Tailwind build pipeline plus a custom accessible atlas design system | -| Content | Cloudflare D1 Catalog + versioned Release; JSON is a read-only compatibility snapshot | -| Collection | Cloudflare Workers, Queues, private R2 snapshots, rules + MiniMax-M2.7 dual extraction | -| Tests | Vitest, Testing Library and Playwright | -| Production | Vercel; GitHub for code, reviews and scheduled quality checks | +| `GET /api/v1/institutions` | Search and filter institutions | +| `GET /api/v1/institutions/{slug}` | Institution detail | +| `GET /api/v1/programs` | Search and filter programs | +| `GET /api/v1/programs/{slug}` | Program detail | +| `GET /api/v1/programs/{slug}/cycles` | Program admission cycles | +| `GET /api/v1/scholarships` | Search and filter scholarships | +| `GET /api/v1/scholarships/{slug}/cycles` | Scholarship cycles | +| `GET /api/v1/releases/current` | Current public release metadata | +| `GET /api/v1/double-first-class` | Double First-Class coverage view | + +```bash +curl "https://studyinchina.vercel.app/api/v1/institutions?discipline=chinese-language&limit=3" +``` + +Program filtering supports institution, city, program type, degree, field, teaching language, academic year, intake, tuition range, application state and scholarship availability. -## Local development +## Run locally + +The default JSON backend works without Cloudflare credentials, so a contributor can inspect the product safely before configuring infrastructure. ```bash +git clone https://github.com/computersciencefreshmen/StudyInChina.git +cd StudyInChina npm ci cp .env.example .env.local npm run dev ``` -Open `http://localhost:3000`. `/` redirects to the saved or browser-preferred launch locale. +PowerShell equivalent: + +```powershell +Copy-Item .env.example .env.local +npm run dev +``` + +Open . The root route redirects to the saved or browser-preferred public locale. + +Set `CONTENT_PREVIEW=true` only in local development or a Vercel Preview when you intentionally need to inspect draft content. Production ignores that switch. The feedback endpoint fails closed when Turnstile, distributed rate limiting or email delivery is missing. -Set `CONTENT_PREVIEW=true` only in a local or Vercel Preview environment when an editor needs to inspect draft records. The production Vercel environment always ignores this switch. +## Quality gates -Run the complete local quality gate: +Every code change is expected to pass the core local gate: ```bash npm run lint npm run typecheck npm test npm run validate:data -npm run validate:manifests npm run validate:d1 -npm run test:ingestion -npm run check:worker:catalog -npm run check:worker:ingestion +npm run validate:manifests npm run build +npm run test:e2e ``` -After a successful production build, Playwright smoke tests start that production server automatically: +CI additionally validates Source Manifests, prompt-injection fixtures, maintenance capacity, all Worker test suites and Worker deployment dry-runs. The complete executable matrix lives in [`.github/workflows/ci.yml`](./.github/workflows/ci.yml). -```bash -npx playwright install chromium -npm run test:e2e -``` +Data changes must satisfy a stricter rule: an increase in row count is not progress unless source coverage, current-cycle coverage, field completeness and update success improve with it. -## Repository structure +## Repository map ```text -content/data/ legacy/read-only Catalog compatibility snapshot -content/source-manifests/ allowlisted official-source collection manifests -docs/content-maintenance.md editorial, translation and review workflow -infra/d1/ Pipeline and Catalog D1 migrations -scripts/ validation, link and data-health checks -src/app/[locale]/ localized App Router pages -src/app/api/feedback/ private feedback delivery endpoint -src/components/ design system and interactive features -src/i18n/ locale registry and launch messages -src/lib/data/ schemas, formatters and server data loader -tests/unit/ data and browser-storage tests -tests/e2e/ multilingual critical-path smoke tests -workers/catalog-api/ versioned public Catalog API Worker -workers/ingestion/ collection, extraction, evidence and quarantine Worker +.github/workflows/ CI, source checks, backups and deployments +content/data/ generated JSON compatibility snapshot +content/source-manifests/ allowlisted official-source manifests +docs/ architecture, operations and data policies +infra/d1/ Pipeline and Catalog D1 migrations +scripts/catalog/ release building and performance benchmarks +scripts/ingestion/ manifest, source and materialization tooling +scripts/quality/ coverage, inventory and platform scorecards +src/app/[locale]/ localized App Router pages +src/app/api/v1/ versioned public API compatibility routes +src/components/ design system and product features +src/i18n/ locale registry and reviewed interface copy +src/lib/catalog/ json / shadow / d1 repository implementations +src/lib/data/ schemas, publication gates and formatting +tests/unit/ domain, API and browser-storage tests +tests/e2e/ multilingual critical-path tests +workers/ingestion/ fetch, snapshot, parse and extraction pipeline +workers/entity-materializer/ candidate-to-entity field mapping +workers/publisher/ validated publication candidates +workers/release-builder/ immutable release assembly and cutover +workers/catalog-api/ public D1 API Worker +workers/localization/ gated translation pipeline (disabled by default) ``` -## Content updates +## Release, rollback and recovery -Do not manually invent or copy an application date, fee, requirement, or scholarship -term. Every dynamic fact belongs to an academic year/intake and must be traceable to a -registered official source snapshot. +- `main` is the production code branch; every pull request receives a Vercel Preview. +- A catalogue release is immutable and moves live only after schema, relationship, count, search and checksum validation. +- Catalog D1 retains the active release and two rollback releases; full history remains in private R2. +- A data rollback moves `currentReleaseId` back to a validated release without redeploying the application. +- An application rollback promotes the previous healthy Vercel deployment. +- Daily exports target a 24-hour recovery point; recovery restores into an isolated database before any production cutover. -1. Register the official HTTPS source and its collection policy in the school's Source Manifest. -2. Run it through snapshot, deterministic parsing, independent dual extraction, - evidence grounding, conflict, freshness, and release gates. -3. Store unknown or rejected values as a non-`known` `FactStatus`; never substitute an old cycle. -4. Publish a complete immutable Catalog Release and atomically advance the current pointer. -5. Treat `content/data/*.json` as generated compatibility output after D1 cutover. -6. Run the full data, migration, Worker, unit, type, lint, and build gates before deployment. +See [`docs/platform-rollout.md`](./docs/platform-rollout.md), [`docs/backup-and-restore.md`](./docs/backup-and-restore.md) and [`docs/database-schema.md`](./docs/database-schema.md) for operational detail. -Old cycles are archived rather than overwritten. Automation may publish only facts that -pass all automatic gates; failed fields stay value-less and link to the official entry. -See the full [content maintenance policy](docs/content-maintenance.md). +## Roadmap: depth before uncontrolled scale -## Vercel deployment +```mermaid +flowchart LR + N["Now
257 public universities"] --> D["Data depth
70% current-cycle coverage"] + D --> T["Trust coverage
257 manifests + reconciliations"] + T --> C["D1 cutover
3 releases / 72h shadow parity"] + C --> F["500 universities
after 2 healthy monthly cycles"] + F --> K["1000+ universities
quality- and cost-gated"] +``` -1. Import `computersciencefreshmen/StudyInChina` into Vercel and select the Next.js preset. -2. Keep the install command as `npm ci`; the repository and CI use Node.js 24. -3. Add the environment variables below to Preview and Production. Until a custom domain is configured, production canonicals default to `https://studyinchina.vercel.app`. -4. Deploy PRs as Preview deployments. Merge only after mobile, language, link and factual checks. -5. Point the custom domain at the Production deployment and redirect the `.vercel.app` host to it. -6. Set `NEXT_PUBLIC_SITE_URL` to the final canonical `https://` origin, then redeploy so sitemap, canonical and `hreflang` values are correct. -7. Enable Vercel Web Analytics and Speed Insights. Configure uptime checks for `/en`, `/en/universities`, one university detail page and `/api/feedback`. +Near-term work is measured by: -### Environment variables +- raising the remaining 31 sparse universities to 3–5 verified international-student programs or a documented `limited` reconciliation; +- increasing current-cycle coverage from 27.6% to at least 70%; +- reaching 90% duration, 80% official application-route and 95% teaching-language coverage; +- expanding scholarship-connected institutions from 192 to at least 230; +- completing 257 Source Manifests and 257 catalogue reconciliations; +- completing three matching shadow releases over at least 72 hours before Production switches to D1; +- passing two full monthly update cycles before expansion to 500, then 1,000+ institutions. -| Variable | Purpose | -|---|---| -| `NEXT_PUBLIC_SITE_URL` | Canonical production origin | -| `CONTENT_PREVIEW` | Optional draft-content preview; keep `false` in Production | -| `CATALOG_BACKEND` | `json`, `shadow`, or `d1` Catalog repository mode | -| `CATALOG_API_URL` | Internal compatibility endpoint used during shadow/cutover | -| `CATALOG_API_TOKEN` | Optional server-only bearer token for that endpoint | -| `CATALOG_API_TOKEN_HOST` | Required exact hostname binding when the Catalog token is set | -| `CONTACT_RECIPIENT` | Private destination email; never exposed to the client | -| `RESEND_API_KEY` | Resend server API key | -| `RESEND_FROM` | Verified Resend sender | -| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` | Public Cloudflare Turnstile widget key | -| `TURNSTILE_SECRET_KEY` | Server-only Turnstile secret | -| `UPSTASH_REDIS_REST_URL` | Serverless rate-limit store | -| `UPSTASH_REDIS_REST_TOKEN` | Server-only Upstash token | -| `RATE_LIMIT_SALT` | Random secret of at least 32 characters for IP HMAC | - -The feedback endpoint intentionally returns `502` in production if distributed rate limiting, verification or email delivery is not configured. This fail-closed behavior prevents unprotected form delivery. - -## Release and rollback - -- `main` is the production branch; every pull request receives a Vercel Preview. -- Tag each reviewed semester dataset as `data-YYYY-semester`. -- Roll back application code from Vercel’s previous stable deployment. -- Roll back Catalog data by atomically moving the pointer to the previous validated Release. -- Restore checksum-verified D1/R2 exports into an isolated database before any production recovery. -- Keep GitHub Pages disabled to avoid a duplicate, stale public copy. +## Contributing data safely + +Corrections and additions are welcome when they strengthen evidence quality. + +1. Provide an official HTTPS URL from the university, government or scholarship provider. +2. Identify the academic year and intake for any deadline, fee or requirement. +3. Include the date on which the source was checked. +4. Leave unannounced values empty; do not infer them from an earlier year. +5. Never use a ranking, agency or aggregator as the sole evidence for an admissions fact. +6. Never commit passports, transcripts, health records, applicant emails or other personal application data. +7. Run lint, typecheck, tests and data validation before opening a pull request. + +Use the [data-correction issue form](./.github/ISSUE_TEMPLATE/data-correction.yml) or follow the [pull-request template](./.github/pull_request_template.md). Raw collection artifacts and candidate packages belong in the private pipeline/quarantine flow, not in the public catalogue. + +## Security and privacy boundaries + +- Collection is limited to registered official HTTPS domains and respects access controls, robots policies and per-domain throttling. +- The platform does not bypass authentication, CAPTCHAs, `403` responses, regional restrictions or private endpoints. +- Fetch validation rejects private-network addresses, unusual ports and unregistered cross-domain redirects. +- Applicants do not create accounts and the project does not collect application documents. +- Favorites and comparison state remain in the browser. +- Feedback is protected with origin checks, Turnstile, HMAC-based rate limiting and private email delivery. + +Report a security problem privately to the maintainer rather than placing secrets or exploit details in a public issue. ## Creator -Created and maintained by [Henry Yang](https://yanghanyu2023.wixsite.com/henry) as a non-commercial public-interest information project. Corrections and suggestions should use the private contact form; never send passports, transcripts, medical records or other application documents. +Created and maintained by [Henry Yang](https://yanghanyu2023.wixsite.com/henry) as a non-commercial public-interest information project for international students. + +If you spot outdated information, use the website’s private correction form and include the official source. Please never send passports, transcripts, medical records or payment information. diff --git a/docs/database-schema.md b/docs/database-schema.md index 2b35576..695c118 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -71,6 +71,11 @@ Apply the pipeline migrations in filename order: 7. `infra/d1/pipeline/migrations/0007_snapshot_derivatives.sql` 8. `infra/d1/pipeline/migrations/0008_release_builder_contract.sql` 9. `infra/d1/pipeline/migrations/0009_entity_discovery_registry.sql` +10. `infra/d1/pipeline/migrations/0010_materialization_batches.sql` +11. `infra/d1/pipeline/migrations/0011_materialization_release_requests.sql` +12. `infra/d1/pipeline/migrations/0012_localization_pipeline.sql` +13. `infra/d1/pipeline/migrations/0013_institution_target_coverage.sql` +14. `infra/d1/pipeline/migrations/0014_entity_candidate_materialization.sql` ### Domain records @@ -141,6 +146,12 @@ Apply the catalog migrations in filename order: 2. `infra/d1/catalog/migrations/0002_programs_scholarships.sql` 3. `infra/d1/catalog/migrations/0003_search_views.sql` 4. `infra/d1/catalog/migrations/0004_atomic_release_cutover.sql` +5. `infra/d1/catalog/migrations/0005_public_projection_hardening.sql` +6. `infra/d1/catalog/migrations/0006_field_meta_projection.sql` +7. `infra/d1/catalog/migrations/0007_deadline_grace_publication.sql` +8. `infra/d1/catalog/migrations/0008_identity_only_program_visibility.sql` +9. `infra/d1/catalog/migrations/0009_release_compatibility_artifacts.sql` +10. `infra/d1/catalog/migrations/0010_release_retention.sql` Every catalog domain row includes `release_id`. A release is an immutable public snapshot. Building a new release does not affect readers because all `current_*` views start from the singleton `release_pointer` and require its target release to be `active`. diff --git a/docs/plans/2026-08-07-site-experience-readme-design.md b/docs/plans/2026-08-07-site-experience-readme-design.md new file mode 100644 index 0000000..dde6e2f --- /dev/null +++ b/docs/plans/2026-08-07-site-experience-readme-design.md @@ -0,0 +1,65 @@ +# Site experience and README design + +Date: 2026-08-07 +Status: approved for implementation + +## Goal + +Move the public experience from a collection of catalogue cards toward a credible, +decision-oriented international education atlas. The homepage should answer four +questions within the first two sections: + +1. What can I explore here? +2. How broad is the public catalogue? +3. Why should I trust the records? +4. What should I do next? + +The README should offer the same clarity to engineers and contributors: product +purpose, current public scope, evidence model, architecture, quality gates, local +workflow, deployment model and honest expansion roadmap. + +## Design language + +Keep the existing "Eastern academic atlas" system: warm paper, deep ink, vermilion +and jade accents, editorial typography, cartographic rules and restrained motion. +The experience should feel like a modern university yearbook and field guide, not a +generic SaaS dashboard. New sections use square editorial panels, fine rules and +large tabular figures rather than excessive pills or decorative gradients. + +## Homepage information architecture + +1. **Hero** — preserve the primary university/program actions and source-first + promise. +2. **Catalogue ledger** — show runtime-derived public counts, the latest official + source check and a direct link to the data policy. +3. **Applicant pathway** — four linked steps: discover universities, compare + programs, verify evidence, then use the official application route. +4. **Curated institutions** — retain featured university cards. +5. **Field index** — retain all 17 applicant-oriented fields and live program counts. +6. **City constellation** — retain the compliant coordinate index. +7. **Application guides** — retain the first three guide entries. + +All new user-facing copy is available for the six launch locales. Counts come from +the active catalogue repository, never from hard-coded marketing text. + +## README structure + +- Branded project header and live links +- Honest public-release metrics +- Product capabilities and user journey +- Source and field-state trust model +- End-to-end architecture diagram +- Technology choices and responsibilities +- Local setup and verification commands +- Repository map and public API surface +- Release, rollback, privacy and security boundaries +- Measured 257 → 500 → 1000+ roadmap + +## Constraints and acceptance criteria + +- No new runtime dependency. +- No invented coverage, freshness or accuracy claim. +- Preserve server rendering and progressive enhancement. +- New links are locale-aware and keyboard accessible. +- Layout remains usable at 320 px and honours reduced motion. +- Lint, typecheck, unit/data validation, production build and browser smoke tests pass. diff --git a/src/app/[locale]/favorites/page.tsx b/src/app/[locale]/favorites/page.tsx index f4a4400..93c33de 100644 --- a/src/app/[locale]/favorites/page.tsx +++ b/src/app/[locale]/favorites/page.tsx @@ -6,5 +6,12 @@ import { getTodayDate } from '@/lib/data/freshness' import { getCatalogData } from '@/lib/data/load' import { pageMetadata, requireLocale } from '@/lib/site' -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.favorites.title, m.favorites.intro, 'favorites') } +export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { + const locale = requireLocale((await params).locale) || 'en' + const messages = getMessages(locale) + return { + ...pageMetadata(locale, messages.favorites.title, messages.favorites.intro, 'favorites'), + robots: { index: false, follow: true }, + } +} export default async function FavoritesPage({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale); if (!locale) notFound(); const messages = getMessages(locale); const data = await getCatalogData(); return <>
} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index bbe8af2..980643d 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -36,7 +36,7 @@ export default async function LocaleLayout({ children, params }: Readonly<{ chil return -
{children}
+
{children}
}) { + const value = (await params).locale + const locale = isPublicLocale(value) ? value : 'en' + const messages = getMessages(locale) + + return new ImageResponse( +
+
+
+
+ +
+
+
+
+
{messages.brand}
+
{messages.shell.brandTagline}
+
+
+ +
+
+ + {messages.home.eyebrow} +
+
{messages.home.title}
+
+ +
+ {messages.common.officialSource} · {messages.common.lastVerified} + studyinchina.vercel.app +
+
+
, + size, + ) +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index cde01f3..d234501 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation' import { Badge, Card, LinkButton, PageHero, SectionHeading } from '@/components/ui' import { CityConstellation } from '@/components/features/CityConstellation' import { UniversityCard } from '@/components/features/RecordCards' +import { getHomeExperienceCopy } from '@/i18n/home-experience' import { getMessages } from '@/i18n/messages' import { localize } from '@/lib/data/format' import { classifyProgramField, programFieldTaxonomy } from '@/lib/data/fields' @@ -17,11 +18,17 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale); if (!locale) notFound() - const messages = getMessages(locale); const data = await getCatalogData() + const messages = getMessages(locale); const experience = getHomeExperienceCopy(locale); const data = await getCatalogData() const featured = data.universities.filter((item) => item.featured).slice(0, 6) const fieldsByUniversity = Object.fromEntries(data.universities.map((university) => [university.id, [...new Set(data.programs.filter((program) => program.universityId === university.id).map(classifyProgramField))]])) const fields = programFieldTaxonomy(locale) const fieldCounts = Object.fromEntries(fields.map(({ key }) => [key, data.programs.filter((program) => classifyProgramField(program) === key).length])) + const officialSourceCount = data.sources.filter((source) => source.official).length + const latestSourceCheck = data.sources.reduce((latest, source) => !latest || source.accessedAt > latest ? source.accessedAt : latest, null) + const sourceCheckLabel = latestSourceCheck + ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${latestSourceCheck}T00:00:00Z`)) + : messages.common.unknown + const pathwayHrefs = ['universities', 'programs', 'data-policy', 'guides'] as const return <> {data.universities.length} {messages.nav.universities}{data.programs.length} {messages.nav.programs}{data.cities.length} {messages.nav.cities}{data.scholarships.length} {messages.nav.scholarships}} aside={
{messages.common.officialSource}

{messages.home.sourceHeading}

{messages.common.authoritativeNotice}

{messages.footer.dataPolicy} →
} /> +
+
+
+
{experience.catalogEyebrow}
+

{experience.catalogTitle}

+

{experience.catalogIntro}

+
+