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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,17 @@ changesets automation publishes `vX.Y.Z-prerelease.N` tags with the GitHub

The build generates `/llms.txt`, `/llms-small.txt`, and `/llms-full.txt`
(Starlight's `starlight-llms-txt` plugin) using canonical production URLs and
the aggregated documentation. The built outputs are asserted by `tests/dist/`
and `just check-generated`.
the aggregated documentation.

Each documented project is also emitted as its own scoped bundle at
`/_llms-txt/<project-id>.txt` via the plugin's `customSets` option. The project
page, the project's documentation overview page, and the documentation portal
all link to that path, and `/llms.txt` lists every bundle under
`Documentation Sets`. Because `rawContent` is enabled, each bundle is the raw
aggregated Markdown for that project (the same content that feeds
`llms-full.txt`, scoped down).

The built outputs are asserted by `tests/dist/` and `just check-generated`.

## Site versioning and releases

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.3",
"version": "0.3.4",
"private": true,
"description": "Purview-Dev public website and unified documentation portal (workspace root).",
"license": "MIT",
Expand Down
16 changes: 15 additions & 1 deletion src/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@ const projects = loadProjects();
const docsManifest = readDocsManifest();

const docsProjects = projects.filter((project) => project.docs && project.status !== 'archived');
/**
* Per-project LLM bundles. `starlight-llms-txt` emits one file per `customSets`
* entry at `/_llms-txt/<slug>.txt`, where the slug is `github-slugger`'s slug of
* the set label. Every documented project's display name slugifies to exactly
* its project id, so `docs/<project.id>/**` maps to `/_llms-txt/<project.id>.txt`
* and the UI links straight to that path. The build's link crawl (and the dist
* tests) fail if a future project name breaks that correspondence.
*/
const docsSets = docsProjects.map((project) => ({
label: project.name,
description: project.shortDescription,
paths: [`docs/${project.id}/**`],
}));
const previewBadge = { text: 'Preview', variant: 'caution' } as const;
const sidebarTopics = [
{ label: 'Documentation home', link: '/docs/' },
Expand Down Expand Up @@ -180,9 +193,10 @@ export default defineConfig({
description: 'GitHub and NuGet release information.',
},
],
promote: ['index*'],
promote: ['index*', 'docs/*/index'],
demote: [],
exclude: ['dotnet-logging-source-generators/**'],
customSets: docsSets,
minify: {
note: true,
tip: true,
Expand Down
30 changes: 30 additions & 0 deletions src/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DOCS_OUTPUT_DIR,
readDocsManifest,
} from '../src/lib/docs/aggregate';
import { loadProjects } from '../src/lib/manifest/load';
import { isReleaseCache, readReleaseCache } from '../src/lib/releases/cache';

const DIST = resolve('dist');
Expand Down Expand Up @@ -124,6 +125,33 @@ function requireDistFile(file: string): void {
}
}

/**
* Per-project `llms.txt` bundles are emitted by `starlight-llms-txt`'s
* `customSets` option, one file per documented project at
* `/_llms-txt/<project-id>.txt`. The project pages, the project documentation
* overview, and the documentation portal all link to these paths, so a missing
* bundle is a broken link — and the link crawl only sees the pages that link it.
*/
function validateProjectLlmsBundles(): void {
const projects = loadProjects().filter(
(project) => project.docs && project.status !== 'archived',
);
for (const project of projects) {
requireDistFile(`_llms-txt/${project.id}.txt`);
}

const entrypointPath = resolve(DIST, 'llms.txt');
if (!existsSync(entrypointPath)) {
return;
}
const entrypoint = readFileSync(entrypointPath, 'utf8');
for (const project of projects) {
if (!entrypoint.includes(`/_llms-txt/${project.id}.txt`)) {
fail(`llms.txt does not link the per-project bundle for "${project.id}".`);
}
}
}

async function scanForSecrets(): Promise<void> {
const files = await glob('**/*', { cwd: DIST, onlyFiles: true });
for (const file of files) {
Expand Down Expand Up @@ -166,6 +194,8 @@ async function run(): Promise<number> {
requireDistFile('sitemap-index.xml');
requireDistFile('robots.txt');

validateProjectLlmsBundles();

const llmsFull = readFileSync(resolve(DIST, 'llms-full.txt'), 'utf8');
const llms = readFileSync(resolve(DIST, 'llms.txt'), 'utf8');
if (
Expand Down
17 changes: 14 additions & 3 deletions src/src/components/SiteFooter.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import SiteBrand from '~/components/SiteBrand.astro';
import { NAV_ITEMS } from '~/components/SiteNav.astro';
import { LLMS_LINK_ATTRS } from '~/lib/llms';
import { loadProjects } from '~/lib/manifest/load';
import { getReleaseInfo } from '~/lib/site-version';
import { OWNER, SITE } from '~/lib/site';
Expand Down Expand Up @@ -76,9 +77,19 @@ const releasesUrl = `${SITE.githubUrl}/${OWNER}.github.io/releases`;
<nav aria-label="For LLMs">
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted">For LLMs</h2>
<ul class="mt-3 list-none space-y-2 text-sm">
<li><a href={withBase('/llms.txt')} class="pv-link">llms.txt</a></li>
<li><a href={withBase('/llms-small.txt')} class="pv-link">llms-small.txt</a></li>
<li><a href={withBase('/llms-full.txt')} class="pv-link">llms-full.txt</a></li>
<li>
<a href={withBase('/llms.txt')} {...LLMS_LINK_ATTRS} class="pv-link">llms.txt</a>
</li>
<li>
<a href={withBase('/llms-small.txt')} {...LLMS_LINK_ATTRS} class="pv-link">
llms-small.txt
</a>
</li>
<li>
<a href={withBase('/llms-full.txt')} {...LLMS_LINK_ATTRS} class="pv-link">
llms-full.txt
</a>
</li>
</ul>
</nav>
</div>
Expand Down
58 changes: 49 additions & 9 deletions src/src/components/SiteNav.astro
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
---
import { withBase } from '~/lib/urls';

export const NAV_ITEMS = [
type NavItem = {
label: string;
href: string;
/**
* Optional icon for the item. In the horizontal nav an icon item renders as a
* compact icon-only button; the vertical (mobile) menu keeps the label so the
* list does not leave a lone glyph dangling next to the text items.
*/
icon?: 'home';
};

export const NAV_ITEMS: readonly NavItem[] = [
{ label: 'Home', href: '/', icon: 'home' },
{ label: 'Projects', href: '/projects/' },
{ label: 'Documentation', href: '/docs/' },
{ label: 'Releases', href: '/releases/' },
{ label: 'About', href: '/about/' },
] as const;
];

interface Props {
currentPath: string;
Expand All @@ -25,30 +37,58 @@ function isActive(href: string): boolean {
---

<ul
class:list={[
'list-none',
vertical ? 'flex flex-col gap-1' : 'hidden items-center gap-1 lg:flex',
]}
class:list={['list-none', vertical ? 'flex flex-col gap-1' : 'hidden items-center gap-1 lg:flex']}
>
{
NAV_ITEMS.map((item) => {
const active = isActive(withBase(item.href));
// Icon items collapse to an icon-only button in the horizontal nav, using
// tighter horizontal padding than the text items. The visible label moves
// into `aria-label`/`title` so the control stays accessible.
const iconOnly = item.icon !== undefined && !vertical;
return (
<li>
<a
href={withBase(item.href)}
aria-current={active ? 'page' : undefined}
aria-label={iconOnly ? item.label : undefined}
title={iconOnly ? item.label : undefined}
class:list={[
'block no-underline rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
'no-underline rounded-md text-sm font-medium transition-colors',
active
? 'text-brand bg-brand/10'
: 'text-muted hover:bg-surface hover:text-foreground',
// Icon-only items are compact buttons: the same row height as the
// text items (`h-8`), but tighter horizontal padding. An icon
// beside a label stays a full-width row (`flex` keeps the block
// level svg inline with the text).
iconOnly
? 'inline-flex h-8 items-center justify-center px-2'
: item.icon
? 'flex items-center gap-2 px-3 py-1.5'
: 'block px-3 py-1.5',
]}
>
{item.label}
{item.icon === 'home' && (
<svg
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"></path>
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
</svg>
)}
{!iconOnly && <span>{item.label}</span>}
</a>
</li>
);
})
}
</ul>
</ul>
26 changes: 26 additions & 0 deletions src/src/components/starlight/PageTitle.astro
Original file line number Diff line number Diff line change
@@ -1,13 +1,25 @@
---
import StatusBadge from '~/components/StatusBadge.astro';
import { isStale, STALE_AFTER_DAYS } from '~/lib/docs/staleness';
import { LLMS_LINK_ATTRS } from '~/lib/llms';
import { withBase } from '~/lib/urls';

const { entry } = Astro.locals.starlightRoute;
const data = entry?.data;
const lastReviewed = typeof data?.lastReviewed === 'string' ? data.lastReviewed : undefined;
const stale = lastReviewed ? isStale(lastReviewed) : false;
const tags = Array.isArray(data?.tags) ? data.tags : [];
// Project overview pages surface the per-project `llms.txt` bundle emitted by
// the `customSets` option in `astro.config.ts`. Starlight resolves an index
// page to its directory id (`docs/<project>`), while the content collection —
// and therefore the plugin's bundle paths — use `docs/<project>/index`, so both
// forms are accepted here. Other aggregated pages keep the heading lean and only
// link the global files from the site footer.
const entryId = typeof entry?.id === 'string' ? entry.id : undefined;
const isProjectOverview = entryId !== undefined && /^docs\/[^/]+(\/index)?$/.test(entryId);
const sourceProject = typeof data?.sourceProject === 'string' ? data.sourceProject : undefined;
const llmsHref =
isProjectOverview && sourceProject ? withBase(`/_llms-txt/${sourceProject}.txt`) : null;
---

<h1 id="_top">{data?.title}</h1>
Expand Down Expand Up @@ -66,6 +78,20 @@ const tags = Array.isArray(data?.tags) ? data.tags : [];
</span>
)
}
{
llmsHref && (
<span class="doc-meta-item">
<a
href={llmsHref}
{...LLMS_LINK_ATTRS}
class="pv-link"
title={`Machine-readable documentation bundle for ${data?.projectName ?? sourceProject}`}
>
llms.txt
</a>
</span>
)
}
</div>
)
}
Expand Down
13 changes: 13 additions & 0 deletions src/src/lib/llms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Link attributes for the generated LLM text bundles — `llms.txt`,
* `llms-small.txt`, `llms-full.txt`, and the per-project
* `/_llms-txt/<project-id>.txt` sets.
*
* These are plain-text files rather than site pages, so every link to one is
* treated like an external link and opens in a new tab. Spreading the object
* keeps the call sites short enough to stay on one line.
*/
export const LLMS_LINK_ATTRS = {
rel: 'noopener noreferrer',
target: '_blank',
} as const;
31 changes: 31 additions & 0 deletions src/src/pages/docs/index.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
import SiteLayout from '~/layouts/SiteLayout.astro';
import { LLMS_LINK_ATTRS } from '~/lib/llms';
import { loadProjects } from '~/lib/manifest/load';
import { readDocsManifest } from '~/lib/docs/aggregate';
import { projectRepoEnrichment } from '~/lib/releases/repo';
Expand Down Expand Up @@ -76,5 +77,35 @@ const pageCount = docsManifest?.projects.reduce(
</li>
</ul>
</section>

<section class="mt-8 rounded-xl border border-border bg-surface p-6 md:p-8">
<h2 class="text-xl font-semibold">Machine-readable documentation</h2>
<p class="mt-3 max-w-2xl text-sm text-muted">
Each project's documentation is also published as a single <code>llms.txt</code> bundle for
language models and other tooling. The whole site is available as
<a href={withBase('/llms.txt')} {...LLMS_LINK_ATTRS} class="pv-link">llms.txt</a>,
<a href={withBase('/llms-small.txt')} {...LLMS_LINK_ATTRS} class="pv-link">
llms-small.txt
</a>, and
<a href={withBase('/llms-full.txt')} {...LLMS_LINK_ATTRS} class="pv-link">
llms-full.txt
</a>.
</p>
<ul class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
{
projects.map((project) => (
<li>
<a
href={withBase(`/_llms-txt/${project.id}.txt`)}
{...LLMS_LINK_ATTRS}
class="pv-link"
>
{project.name}
</a>
</li>
))
}
</ul>
</section>
</div>
</SiteLayout>
16 changes: 16 additions & 0 deletions src/src/pages/projects/[project].astro
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PackageVersions, { type PackageRow } from '~/components/islands/PackageVe
import StatusBadge from '~/components/StatusBadge.astro';
import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from '~/lib/badges';
import { installAnchorId, installKindFor, installTabs } from '~/lib/install';
import { LLMS_LINK_ATTRS } from '~/lib/llms';
import { loadProjects } from '~/lib/manifest/load';
import { projectRepoEnrichment } from '~/lib/releases/repo';
import { projectReleaseSummary } from '~/lib/releases/transform';
Expand Down Expand Up @@ -44,6 +45,9 @@ const supersededBy =
: undefined;
const primaryPackage = project.packages.find((pkg) => pkg.primary) ?? project.packages[0];
const docsPath = project.docs ? withBase(`/docs/${project.id}/`) : null;
// Per-project machine-readable bundle emitted by `starlight-llms-txt`'s
// `customSets` option (one file per documented project at /_llms-txt/<id>.txt).
const llmsPath = project.docs ? withBase(`/_llms-txt/${project.id}.txt`) : null;
const targetFrameworks = project.targetFrameworks ?? [];
const installSections =
project.status !== 'archived' ?
Expand Down Expand Up @@ -137,6 +141,18 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({
</a>
)
}
{
llmsPath && (
<a
href={llmsPath}
{...LLMS_LINK_ATTRS}
class="pv-btn pv-btn-secondary pv-btn-sm"
title={`Machine-readable documentation bundle for ${project.name}`}
>
llms.txt
</a>
)
}
<a
href={project.releasesUrl}
rel="noopener noreferrer"
Expand Down
Loading
Loading