Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 109 additions & 67 deletions src/components/pages/ChangelogPage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ const products = ["lite", "enterprise", "core"] as const;
const count = (id: string) => (id === "all" ? entries.length : entries.filter((e) => e.product === id).length);
const filters = [{ id: "all", label: c.all }, ...products.map((id) => ({ id, label: c.filters[id] }))];

// The latest release of each product; its tile leads to its notes on this page when it has any.
const latest = products
.map((id) => entries.find((e) => e.product === id))
.filter((e): e is ChangelogEntry => Boolean(e));
.filter((e): e is ChangelogEntry => Boolean(e))
.map((e) => ({ ...e, onPage: Boolean(e.notes || e.releaseNotes) || !e.url }));

// Newest month first; releases keep their newest-first order inside it.
const months: { key: string; label: string; items: ChangelogEntry[] }[] = [];
Expand All @@ -62,12 +64,12 @@ for (const e of entries) {
<h2 class="sr-only">{c.latest}</h2>
<div class="cl-latest nx-rise" style="--d:3">
{latest.map((e) => (
<a href={e.url ?? `#${e.id}`} target={e.url ? "_blank" : undefined} rel={e.url ? "noopener" : undefined} class="nx-tile cl-latest-tile">
<a href={e.onPage ? `#${e.id}` : e.url} target={e.onPage ? undefined : "_blank"} rel={e.onPage ? undefined : "noopener"} class="nx-tile cl-latest-tile">
<span class="cl-prod" data-product={e.product}>{releaseProductName(e.product, lang)}</span>
<span class="cl-latest-tag">{e.tag}</span>
<span class="cl-latest-foot">
<time datetime={e.date.toISOString()}>{fmtFull.format(e.date)}</time>
{e.url && <span class="cl-gh">{c.github} ↗</span>}
<span class="cl-gh">{e.onPage ? `${c.readNotes} ↓` : `${c.github} ↗`}</span>
</span>
</a>
))}
Expand All @@ -92,38 +94,45 @@ for (const e of entries) {
{m.items.map(async (entry) => {
const name = releaseProductName(entry.product, lang);
const notes = entry.notes;
const Content = notes ? (await render(notes)).Content : null;
const published = notes ? undefined : entry.releaseNotes;
const source = notes ?? published;
const Content = source ? (await render(source)).Content : null;
// Core and Enterprise publish their notes in English only
const english = lang === "zh-CN" && published?.data.lang === "en";
// A release with notes opens in place to show them
const Wrap = Content ? "details" : "div";
const Row = Content ? "summary" : "div";
return (
<li id={entry.id} data-product={entry.product} class="changelog-item">
{/* The anchor the release had while the page listed Enterprise alone (#v0.4.0). */}
{entry.legacyId && <span id={entry.legacyId} class="cl-legacy" aria-hidden="true"></span>}
<div class="cl-row">
<time class="cl-date" datetime={entry.date.toISOString()}>{fmt.format(entry.date)}</time>
<span class="cl-prod" data-product={entry.product}>{name}</span>
<a href={`#${entry.id}`} class="cl-tag" aria-label={`${c.linkTo} ${name} ${entry.tag}`}>{entry.tag}</a>
{entry.url ? (
<a href={entry.url} target="_blank" rel="noopener" class="cl-gh-link" aria-label={`${c.githubFull}: ${name} ${entry.tag}`}>
{c.github} ↗
</a>
) : <span />}
</div>

{notes && (
<div class="cl-notes">
<h3 class="cl-notes-title">{notes.data.title}</h3>
{notes.data.highlights && (
<ul class="cl-highlights">
{notes.data.highlights.map((h) => <li>{h}</li>)}
</ul>
)}
{Content && (
<details class="cl-more">
<summary>{c.readNotes}</summary>
<div class="prose-tw cl-prose"><Content /></div>
</details>
)}
</div>
)}
<Wrap class="cl-wrap">
<Row class="cl-row">
<time class="cl-date" datetime={entry.date.toISOString()}>{fmt.format(entry.date)}</time>
<span class="cl-prod" data-product={entry.product}>{name}</span>
<span class="cl-tagcell">
<a href={`#${entry.id}`} class="cl-tag" aria-label={`${c.linkTo} ${name} ${entry.tag}`}>{entry.tag}</a>
{english && <span class="cl-en" title={c.englishOnly}>EN</span>}
</span>
{entry.url ? (
<a href={entry.url} target="_blank" rel="noopener" class="cl-gh-link" aria-label={`${c.githubFull}: ${name} ${entry.tag}`}>
{c.github} ↗
</a>
) : <span />}
<span class:list={["cl-chev", !Content && "cl-chev-none"]} aria-hidden="true"></span>
</Row>
{Content && (
<div class="cl-notes">
{notes && <h3 class="cl-notes-title">{notes.data.title}</h3>}
{notes?.data.highlights && (
<ul class="cl-highlights">
{notes.data.highlights.map((h) => <li>{h}</li>)}
</ul>
)}
<div class="prose-tw cl-prose"><Content /></div>
</div>
)}
</Wrap>
</li>
);
})}
Expand Down Expand Up @@ -319,9 +328,16 @@ for (const e of entries) {
--prod: 168px;
--gap: 20px;
}
.cl-wrap > summary {
list-style: none;
cursor: pointer;
}
.cl-wrap > summary::-webkit-details-marker {
display: none;
}
.cl-row {
display: grid;
grid-template-columns: var(--date) var(--prod) minmax(0, 1fr) 72px;
grid-template-columns: var(--date) var(--prod) minmax(0, 1fr) 72px 14px;
align-items: center;
gap: 4px var(--gap);
min-height: 44px;
Expand All @@ -331,9 +347,44 @@ for (const e of entries) {
transition: background 150ms;
}
.cl-row:hover,
.changelog-item:target .cl-row {
.changelog-item:target .cl-row,
details[open] > .cl-row {
background: rgba(255, 255, 255, 0.03);
}
.cl-tagcell {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.cl-en {
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 4px;
padding: 1px 5px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.06em;
color: var(--color-dim);
}
/* A chevron on the releases that open to show their notes */
.cl-chev {
width: 7px;
height: 7px;
justify-self: center;
border-right: 1.5px solid var(--color-dim);
border-bottom: 1.5px solid var(--color-dim);
transform: translateY(-2px) rotate(45deg);
transition: transform 200ms, border-color 150ms;
}
.cl-row:hover .cl-chev {
border-color: var(--color-text);
}
details[open] > .cl-row .cl-chev {
transform: translateY(2px) rotate(225deg);
}
.cl-chev-none {
visibility: hidden;
}
.changelog-item:target .cl-row {
box-shadow: inset 2px 0 0 rgb(var(--a1));
}
Expand Down Expand Up @@ -367,9 +418,13 @@ for (const e of entries) {
}
@media (max-width: 639px) {
.cl-row {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto 14px;
padding-block: 10px;
}
.cl-chev {
grid-column: 3;
grid-row: 1 / 3;
}
.cl-prod {
grid-column: 1;
grid-row: 1;
Expand All @@ -379,7 +434,7 @@ for (const e of entries) {
grid-row: 1;
justify-self: end;
}
.cl-tag {
.cl-tagcell {
grid-column: 1;
grid-row: 2;
}
Expand All @@ -390,7 +445,7 @@ for (const e of entries) {
}

.cl-notes {
padding: 8px 0 28px;
padding: 12px 0 32px;
}
/* the notes line up under the version */
@media (min-width: 640px) {
Expand All @@ -406,7 +461,7 @@ for (const e of entries) {
color: #fff;
}
.cl-highlights {
margin: 14px 0 0;
margin: 14px 0 20px;
padding: 0;
list-style: none;
display: flex;
Expand All @@ -429,35 +484,6 @@ for (const e of entries) {
border-radius: 99px;
background: linear-gradient(135deg, rgb(var(--a1)), rgb(var(--a2)));
}
.cl-more {
margin-top: 16px;
}
.cl-more summary {
display: inline-flex;
min-height: 40px;
align-items: center;
gap: 6px;
cursor: pointer;
list-style: none;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
transition: color 150ms;
}
.cl-more summary::-webkit-details-marker {
display: none;
}
.cl-more summary::after {
content: "↓";
color: var(--color-dim);
transition: transform 150ms;
}
.cl-more[open] summary::after {
transform: rotate(180deg);
}
.cl-more summary:hover {
color: #fff;
}
.cl-prose {
margin-top: 12px;
font-size: 0.9rem;
Expand All @@ -466,7 +492,11 @@ for (const e of entries) {
margin-top: 1.8em;
font-size: 1.1rem;
}
.cl-prose :global(h2:first-child) {
.cl-prose :global(h3) {
margin-top: 1.6em;
font-size: 1rem;
}
.cl-prose :global(:is(h2, h3, p):first-child) {
margin-top: 0;
}
/* Hide the # anchor link appended by rehypeAutolinkHeadings */
Expand Down Expand Up @@ -497,6 +527,18 @@ for (const e of entries) {
</style>

<script>
// A link to a release (the latest tiles, a shared #anchor) opens its notes.
function openRelease(hash: string) {
const id = decodeURIComponent(hash.slice(1));
const details = id ? document.getElementById(id)?.closest(".changelog-item")?.querySelector("details") : null;
if (details) details.open = true;
}
openRelease(location.hash);
window.addEventListener("hashchange", () => openRelease(location.hash));
document.querySelectorAll<HTMLAnchorElement>('a[href^="#"]').forEach((a) => {
a.addEventListener("click", () => openRelease(a.hash));
});

const list = document.querySelector(".changelog-list");
const buttons = document.querySelectorAll<HTMLButtonElement>("[data-filter-btn]");
if (list && buttons.length) {
Expand Down
15 changes: 14 additions & 1 deletion src/content.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
import { coreDocsLoader } from "./lib/core-docs-loader";
import { releaseNotesLoader } from "./lib/release-notes";

const changelogSchema = z.object({
version: z.string(),
Expand Down Expand Up @@ -53,4 +54,16 @@ const docs_core_synced = defineCollection({
}),
});

export const collections = { changelog, changelog_zh, docs, docs_lite, docs_core, docs_core_synced };
// The notes published with each release on GitHub, shown on the changelog:
// see src/lib/release-notes.ts. Ids are "<lang>/<product>-<tag>".
const release_notes = defineCollection({
loader: releaseNotesLoader(),
schema: z.object({
product: z.enum(["enterprise", "lite", "core"]),
/** The git tag, e.g. "v0.47.0" */
tag: z.string(),
lang: z.enum(["en", "zh-CN"]),
}),
});

export const collections = { changelog, changelog_zh, docs, docs_lite, docs_core, docs_core_synced, release_notes };
6 changes: 4 additions & 2 deletions src/i18n/pages/changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export const changelogCopy = {
githubFull: "View on GitHub",
linkTo: "Link to",
latest: "Latest releases",
readNotes: "Read the full notes",
readNotes: "Read the notes",
englishOnly: "These notes are in English only",
dateLocale: "en-US",
/** A release's date under its month */
dateFormat: { month: "short", day: "numeric" },
Expand All @@ -50,7 +51,8 @@ export const changelogCopy = {
githubFull: "在 GitHub 上查看",
linkTo: "链接到",
latest: "最新版本",
readNotes: "阅读完整说明",
readNotes: "查看说明",
englishOnly: "此版本的说明仅有英文",
dateLocale: "zh-CN",
dateFormat: { month: "long", day: "numeric" },
fullDateFormat: { year: "numeric", month: "long", day: "numeric" },
Expand Down
15 changes: 12 additions & 3 deletions src/lib/changelog.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The changelog: every published release of the three products, read from
// GitHub at build time, with the release notes written for this site (the
// Enterprise releases up to v0.4.0, in src/content/changelog) attached to
// their version. Releases without notes here link to their page on GitHub.
// GitHub at build time, each with its notes: the ones written for this site
// (the Enterprise releases up to v0.4.0, in src/content/changelog) or else the
// ones published with the release on GitHub (src/lib/release-notes.ts).
import { getCollection, type CollectionEntry } from "astro:content";
import { getProduct, productName } from "~/content/docs/_meta";
import type { Lang } from "~/i18n";
Expand All @@ -24,6 +24,8 @@ export interface ChangelogEntry {
url?: string;
/** Release notes written for this site */
notes?: CollectionEntry<"changelog"> | CollectionEntry<"changelog_zh">;
/** Otherwise the notes published with the release on GitHub, in the page's language when they exist in it */
releaseNotes?: CollectionEntry<"release_notes">;
}

/** "ThinkWatch Enterprise" / "ThinkWatch 企业版", "ThinkWatch Lite", "ThinkWatch Core" */
Expand All @@ -39,6 +41,12 @@ export async function getChangelog(lang: Lang): Promise<ChangelogEntry[]> {
for (const entry of await getCollection("changelog_zh")) notes.set(entry.data.version, entry);
}

const published = new Map<string, CollectionEntry<"release_notes">>();
for (const entry of await getCollection("release_notes")) {
const key = `${entry.data.product}-${entry.data.tag}`;
if (entry.data.lang === lang || !published.has(key)) published.set(key, entry);
}

const releases = await getReleases();
const entries: ChangelogEntry[] = releases.map((r) => {
const enterprise = r.product === "enterprise";
Expand All @@ -51,6 +59,7 @@ export async function getChangelog(lang: Lang): Promise<ChangelogEntry[]> {
date: new Date(r.date),
url: r.url,
notes: enterprise ? notes.get(version) : undefined,
releaseNotes: published.get(`${r.product}-${r.tag}`),
};
});

Expand Down
Loading
Loading