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
23 changes: 23 additions & 0 deletions apps/petrinaut-docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,29 @@ Copying sidesteps that, and matches what an embedding host does anyway.
The copied directories (`src/content/docs`, `src/content/diagrams`) are
gitignored, as is the bundle they come from. Nothing generated is versioned.

## Preview deployments highlight changes

On a Vercel preview, [`vercel-build.sh`](vercel-build.sh) sets
`PETRINAUT_ARCH_DOCS_DIFF_BASE` to the PR's target branch β€” resolved by
[`scripts/resolve-diff-base.mjs`](scripts/resolve-diff-base.mjs) via the
GitHub API, falling back to `main` β€” so the generator builds the bundle in
diff mode against the right base, and a stacked PR shows only its own delta.

The sidebar badges pages as `new`, `changed` or `removed` (with roll-up counts
on collapsed groups, so a change deep in a subtree is visible from the top),
and changed pages mark their differing blocks β€” green for added, blue for
edited, red collapsed blocks for removed content. Removed pages keep a
struck-through entry linking to a stub that carries the removed source. The
header carries two linked chips saying what is being compared β€” the deployed
PR and its base, each with the commit built
(`src/components/DiffBadges.astro`, resolved at config time and injected as a
compile-time constant).

Set the variable in the Vercel project to pin a different base; production
builds of `main` never diff. What counts as a change (and what noise is
filtered out) is the generator's contract β€” see its README section
"Highlighting changes against a base ref".

## Notes on configuration

Authored pages are optional. When `content/` in the generator package is empty
Expand Down
99 changes: 94 additions & 5 deletions apps/petrinaut-docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import react from "@astrojs/react";
import starlight from "@astrojs/starlight";
import { defineConfig, fontProviders } from "astro/config";

import { resolveDiffCompareContext } from "./src/diff-context";

/**
* Renders the architecture bundle produced by `@local/petrinaut-arch-docs`.
*
Expand All @@ -26,11 +28,74 @@ const manifestPath = fileURLToPath(
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));

/**
* @typedef {{ label: string, link: string }} SidebarLink
* @typedef {{ label: string, collapsed?: boolean, items: SidebarItem[] }} SidebarGroup
* @typedef {{ text: string, variant: "success" | "note" | "danger" }} SidebarBadge
* @typedef {{ label: string, link: string, badge?: SidebarBadge, attrs?: Record<string, string> }} SidebarLink
* @typedef {{ label: string, collapsed?: boolean, items: SidebarItem[], badge?: SidebarBadge }} SidebarGroup
* @typedef {SidebarLink | SidebarGroup} SidebarItem
*/

/**
* Change statuses from a diff build (a preview built against a base ref).
* Empty on a plain build, so everything below renders as before.
*
* @type {Record<string, "added" | "changed" | "removed">}
*/
const diffPages = manifest.diff?.pages ?? {};

const DIFF_BADGES = /** @type {const} */ ({
added: { text: "new", variant: "success" },
changed: { text: "changed", variant: "note" },
removed: { text: "removed", variant: "danger" },
});

/**
* Badge and class for one page's link.
*
* @param {string} slug
*/
const diffLinkProps = (slug) => {
const status = diffPages[slug];
return status === undefined
? {}
: {
badge: DIFF_BADGES[status],
// A data attribute rather than `class`: Starlight already merges
// `attrs.class` into the link's computed class and then re-spreads
// `attrs`, which would emit a second `class` attribute.
attrs: { "data-pnd-diff": status },
};
};

/**
* Roll-up badge for a group: how many pages at or beneath `slug` changed, so
* a collapsed group still shows that something inside it did. Colored by the
* one status when they all match, neutral when mixed.
*
* @param {string} slug
*/
const diffGroupProps = (slug) => {
const statuses = Object.entries(diffPages)
.filter(
([pageSlug]) => pageSlug === slug || pageSlug.startsWith(`${slug}/`),
)
.map(([, status]) => status);

if (statuses.length === 0) {
return {};
}

const uniform = statuses.every((status) => status === statuses[0])
? statuses[0]
: null;

return {
badge: {
text: String(statuses.length),
variant: uniform === null ? "note" : DIFF_BADGES[uniform].variant,
},
};
};

/**
* Builds Starlight's nested sidebar from the manifest.
*
Expand Down Expand Up @@ -154,14 +219,19 @@ const buildSidebar = () => {
// Named "Overview" rather than "<title> overview", matching the
// Architecture group and avoiding a label that repeats the
// group it sits directly beneath.
items: [{ label: "Overview", link }, ...itemsUnder(page.slug)],
items: [
{ label: "Overview", link, ...diffLinkProps(page.slug) },
...itemsUnder(page.slug),
],
...diffGroupProps(page.slug),
}
: { label: page.title, link };
: { label: page.title, link, ...diffLinkProps(page.slug) };
}),
...implied.map((slug) => ({
label: labelFromSlug(slug),
collapsed: true,
items: itemsUnder(slug),
...diffGroupProps(slug),
})),
];
};
Expand All @@ -180,9 +250,14 @@ const buildSidebar = () => {
label: "Architecture",
collapsed: false,
items: [
{ label: "Overview", link: `/${architectureRoot.slug}` },
{
label: "Overview",
link: `/${architectureRoot.slug}`,
...diffLinkProps(architectureRoot.slug),
},
...itemsFrom(pages)(architectureRoot.slug),
],
...diffGroupProps(architectureRoot.slug),
},
]
: []),
Expand All @@ -200,9 +275,23 @@ const hasAuthoredIndex = manifest.pages.some(
(page) => page.kind === "authored" && page.slug === "index",
);

/**
* Resolved here rather than in the component that renders it: this file
* already owns reading the manifest, and after bundling a component has no
* stable relative path to the bundle. Injected as a compile-time constant
* below (`vite.define`).
*/
const diffCompareContext = await resolveDiffCompareContext(manifest);

