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