返回 CodeWhale
page.tsx
根目录 / web / app / [locale] / changelog / page.tsx
1 import Link from "next/link";
2 import { EmptyState } from "@/components/surface-state";
3 import { CHANGELOG, type ChangelogRelease } from "@/lib/changelog.generated";
4 import { changelogAnchor } from "@/scripts/changelog-lib.mjs";
5 import { getFacts } from "@/lib/facts";
6 import { fill, getChangelog, getChrome } from "@/lib/i18n/dictionaries";
7 import { REPO_RELEASES_URL, REPO_URL } from "@/lib/i18n/links";
8 import { buildPageMetadata } from "@/lib/page-meta";
9
10 // The two headline facts come from the facts layer, which may be refreshed
11 // from the KV snapshot after deploy; the notes are build-time from CHANGELOG.md.
12 export const revalidate = 300;
13
14 export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
15 const { locale } = await params;
16 const t = getChangelog(locale);
17 return buildPageMetadata({
18 path: "/changelog",
19 locale,
20 title: t.metaTitle,
21 description: t.metaDescription,
22 });
23 }
24
25 function formatDate(iso: string, dateLocale: string): string {
26 return new Date(iso).toLocaleDateString(dateLocale, {
27 year: "numeric",
28 month: "short",
29 day: "numeric",
30 // Dates come from ISO timestamps and YYYY-MM-DD headings; render them
31 // as the UTC day they name, not the server's local day.
32 timeZone: "UTC",
33 });
34 }
35
36 export default async function ChangelogPage({ params }: { params: Promise<{ locale: string }> }) {
37 const { locale } = await params;
38 const t = getChangelog(locale);
39 const chrome = getChrome(locale);
40 const facts = await getFacts();
41 const published = facts.latestPublishedRelease;
42 const candidate = facts.version;
43 const candidateIsPublished = published !== null && candidate !== null && published.version === candidate;
44
45 return (
46 <div className="portal-home">
47 <section className="hero">
48 <div className="portal-current" aria-hidden="true" />
49 <div className="portal-container community-welcome-inner">
50 <div className="eyebrow">{t.kicker}</div>
51 <h1>{t.title}</h1>
52 <p>{t.lead}</p>
53
54 <div className="changelog-facts">
55 <div data-published={published ? "true" : "false"}>
56 <span>{t.publishedLabel}</span>
57 {published ? (
58 <>
59 <strong>
60 {fill(t.publishedValue, {
61 tag: published.tag,
62 date: formatDate(published.publishedAt, chrome.dateLocale),
63 })}
64 </strong>
65 <a href={published.url} target="_blank" rel="noreferrer">
66 {t.releasePageLink}
67 </a>
68 </>
69 ) : (
70 <a href={REPO_RELEASES_URL} target="_blank" rel="noreferrer">
71 {t.releasesLink}
72 </a>
73 )}
74 </div>
75 <div data-candidate={candidateIsPublished ? "published" : "unreleased"}>
76 <span>{t.candidateLabel}</span>
77 {candidate && (
78 <strong>
79 {candidateIsPublished
80 ? fill(t.candidateMatches, { version: `v${candidate}` })
81 : fill(t.candidateValue, { version: `v${candidate}` })}
82 </strong>
83 )}
84 <a href={`${REPO_URL}/blob/main/CHANGELOG.md`} target="_blank" rel="noreferrer">
85 {t.fullNotes}
86 </a>
87 </div>
88 </div>
89 </div>
90 </section>
91
92 <section className="portal-section">
93 <div className="portal-container" style={{ maxWidth: "56rem" }}>
94 {CHANGELOG.length === 0 ? (
95 <EmptyState
96 locale={locale}
97 title={t.emptyTitle}
98 body={t.emptyBody}
99 action={
100 <a className="portal-button portal-button-secondary" href={REPO_RELEASES_URL}>
101 {t.releasesLink}
102 </a>
103 }
104 />
105 ) : (
106 CHANGELOG.map((release) => (
107 <Release
108 key={release.version}
109 release={release}
110 locale={locale}
111 publishedUrl={
112 published && published.version === release.version ? published.url : null
113 }
114 />
115 ))
116 )}
117 <p className="mt-8 text-sm text-ink-mute">
118 <Link href={`/${locale}/docs`} className="body-link">
119 {chrome.footerDocs}
120 </Link>
121 {" · "}
122 <a href={REPO_RELEASES_URL} target="_blank" rel="noreferrer" className="body-link">
123 {t.releasesLink}
124 </a>
125 </p>
126 </div>
127 </section>
128 </div>
129 );
130 }
131
132 function Release({
133 release,
134 locale,
135 publishedUrl,
136 }: {
137 release: ChangelogRelease;
138 locale: string;
139 publishedUrl: string | null;
140 }) {
141 const t = getChangelog(locale);
142 const chrome = getChrome(locale);
143 const heading = release.unreleased ? t.unreleasedHeading : `v${release.version}`;
144 const anchor = release.unreleased ? "unreleased" : `v${release.version}`;
145 // Every entry here is clipped or capped for the web; this is the in-page
146 // path to the unabridged notes for exactly this version.
147 const notesUrl = `${REPO_URL}/blob/main/CHANGELOG.md#${changelogAnchor(release)}`;
148
149 return (
150 <article id={anchor} className="changelog-release scroll-mt-32">
151 <div className="changelog-release-head">
152 <h2>{heading}</h2>
153 <div className="dotline">
154 {release.date && <span className="tabular">{formatDate(release.date, chrome.dateLocale)}</span>}
155 {publishedUrl && (
156 <a href={publishedUrl} target="_blank" rel="noreferrer">
157 {t.releasePageLink}
158 </a>
159 )}
160 {release.compareUrl && (
161 <a href={release.compareUrl} target="_blank" rel="noreferrer">
162 {t.compareLink}
163 </a>
164 )}
165 <a href={notesUrl} target="_blank" rel="noreferrer">
166 {fill(t.releaseNotesLink, { version: heading })}
167 </a>
168 </div>
169 </div>
170 {release.unreleased && <p className="changelog-unreleased-note">{t.unreleasedNote}</p>}
171 <div className="changelog-sections">
172 {release.sections.map((section, i) => (
173 <section key={`${section.heading}-${i}`}>
174 <h3>
175 {section.heading}
176 {section.itemCount > section.items.length && (
177 <a href={notesUrl} target="_blank" rel="noreferrer">
178 {fill(t.moreEntries, { shown: section.items.length, total: section.itemCount })}
179 </a>
180 )}
181 </h3>
182 <ul>
183 {section.items.map((item, j) => (
184 <li key={j}>{item}</li>
185 ))}
186 </ul>
187 </section>
188 ))}
189 </div>
190 </article>
191 );
192 }
193
193 lines Plain Text