export default defineConfig({
site: "https://docs.petrinaut.org",

vite: {
define: {
__PND_DIFF_COMPARE__: JSON.stringify(diffCompareContext),
},
},

...(hasAuthoredIndex ? {} : { redirects: { "/": "/architecture" } }),

// The bundle's inter-page links are relative and assume slugs map to URLs
Expand Down
69 changes: 69 additions & 0 deletions apps/petrinaut-docs/scripts/resolve-diff-base.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Prints the ref a preview build should highlight changes against.
*
* This is the policy half of the diff feature: *which* ref to compare with is
* a deployment question, so it lives here in the CI wrapper, while turning a
* ref into a comparable tree is the generator's job (`--diff-base`).
*
* For a pull-request deployment the base is the PR's target branch, so a
* stacked PR shows only its own delta rather than the whole stack's diff
* against `main`. Vercel does not expose the target branch, so it is read
* from the GitHub API β€” anonymously, which works because the repository is
* public. Anything short of a definite answer falls back to `main`, and a
* non-preview build prints nothing at all.
*/

/** @param {string} ref */
const out = (ref) => process.stdout.write(`${ref}\n`);

const preset = process.env.PETRINAUT_ARCH_DOCS_DIFF_BASE?.trim();
if (preset) {
out(preset);
process.exit(0);
}

if (process.env.VERCEL_ENV !== "preview") {
process.exit(0);
}

const {
VERCEL_GIT_REPO_OWNER,
VERCEL_GIT_REPO_SLUG,
VERCEL_GIT_PULL_REQUEST_ID,
} = process.env;

const wellFormed = /^[\w.-]+$/u;
const prNumber = /^\d+$/u.test(VERCEL_GIT_PULL_REQUEST_ID ?? "")
? VERCEL_GIT_PULL_REQUEST_ID
: null;

if (
prNumber === null ||
!wellFormed.test(VERCEL_GIT_REPO_OWNER ?? "") ||
!wellFormed.test(VERCEL_GIT_REPO_SLUG ?? "")
) {
// A preview without a PR (e.g. a branch deployment) has no target branch.
out("main");
process.exit(0);
}

try {
const response = await fetch(
`https://api.github.com/repos/${VERCEL_GIT_REPO_OWNER}/${VERCEL_GIT_REPO_SLUG}/pulls/${prNumber}`,
{
headers: { accept: "application/vnd.github+json" },
signal: AbortSignal.timeout(10_000),
},
);
const baseRef = response.ok ? (await response.json()).base?.ref : undefined;
// Branch-name shaped only: the value becomes an environment variable that
// ends up on a git command line, so anything surprising falls back.
out(
typeof baseRef === "string" && /^[\w][\w.@+/-]*$/u.test(baseRef)
? baseRef
: "main",
);
} catch {
// Rate limiting or a network failure: `main` still gives a useful diff.
out("main");
}
38 changes: 38 additions & 0 deletions apps/petrinaut-docs/src/components/DiffBadges.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
/**
* On a diff build, two linked chips in the header say what is being compared:
* the deployed PR (or branch) and the base it is diffed against, each with the
* commit built. The arrow reads in the merge direction. Hidden entirely on a
* plain build, and on viewports too narrow to carry them.
*
* The context is injected by `astro.config.mjs` as a compile-time constant;
* see `src/diff-context.ts`.
*/

import type { DiffCompareContext } from "../diff-context";

declare const __PND_DIFF_COMPARE__: DiffCompareContext | null | undefined;

const context: DiffCompareContext | null =
typeof __PND_DIFF_COMPARE__ === "undefined"
? null
: (__PND_DIFF_COMPARE__ ?? null);
---

{
context !== null && (
<span class="pnd-diff-compare">
<a class="pnd-diff-chip" href={context.head.href} title={context.head.title}>
<span class="pnd-diff-chip-name">{context.head.label}</span>
{context.head.sha !== null && <code>{context.head.sha}</code>}
</a>
<span class="pnd-diff-compare-arrow" aria-hidden="true">
β†’
</span>
<a class="pnd-diff-chip" href={context.base.href} title={context.base.title}>
<span class="pnd-diff-chip-name">{context.base.label}</span>
{context.base.sha !== null && <code>{context.base.sha}</code>}
</a>
</span>
)
}
12 changes: 9 additions & 3 deletions apps/petrinaut-docs/src/components/SiteTitle.astro
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
---
import Default from "@astrojs/starlight/components/SiteTitle.astro";

import DiffBadges from "./DiffBadges.astro";

/**
* Adds the desktop sidebar collapse toggle and resize handle beside the title.
* Adds the desktop sidebar collapse toggle and resize handle beside the title,
* and β€” on diff builds β€” the badges saying what is being compared.
*
* They hang off `SiteTitle` because it is the header's leftmost slot, next to
* the rail's edge where the controls act, and the fixed header is the only
* chrome still on screen once the sidebar pane itself is collapsed away. Both
* are display: none unless the page has a sidebar (see `chrome.css`), so the
* splash-template 404 page does not grow controls with nothing to control.
* controls are display: none unless the page has a sidebar (see `chrome.css`),
* so the splash-template 404 page does not grow controls with nothing to
* control.
*/
---

Expand Down Expand Up @@ -36,6 +40,8 @@ import Default from "@astrojs/starlight/components/SiteTitle.astro";

<Default><slot /></Default>

<DiffBadges />

<div
class="pnd-sidebar-resize"
role="separator"
Expand Down
Loading
Loading