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
336 changes: 335 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions docs/decisions/0001-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,13 @@ generation and avoids a misleading language selector.
### 4. Preact islands instead of a client-heavy application

The site is not a single-page application. Client-side interactivity is limited
to three small Preact islands: catalogue filtering, release filtering, and a
copy-command button. All meaningful content and links exist in the generated
HTML before hydration; the islands only enhance them. Hydration uses the least
expensive directive that works (`client:visible` / `client:idle`).
to four small Preact islands: catalogue filtering, release filtering, the
sortable/groupable package-version table, and the copy-command install panel.
Everything else — including the rolled-up per-project version snapshot on the
home page — is server-rendered with no client JavaScript. All meaningful content
and links exist in the generated HTML before hydration; the islands only enhance
them. Hydration uses the least expensive directive that works (`client:visible` /
`client:idle`).

### 5. Bun, Just, and Lefthook for the local-first workflow

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "purview-dev",
"version": "0.3.2",
"version": "0.3.3",
"private": true,
"description": "Purview-Dev public website and unified documentation portal (workspace root).",
"license": "MIT",
Expand Down
8 changes: 8 additions & 0 deletions src/astro.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { unified } from '@astrojs/markdown-remark';
import preact from '@astrojs/preact';
import sitemap from '@astrojs/sitemap';
import starlight from '@astrojs/starlight';
Expand All @@ -7,6 +8,7 @@ import starlightLinksValidator from 'starlight-links-validator';
import starlightLlmsTxt from 'starlight-llms-txt';
import starlightSidebarTopics from 'starlight-sidebar-topics';

