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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:e2e:report": "playwright show-report",
"test:server": "node --test tests/server.test.mjs"
"test:server": "node --test tests/server.test.mjs",
"test:changelog": "node --test tests/changelogMdx.test.ts"
},
"dependencies": {
"@alpinejs/collapse": "^3.17.1",
Expand Down
36 changes: 19 additions & 17 deletions src/components/changelog/Card.astro
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function embedGithubVideos(html: string): string {
<ChangelogDot aria-hidden="true" />
</div>
<div class="changelog-entry-nav-content">
{entry.product && <span class="changelog-entry-nav-product">{entry.product}</span>}
<h3 class="changelog-entry-nav-title">{entry.title}</h3>
<div class="changelog-entry-nav-date-row">
<time class="changelog-entry-nav-date">
Expand All @@ -78,6 +79,24 @@ function embedGithubVideos(html: string): string {
year: 'numeric',
})}
</time>
{entry.labels && entry.labels.nodes.length > 0 && (
<div class="changelog-entry-card-tags">
{entry.labels.nodes.map((label) => (
<div class="changelog-entry-card-tag-row">
<span
class={`changelog-entry-card-tag changelog-entry-card-tag--${label.name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`}
>
{label.name}
</span>
{label.description && (
<span class="changelog-entry-card-tag-description">
{label.description}
</span>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
Expand All @@ -98,23 +117,6 @@ function embedGithubVideos(html: string): string {
class="changelog-entry-card-body"
set:html={embedGithubVideos(marked.parse(entry.body) as string)}
/>

{entry.labels && entry.labels.nodes.length > 0 && (
<div class="changelog-entry-card-tags">
{entry.labels.nodes.map((label) => (
<div class="changelog-entry-card-tag-row">
<span
class={`changelog-entry-card-tag changelog-entry-card-tag--${label.name.toLowerCase()}`}
>
{label.name}
</span>
{label.description && (
<span class="changelog-entry-card-tag-description">{label.description}</span>
)}
</div>
))}
</div>
)}
</article>
</div>
))
Expand Down
20 changes: 0 additions & 20 deletions src/content/changelog/0.0.1.md

This file was deleted.

21 changes: 0 additions & 21 deletions src/content/changelog/0.1.0.md

This file was deleted.

27 changes: 0 additions & 27 deletions src/content/changelog/0.1.1.md

This file was deleted.

94 changes: 94 additions & 0 deletions src/libs/changelogMdx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Parses the Mintlify changelog (datum-cloud/docs `changelog.mdx`), the single
// source of truth for both /docs/changelog and the marketing /changelog page.
// The parser throws on any block it cannot read: a format change in the docs
// repo must surface as an error, never as silently missing entries.
import type { ChangelogProps } from './datum';

export const DOCS_CHANGELOG_SOURCE_URL =
'https://raw.githubusercontent.com/datum-cloud/docs/main/changelog.mdx';
export const CHANGELOG_DISCUSSIONS_URL =
'https://github.com/orgs/datum-cloud/discussions/categories/changelog';

const DOCS_BASE_PATH = '/docs';
const UPDATE_OPEN_TAG = /<Update\b/g;
const UPDATE_BLOCK = /<Update\s+([^>]*?)>\r?\n([\s\S]*?)\r?\n\s*<\/Update>/g;
const DISCUSSION_LINK = /https:\/\/github\.com\/orgs\/datum-cloud\/discussions\/\d+/;

function readStringAttr(attrs: string, name: string): string | undefined {
return attrs.match(new RegExp(`${name}="([^"]*)"`))?.[1];
}

function readTags(attrs: string): string[] {
const raw = attrs.match(/tags=\{(\[[^\]]*\])\}/)?.[1];
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed) || !parsed.every((t) => typeof t === 'string')) {
throw new Error(`Invalid changelog tags ${raw}`);
}
return parsed;
}

function dedent(text: string): string {
const lines = text.split('\n');
const indents = lines
.filter((line) => line.trim())
.map((line) => line.match(/^ */)?.[0].length ?? 0);
const minIndent = indents.length ? Math.min(...indents) : 0;
return lines
.map((line) => line.slice(minIndent))
.join('\n')
.trim();
}

function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}

function parseLabelDate(label: string): Date {
// Noon UTC keeps the same calendar day in every server timezone.
const date = new Date(`${label} 12:00:00 UTC`);
if (isNaN(date.getTime())) throw new Error(`Invalid changelog date "${label}"`);
return date;
}

function parseBlock(attrs: string, content: string): ChangelogProps {
const label = readStringAttr(attrs, 'label');
if (!label) throw new Error(`Changelog <Update ${attrs}> is missing a label`);
const createdAt = parseLabelDate(label);

const body = dedent(content);
const heading = body.match(/^## +(.+)$/m);
if (!heading) throw new Error(`Changelog entry "${label}" is missing a "## " heading`);
const title = heading[1].trim();

const bodyWithoutTitle = body
.replace(heading[0], '')
.trim()
.replace(/\]\(\/(?!\/)/g, `](${DOCS_BASE_PATH}/`);

return {
id: `${createdAt.toISOString().slice(0, 10)}-${slugify(title)}`,
title,
body: bodyWithoutTitle,
url: body.match(DISCUSSION_LINK)?.[0] ?? CHANGELOG_DISCUSSIONS_URL,
createdAt,
product: readStringAttr(attrs, 'description'),
labels: { nodes: readTags(attrs).map((name) => ({ name, color: '' })) },
};
}

export function parseDocsChangelog(mdx: string): ChangelogProps[] {
const expected = mdx.match(UPDATE_OPEN_TAG)?.length ?? 0;
if (expected === 0) throw new Error('No <Update> entries found in docs changelog');

const entries = [...mdx.matchAll(UPDATE_BLOCK)].map(([, attrs, content]) =>
parseBlock(attrs, content)
);
if (entries.length !== expected) {
throw new Error(`Parsed ${entries.length} of ${expected} <Update> blocks in docs changelog`);
}
return entries;
}
108 changes: 17 additions & 91 deletions src/libs/datum.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { graph } from '@libs/github';
import { Cache } from '@libs/cache';
import { DOCS_CHANGELOG_SOURCE_URL, parseDocsChangelog } from '@libs/changelogMdx';

const cache = new Cache('.cache');
const owner = 'datum-cloud';
Expand All @@ -20,6 +21,7 @@ type ChangelogProps = {
body: string;
url: string;
createdAt: Date;
product?: string;
labels?: { nodes: Array<{ name: string; color: string; description?: string }> };
};

Expand Down Expand Up @@ -146,104 +148,28 @@ async function roadmaps(): Promise<RoadmapProps[]> {
return roadmaps;
}

// Source of truth is the Mintlify changelog (/docs/changelog), so the
// marketing page always matches the docs. Errors are logged and not cached,
// so the next request retries.
async function changelogs(): Promise<ChangelogProps[]> {
type ResponseProps = {
repository: {
discussions: {
nodes: [
{
id: string;
title: string;
body: string;
url: string;
labels: {
nodes: Array<{
name: string;
color: string;
description?: string;
}>;
};
},
];
};
};
};

const name = 'datum';
let changelogs: ChangelogProps[] = [];
const categoryVariables = {
owner,
name,
slug: 'changelog',
};

if (await cache.has('changelogs')) {
return (await cache.get<ChangelogProps[]>('changelogs')) ?? [];
}
{
try {
const categoryQuery = `
query ($owner:String!, $name:String!, $slug:String!) {
repository(owner: $owner, name: $name) {
discussionCategory(slug: $slug){
id,
}
}
}`;

const categoryResponse = (await graph(categoryQuery, categoryVariables)) as {
repository: { discussionCategory: { id: string } };
};

if (
!categoryResponse ||
!categoryResponse.repository ||
!categoryResponse.repository.discussionCategory
) {
return changelogs;
}

const categoryId = categoryResponse.repository.discussionCategory.id as string;
const query = `
query ($owner:String!, $name:String!, $categoryId:ID!) {
repository(owner: $owner, name: $name) {
discussions(first: 30, after: null, categoryId: $categoryId) {
nodes {
id,
title,
body,
url,
createdAt,
labels (first: 5, last: null) {
nodes {
name,
color,
description,
}
},
}
}
}
}
`;

const variables = {
owner,
name,
categoryId,
};

const response = (await graph(query, variables)) as ResponseProps;
changelogs = Object(response.repository.discussions.nodes).map((log: ChangelogProps) => ({
...log,
}));
await cache.set('changelogs', changelogs, 1000 * 60 * 10); // cache for 10 minutes
} catch {
changelogs = [];
try {
const response = await fetch(DOCS_CHANGELOG_SOURCE_URL, {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status} fetching ${DOCS_CHANGELOG_SOURCE_URL}`);
}
const entries = parseDocsChangelog(await response.text());
await cache.set('changelogs', entries, 1000 * 60 * 20); // cache for 20 minutes
return entries;
} catch (error) {
console.error('Failed to load changelog from docs:', error);
return [];
}

return changelogs;
}

export { stargazerCount, roadmaps, changelogs, type RoadmapProps, type ChangelogProps };
Loading
Loading