import { remarkMermaid } from './config/remark-mermaid.mjs';
import { readDocsManifest } from './src/lib/docs/aggregate';
import { buildProjectItems } from './src/lib/docs/sidebar';
import { loadProjects } from './src/lib/manifest/load';
Expand Down Expand Up @@ -84,6 +86,11 @@ export default defineConfig({
format: 'directory',
},
compressHTML: true,
markdown: {
processor: unified({
remarkPlugins: [remarkMermaid],
}),
},
integrations: [
preact(),
sitemap(),
Expand Down Expand Up @@ -126,6 +133,7 @@ export default defineConfig({
},
},
components: {
Head: './src/components/starlight/Head.astro',
Header: './src/components/starlight/Header.astro',
PageFrame: './src/components/starlight/PageFrame.astro',
EditLink: './src/components/starlight/EditLink.astro',
Expand Down
11 changes: 11 additions & 0 deletions src/config/mermaid-icons.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/** Iconify packs loaded only when a Mermaid diagram references one of them. */
export const mermaidIconPacks = [
{
name: 'logos',
url: 'https://unpkg.com/@iconify-json/logos@1/icons.json',
},
{
name: 'iconoir',
url: 'https://unpkg.com/@iconify-json/iconoir@1/icons.json',
},
];
29 changes: 29 additions & 0 deletions src/config/remark-mermaid.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Recursively replace Mermaid code fences with plain `<pre class="mermaid">`
* nodes. Hoisted to module scope so the walker is created once, not per document.
*/
function visit(parent) {
for (const [index, node] of parent.children.entries()) {
if (node.type === 'code' && node.lang === 'mermaid') {
parent.children[index] = {
type: 'paragraph',
children: [],
data: {
hName: 'pre',
hProperties: { className: ['mermaid'] },
hChildren: [{ type: 'text', value: node.value }],
},
position: node.position,
};
} else if (Array.isArray(node.children)) {
visit(node);
}
}
}

/** Turn opted-in Mermaid fences into plain pre elements before code highlighting. */
export function remarkMermaid() {
return (tree) => {
visit(tree);
};
}
2 changes: 2 additions & 0 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"devDependencies": {
"@astrojs/check": "0.9.10",
"@astrojs/markdown-remark": "^7.0.1",
"@astrojs/preact": "6.0.5",
"@astrojs/sitemap": "3.7.4",
"@astrojs/starlight": "0.42.3",
Expand All @@ -40,6 +41,7 @@
"astro": "7.3.4",
"fast-glob": "3.3.3",
"lefthook": "2.1.14",
"mermaid": "^11.16.1",
"oxfmt": "0.68.0",
"oxlint": "1.83.0",
"semver": "7.8.5",
Expand Down
57 changes: 55 additions & 2 deletions src/scripts/check-generated.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { glob } from 'fast-glob';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';

import { DOCS_MANIFEST_SCHEMA, DOCS_CACHE_DIR } from '../src/lib/docs/aggregate';
import {
DOCS_CACHE_DIR,
DOCS_MANIFEST_SCHEMA,
DOCS_OUTPUT_DIR,
readDocsManifest,
} from '../src/lib/docs/aggregate';
import { isReleaseCache, readReleaseCache } from '../src/lib/releases/cache';

const DIST = resolve('dist');
Expand Down Expand Up @@ -49,6 +54,53 @@ function validateDocsManifest(): void {
}
}

/**
* The docs manifest drives the Starlight sidebar while the generated mirror
* under `src/content/docs/` provides the pages it links to. They are written by
* the same sync, so a mismatch means the sync was interrupted — Starlight would
* then fail with `The slug "docs/<project>/<page>" specified in the Starlight
* sidebar config does not exist`.
*/
function validateDocsMirror(): void {
const manifest = readDocsManifest();
if (!manifest) {
console.warn(' (no docs manifest present — skipping docs mirror validation)');
return;
}
const mirrorRoot = join(DOCS_OUTPUT_DIR, 'docs');
if (!existsSync(mirrorRoot)) {
console.warn(' (no docs mirror present — skipping docs mirror validation)');
return;
}

const expected = new Set<string>();
for (const project of manifest.projects) {
for (const page of project.pages) {
expected.add(`${project.projectId}/${page.slug}`);
if (!existsSync(join(mirrorRoot, project.projectId, `${page.slug}.md`))) {
fail(
`Docs manifest lists a page with no mirror file: docs/${project.projectId}/${page.slug}. ` +
'The Starlight sidebar would link to a missing page — re-run `just data-sync`.',
);
}
}
}

for (const entry of readdirSync(mirrorRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
for (const page of readdirSync(join(mirrorRoot, entry.name))) {
if (page.endsWith('.md') && !expected.has(`${entry.name}/${page.slice(0, -3)}`)) {
fail(
`Docs mirror page is missing from the manifest: docs/${entry.name}/${page}. ` +
'Re-run `just data-sync` so the sidebar and content agree.',
);
}
}
}
}

function validateReleaseCache(): void {
const cached = readReleaseCache();
if (!cached) {
Expand Down Expand Up @@ -100,6 +152,7 @@ async function scanForSecrets(): Promise<void> {
async function run(): Promise<number> {
console.log('Checking generated data and build outputs...');
validateDocsManifest();
validateDocsMirror();
validateReleaseCache();

if (!existsSync(DIST)) {
Expand Down
14 changes: 14 additions & 0 deletions src/src/components/MermaidDiagram.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
interface Props {
diagram: string;
label: string;
caption?: string;
}

const { diagram, label, caption } = Astro.props;
---

<figure class="my-6">
<pre class="mermaid" role="img" aria-label={label} set:text={diagram}></pre>
{caption && <figcaption class="-mt-4 text-center text-sm text-muted">{caption}</figcaption>}
</figure>
6 changes: 3 additions & 3 deletions src/src/components/ProjectCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ const badges =

<div class="pv-card pv-card-interactive flex flex-col gap-3 p-5">
<a href={withBase(`/projects/${project.id}/`)} class="flex flex-1 flex-col gap-3 no-underline">
<div class="flex items-start justify-between gap-3">
<h3 class="text-lg font-semibold leading-snug">{project.name}</h3>
<StatusBadge status={project.status} />
<div class="flex items-center justify-between gap-3">
<h3 class="min-w-0 text-lg font-semibold leading-snug">{project.name}</h3>
<StatusBadge status={project.status} class="shrink-0" />
</div>
<p class="text-sm text-muted">{project.shortDescription}</p>
<div class="mt-auto flex flex-wrap items-center gap-2 pt-2">
Expand Down
136 changes: 136 additions & 0 deletions src/src/components/ProjectVersionSnapshot.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
---
import StatusBadge from '~/components/StatusBadge.astro';
import type { ProjectVersionRollup } from '~/lib/releases/transform';
import { withBase } from '~/lib/urls';

interface Props {
/** One row per active project, rolled up from that project's NuGet packages. */
rows: ProjectVersionRollup[];
}

const { rows } = Astro.props;

function formatDate(value: string | null): string {
if (!value) {
return 'Unknown date';
}
const date = new Date(value);
return Number.isNaN(date.getTime())
? 'Unknown date'
: date.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric' });
}

/**
* Explanation for a blanked prerelease cell (or for packages that trail the
* headline version), so the suppressed detail stays discoverable on hover.
*/
function prereleaseTooltip(row: ProjectVersionRollup): string | undefined {
if (row.prereleaseVersion) {
return row.laggingPackageCount > 0
? `${row.laggingPackageCount} of ${row.packageCount} packages are not on this version`
: undefined;
}
if (row.suppressedPrerelease && row.stableVersion) {
return `Prerelease ${row.suppressedPrerelease} is older than the stable release ${row.stableVersion}`;
}
return undefined;
}

function projectTooltip(row: ProjectVersionRollup): string | undefined {
return row.laggingPackageCount > 0
? `${row.laggingPackageCount} of ${row.packageCount} packages are on older versions`
: undefined;
}
---

{
rows.length === 0 ? (
<p class="border-border bg-surface text-muted rounded-lg border p-6">
No release data is currently available.
</p>
) : (
<div class="pv-card overflow-hidden overflow-x-auto">
<table class="w-full min-w-[44rem] text-left text-sm">
<thead class="border-border bg-surface/80 border-b backdrop-blur">
<tr>
<th scope="col" class="px-4 py-3 font-semibold">
Project
</th>
<th scope="col" class="px-4 py-3 font-semibold">
Stable
</th>
<th scope="col" class="px-4 py-3 font-semibold">
Prerelease
</th>
<th scope="col" class="px-4 py-3 font-semibold">
Latest release
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
class="border-border hover:bg-surface/60 border-b last:border-0"
data-project-id={row.projectId}
data-project-version={row.stableVersion ?? row.prereleaseVersion ?? ''}
>
<td class="px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<a href={withBase(`/projects/${row.projectId}/`)} class="pv-link font-medium">
{row.projectName}
</a>
<StatusBadge status={row.status} />
<span class="pv-chip" title={projectTooltip(row)}>
{row.packageCount} package{row.packageCount === 1 ? '' : 's'}
</span>
</div>
</td>
<td class="px-4 py-3">
{row.stableVersion ? (
<span class="border-success/30 bg-success/10 text-success inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-xs">
{row.stableVersion}
</span>
) : (
<span class="text-muted text-xs">—</span>
)}
</td>
<td class="px-4 py-3">
{row.prereleaseVersion ? (
<span
class="border-warning/30 bg-warning/10 text-warning inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-xs"
title={prereleaseTooltip(row)}
>
{row.prereleaseVersion}
</span>
) : (
<span class="text-muted text-xs" title={prereleaseTooltip(row)}>
—
</span>
)}
</td>
<td class="text-muted px-4 py-3 text-xs">
{row.latestRelease ? (
<a
href={row.latestRelease.htmlUrl}
rel="noopener noreferrer"
target="_blank"
class="pv-link font-mono whitespace-nowrap"
>
{row.latestRelease.tagName}
</a>
) : (
<span class="text-muted">—</span>
)}
{row.latestRelease?.publishedAt && (
<span class="ml-2 whitespace-nowrap">
{formatDate(row.latestRelease.publishedAt)}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
6 changes: 4 additions & 2 deletions src/src/components/StatusBadge.astro
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
---
interface Props {
status: 'stable' | 'preview' | 'archived';
/** Extra classes, e.g. `shrink-0` when the badge shares a row with a title. */
class?: string;
}

const { status } = Astro.props;
const { status, class: className } = Astro.props;
const label = { stable: 'Stable', preview: 'Preview', archived: 'Archived' }[status];
---

<span class:list={['pv-chip', `pv-status-${status}`]}>{label}</span>
<span class:list={['pv-chip', `pv-status-${status}`, className]}>{label}</span>
Loading
Loading