diff --git a/README.md b/README.md index 329e11d..35b3d89 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ See `docs/decisions/0001-architecture.md` for the architectural decision record. | Documentation theme | Starlight | 0.42.1 | | Styling | Tailwind CSS | 4.3.3 | | Islands | Preact | 10.29.8 | +| Syntax highlighting | Shiki (Astro built-in ``) | 4.4.3 | | Linting | oxlint | 1.83.0 | | Formatting | oxfmt | 0.68.0 | | TypeScript | 5.9.3 | @@ -207,13 +208,18 @@ the offending property, the expected shape, and a remediation hint. Use `rootPage` to name the page served at `/docs//` and `exclude` to drop files from the aggregation — see [Documentation landing pages](#documentation-landing-pages). -4. Add relationships with `related`, or `supersededBy`/`supersedes` for +4. Add concrete `useCases` (see [Use cases](#use-cases)) — at least one is + expected for every active project. Each entry needs an `audience` + (`developer`, `team-lead`, `architect`, or `contributor`), `title`, + `scenario`, and `outcome`; `code` + `language`, `evidence`, and `docsPage` + are optional. +5. Add relationships with `related`, or `supersededBy`/`supersedes` for archived projects. -5. Credit upstream work with `acknowledgments` (`name`, `url`, and an optional +6. Credit upstream work with `acknowledgments` (`name`, `url`, and an optional `description`) when the project is based on, or forked from, another project — for example ZodSharp credits the original Zod and the `guinhx/ZodSharp` port it was forked from. -6. Run `just validate` — the manifest schema, catalogue page, project page, +7. Run `just validate` — the manifest schema, catalogue page, project page, docs aggregation, and release transforms are all regenerated from this one file. @@ -224,6 +230,28 @@ Projects the organisation contributes to but does not own are listed under section on the catalogue page and link to their own site/repository (e.g. `https://likec4.dev` for LikeC4) rather than the Purview catalogue. +## Use cases + +Each project's `useCases` entries are the site's "concrete evidence": an +audience-tagged example of the tool solving a real problem, showing the code and +stating the outcome. Prose explains intent; a use case is meant to prove it. + +- `audience` — `developer`, `team-lead`, `architect`, or `contributor`. +- `title` / `scenario` / `outcome` — the headline, the situation, and what the + reader gets. +- `code` + `language` — a short, accurate snippet. The schema requires + `language` whenever `code` is present. +- `evidence` — a hard fact: a before/after, a count, a generated-output excerpt, + or a measured property (for example ZodSharp's zero-allocation valid path). +- `docsPage` — an optional docs page slug; the card links to + `/docs///`. The post-build link crawl fails the build if a + deep link does not resolve, so these cannot rot. + +Use cases surface in three places: the home page ("What it looks like in +practice"), each project page ("Use cases"), and the filterable catalogue at +`/use-cases/` (filter by audience, free-text search). Keep snippets short and +point at the documentation for depth rather than duplicating long examples. + ## Documentation aggregation Documentation is owned by each product repository and presented through this diff --git a/bun.lock b/bun.lock index f7f8ccf..f001c1e 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,7 @@ "@astrojs/sitemap": "3.7.4", "@astrojs/starlight": "0.42.3", "@astrojs/starlight-tailwind": "5.0.0", + "@shikijs/themes": "4.4.3", "@tailwindcss/vite": "4.3.3", "@types/bun": "1.4.2", "@types/semver": "^7.8.0", diff --git a/docs/decisions/0002-concrete-use-cases.md b/docs/decisions/0002-concrete-use-cases.md new file mode 100644 index 0000000..16d0d98 --- /dev/null +++ b/docs/decisions/0002-concrete-use-cases.md @@ -0,0 +1,75 @@ +# ADR 0002 — Surface concrete, audience-tagged use cases in the catalogue + +- Status: accepted +- Date: 2026-09-29 + +## Context + +The site explains what Purview is and why each project exists, but a reader +arriving without context could not see what the tools actually do. Feedback on +the beta was consistent: it is not obvious what it does depending on the +audience, a few examples/use cases would help, and the audience — even one +already aware of the organisation — still needs concrete evidence. + +The project manifest already carried `description`, `origin`, `useWhen`, and +`avoidWhen`, but these are prose. The concrete examples that existed — dozens of +code fences across the aggregated documentation — were only reachable after a +click into the portal, and were written as reference material rather than as +proof. + +## Decisions + +### 1. Model use cases in the catalogue, not in a separate content store + +Every project gains an optional `useCases` array in `src/data/projects.yml`, +validated by the Zod manifest schema. A use case is audience-tagged +(`developer`, `team-lead`, `architect`, `contributor`) and carries a `title`, +`scenario`, `outcome`, optional `code` + `language`, optional `evidence`, and an +optional `docsPage` deep link. Keeping this in the single source of truth means +the catalogue, project page, and use-case index cannot disagree, and the schema +enforces the shape at build time. + +### 2. Snippets are short and point at the documentation for depth + +Use cases carry a short taste of the code and an `evidence` fact (a before/after, +a count, a generated-output excerpt, or a measured property such as ZodSharp's +zero-allocation valid path). Long examples stay in the aggregated documentation +so there is one copy of them; the card links into `/docs///`. + +### 3. Deep links are validated by the link crawl + +`docsPage` slugs are rendered into `/docs///` links. The +post-build crawl (`just check-links`) fails the build when a link does not +resolve, so a renamed documentation page cannot leave a dead link behind. A unit +test additionally rejects a `docsPage` on a project that publishes no +documentation. + +### 4. Render server-side, enhance with one small island + +Use cases are rendered as static HTML on the home page, project pages, and +`/use-cases/`. A single Preact island (`UseCaseFilter`) toggles visibility on +the index page only; the page is fully usable without JavaScript, consistent +with the existing catalogue and release filters. + +### 5. Highlight code at build time with Shiki + +Code samples are highlighted at build time by Astro's built-in `` +component, which wraps Shiki. This keeps the marketing pages consistent with the +documentation (Starlight's Expressive Code uses Night Owl) and ships no client +JavaScript. The theme is passed as an imported object (`@shikijs/themes/night-owl`) +rather than a Shiki theme name, because Astro's bundled Shiki theme registry +resolves to an empty map in this build and a string name — even the default — +throws "not included in this bundle". One CSS rule makes the frame's +brand-tinted background show through instead of the theme background, so the +highlighted samples and the un-highlighted install panel share the same surface. + +## Consequences + +- Adding a project now includes writing at least one concrete use case; the + manifest test suite asserts coverage across projects. +- The audience vocabulary is deliberately small and lives in the schema; adding + an audience is a schema change, so it is a considered decision rather than a + free-text label. +- Use cases are hand-authored prose-plus-code in YAML. This is acceptable for the + current catalogue size; if it grows substantially, snippets could move to files + referenced by path without changing the rendered shape. diff --git a/docs/index.md b/docs/index.md index 0d8c466..2da674d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,3 +5,4 @@ Shared engineering documentation and architecture decisions for Purview Developm ## Architecture decisions - [ADR 0001: Architecture](decisions/0001-architecture.md) +- [ADR 0002: Concrete use cases](decisions/0002-concrete-use-cases.md) diff --git a/src/package.json b/src/package.json index 2fa3292..4d1b669 100644 --- a/src/package.json +++ b/src/package.json @@ -35,6 +35,7 @@ "@astrojs/sitemap": "3.7.4", "@astrojs/starlight": "0.42.3", "@astrojs/starlight-tailwind": "5.0.0", + "@shikijs/themes": "4.4.3", "@tailwindcss/vite": "4.3.3", "@types/bun": "1.4.2", "@types/semver": "^7.8.0", diff --git a/src/src/components/CodeSample.astro b/src/src/components/CodeSample.astro new file mode 100644 index 0000000..f0096ad --- /dev/null +++ b/src/src/components/CodeSample.astro @@ -0,0 +1,39 @@ +--- +import { Code } from 'astro:components'; +import nightOwl from '@shikijs/themes/night-owl'; +import type { CodeLanguage } from 'astro'; + +interface Props { + code: string; + /** Language label shown in the frame header (e.g. "csharp", "yaml"). */ + language?: string; +} + +const { code, language = 'plaintext' } = Astro.props; +// The manifest stores a free-form language string; Astro's falls back to +// `plaintext` for anything it does not recognise, so the narrowing is safe. +const lang = language as CodeLanguage; +// The theme is passed as an imported object rather than a Shiki theme name. +// Astro's bundled Shiki theme registry resolves to an empty map in this build, +// so a string name (even the default) throws "not included in this bundle"; an +// object bypasses the registry lookup. Night Owl matches the documentation +// pages, which use Starlight's Night Owl dark theme. +const theme = nightOwl; +// `wrap` is on so a long line soft-wraps inside the frame instead of producing a +// horizontal scrollbar. The cards sit in multi-column grids, where a scrollbar +// would hide content the reader is meant to see at a glance. +--- + +
+
+ + + {language} + +
+ +
diff --git a/src/src/components/SiteNav.astro b/src/src/components/SiteNav.astro index b49d89d..539237c 100644 --- a/src/src/components/SiteNav.astro +++ b/src/src/components/SiteNav.astro @@ -15,6 +15,7 @@ type NavItem = { export const NAV_ITEMS: readonly NavItem[] = [ { label: 'Home', href: '/', icon: 'home' }, { label: 'Projects', href: '/projects/' }, + { label: 'Use cases', href: '/use-cases/' }, { label: 'Documentation', href: '/docs/' }, { label: 'Releases', href: '/releases/' }, { label: 'About', href: '/about/' }, diff --git a/src/src/components/UseCaseCard.astro b/src/src/components/UseCaseCard.astro new file mode 100644 index 0000000..d8e6962 --- /dev/null +++ b/src/src/components/UseCaseCard.astro @@ -0,0 +1,54 @@ +--- +import CodeSample from '~/components/CodeSample.astro'; +import { AUDIENCE_LABELS, type ProjectUseCase } from '~/lib/manifest/schema'; + +interface Props { + useCase: ProjectUseCase; + projectName: string; + projectId: string; + /** Base docs URL for the owning project (`/docs//`), or null when the project has no docs. */ + docsBase: string | null; + /** Hide the owning project chip when the card is already scoped to that project. */ + showProject?: boolean; +} + +const { useCase, projectName, projectId, docsBase, showProject = true } = Astro.props; +const audienceLabel = AUDIENCE_LABELS[useCase.audience]; +const docsLink = + docsBase && useCase.docsPage ? `${docsBase}${useCase.docsPage}/` : (docsBase ?? null); +--- + +
+
+ {audienceLabel} + {showProject && {projectName}} +
+ +

{useCase.title}

+ +

{useCase.scenario}

+ + {useCase.code && } + +

+ What you get: {useCase.outcome} +

+ + { + useCase.evidence && ( +

{useCase.evidence}

+ ) + } + + { + docsLink && ( + + Read the guide → + + ) + } +
diff --git a/src/src/components/islands/UseCaseFilter.tsx b/src/src/components/islands/UseCaseFilter.tsx new file mode 100644 index 0000000..b91d33f --- /dev/null +++ b/src/src/components/islands/UseCaseFilter.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'preact/hooks'; + +interface AudienceOption { + value: string; + label: string; +} + +interface Props { + audiences: AudienceOption[]; +} + +/** + * Filters the use-case cards rendered on `/use-cases/`. Cards are + * server-rendered and carry `data-audience` / `data-usecase-search`; this island + * only toggles their `hidden` attribute, so the page stays fully usable without + * JavaScript. + */ +export default function UseCaseFilter({ audiences }: Props) { + const [audience, setAudience] = useState('all'); + const [search, setSearch] = useState(''); + const [visibleCount, setVisibleCount] = useState(0); + + useEffect(() => { + const items = Array.from(document.querySelectorAll('[data-usecase-id]')); + const query = search.trim().toLowerCase(); + let visible = 0; + for (const item of items) { + const matchesAudience = + audience === 'all' || (item.dataset.usecaseAudience ?? '') === audience; + const matchesSearch = query === '' || (item.dataset.usecaseSearch ?? '').includes(query); + const show = matchesAudience && matchesSearch; + item.hidden = !show; + if (show) { + visible += 1; + } + } + setVisibleCount(visible); + }, [audience, search]); + + const selectClasses = + 'rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground'; + + return ( +
+ + + + + +

+ {visibleCount} use case{visibleCount === 1 ? '' : 's'} +

+
+ ); +} diff --git a/src/src/data/projects.yml b/src/src/data/projects.yml index 0fa1b6a..b304cf8 100644 --- a/src/src/data/projects.yml +++ b/src/src/data/projects.yml @@ -41,6 +41,47 @@ projects: primary: true targetFrameworks: - netstandard2.0 + useCases: + - audience: developer + title: Tracing, logging, and metrics from a single interface + scenario: >- + You want OpenTelemetry activities, structured logs, and metrics around an order service, but + you do not want to hand-write the ActivitySource, ILogger, and Meter wiring in every class. + code: | + [ActivitySource] + [Logger] + [Meter] + public interface IOrderServiceTelemetry + { + [Activity] + [Info] + [AutoCounter] + Activity? PlacingOrder(int orderId, [Baggage] string region); + } + language: csharp + outcome: >- + The generator emits the implementation and an AddOrderServiceTelemetry() DI extension; you + register it once and inject the interface wherever telemetry is needed. + evidence: >- + One interface replaces the ActivitySource, ILogger, and Meter boilerplate you would otherwise + repeat in every service. + - audience: architect + title: Standardise telemetry names across services + scenario: >- + You want consistent OpenTelemetry meter and activity-source names across many services instead + of ad-hoc string literals scattered through the codebase. + code: | + builder.AddServiceDefaults( + TelemetryNames.MeterNames, + TelemetryNames.ActivitySourceNames); + language: csharp + outcome: >- + A generated TelemetryNames static class holds the names, so registration happens once at + startup rather than in every service. + evidence: >- + Naming conventions are generated, so snake_case tags and hierarchical metrics stay consistent + without a review checklist. + docsPage: generation discussions: false - id: event-sourcing @@ -98,6 +139,49 @@ projects: description: FluentValidation adapter for aggregate save-time validation. - id: Purview.EventSourcing.Validation.ZodSharp description: Purview.ZodSharp adapter for aggregate save-time validation. + useCases: + - audience: developer + title: An aggregate that persists as events + scenario: >- + You want aggregate-based event sourcing without hand-writing the event stream, snapshot, and + transaction plumbing. + code: | + [Aggregate] + public partial class OrderAggregate : AggregateBase + { + public string CustomerId { get; private set; } = default!; + public decimal Total { get; private set; } + + [Event] + public partial void CreateOrder(string customerId); + + [Event] + public partial void AddLineItem(string productId, string productName, int quantity, decimal unitPrice); + } + language: csharp + outcome: >- + The source generator produces the aggregate plumbing; provider packages add stores for SQL + Server, PostgreSQL, MongoDB, Azure Storage, Cosmos DB, or in-memory. + evidence: >- + One [Aggregate] declaration, and switching storage means swapping a provider package rather + than rewriting the domain. + docsPage: solution-design-guide + - audience: architect + title: Choose the store late, keep the domain stable + scenario: >- + You need to start on one database and move providers later, but you do not want persistence + choices to leak into the domain model. + code: | + builder.Services.AddSqlServerEventStore(); + builder.Services.AddSqlServerSnapshotQueryableEventStore(); + language: csharp + outcome: >- + Domain code depends on provider-agnostic facades, while concrete stores live behind separate + packages and registration extensions. + evidence: >- + SQL Server, PostgreSQL, MongoDB, Azure Storage, and Cosmos DB are interchangeable at the + registration level, so the domain never references a store. + docsPage: sql-server-guide related: - zodsharp - value-objects @@ -157,6 +241,40 @@ projects: - name: ZodSharp (guinhx) url: https://github.com/guinhx/ZodSharp description: The C# port of Zod, and the fork this project is based on. + useCases: + - audience: developer + title: Validate input without exceptions or allocations + scenario: >- + You need to validate values at an application boundary and prefer a result you can inspect + over exception-driven control flow. + code: | + var nameSchema = Z.String().Min(3).Max(50); + var result = nameSchema.Validate("John"); + + if (result.IsSuccess) + Console.WriteLine($"Valid name: {result.Value}"); + language: csharp + outcome: >- + Validate/SafeParse return a ValidationResult with no exceptions, while Parse still throws + ZodException when you want that behaviour. + evidence: >- + Validation is zero-allocation on every valid input path, so the performance claim is a + property of the code rather than an aspiration. + docsPage: fluent-schema-api + - audience: team-lead + title: Return standard ProblemDetails for invalid requests + scenario: >- + You want failed validation in ASP.NET Core to surface as standard ProblemDetails payloads + rather than a bespoke error shape that clients must learn. + code: | + dotnet add package Purview.ZodSharp.AspNetCore + language: bash + outcome: >- + The AspNetCore adapter converts failed validation results into the standard ProblemDetails / + HttpValidationProblemDetails payloads. + evidence: >- + One adapter package, and API consumers keep the error contract they already expect. + docsPage: aspnetcore-integration - id: value-objects name: Value Objects @@ -199,6 +317,48 @@ projects: - id: Purview.ValueObjects description: Runtime contracts, source generator, and analyzer for scalar and complex value objects. primary: true + useCases: + - audience: developer + title: A domain scalar without the boilerplate + scenario: >- + You want a strong EmailAddress type with normalisation and validation, without hand-writing + factories, equality, conversions, and a JSON converter. + code: | + [Scalar] + public readonly partial record struct EmailAddress + { + public string Value { get; } + + static partial void OnNormalize(ref string value) + => value = value?.Trim().ToLowerInvariant()!; + } + language: csharp + outcome: >- + The generator adds Create, Hydrate, TryCreate, and Empty, plus equality, comparison, implicit + conversions, and a JSON converter. + evidence: >- + One declaration replaces the factory, equality, comparison, conversion, and serialization code + you would otherwise maintain by hand. + docsPage: value-object-design + - audience: developer + title: Map a value object onto an Entity Framework column + scenario: >- + You use a value object in your domain and want it to persist transparently instead of writing + converters at every mapping site. + code: | + var email = EmailAddress.Create(" Demo@Example.COM "); + // email.Value == "demo@example.com" + + bool ok = EmailAddress.TryCreate("not-an-email", out _); + // ok == false + language: csharp + outcome: >- + Value objects serialize as their underlying value and map onto Entity Framework JSON columns, + with optional Purview.ZodSharp validation. + evidence: >- + Persistence and serialization come from the generated type, so mapping code is not duplicated + per entity. + docsPage: entity-framework related: - zodsharp - event-sourcing @@ -245,6 +405,39 @@ projects: description: Framework-agnostic test runner and assertions for generator tests. - id: Purview.SourceGeneratorFramework.Testing.TUnit description: TUnit-specific test base classes and assertions. + useCases: + - audience: contributor + title: Build an incremental generator on typed foundations + scenario: >- + You are writing a non-trivial incremental C# source generator and need typed attribute + models, structured code output, and packaging that Roslyn will actually load. + code: | + + language: xml + outcome: >- + The framework supplies attribute-data models, a structured code writer, a type library, and + packaging helpers so the generator stays focused on its own logic. + evidence: >- + The Purview generators themselves are built on the framework, so it is exercised by real + generators rather than being an untested abstraction. + docsPage: guide + - audience: contributor + title: Verify incremental behaviour in tests + scenario: >- + You need to prove a generator is incremental and produces the code you expect, without + assembling a Roslyn driver by hand in every test. + outcome: >- + The testing package provides SourceGeneratorTestRunner, a framework-agnostic test + base, and assertions such as AssertSingleGeneratedSource and AssertNoCompilationErrors. + evidence: >- + Step-cache tests and a TUnit integration ship alongside the runner, so incremental regressions + surface in CI. + docsPage: testing - id: aspire-resourcekit name: Aspire ResourceKit @@ -281,6 +474,42 @@ projects: - id: Purview.Aspire.ResourceKit description: Runtime and source generator for resource kits. primary: true + useCases: + - audience: architect + title: Typed, testable resource groups in a large AppHost + scenario: >- + A large Aspire AppHost accumulates procedural resource wiring and configuration that is hard + to test and easy to misconfigure. + code: | + [HostKit] + partial class ShopHostKit; + + [ResourceDefinition("api")] + partial class ApiResourceKit + { + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder) + => builder.AddProject(Name); + } + language: csharp + outcome: >- + Resource definitions become strongly typed, source-generated classes with generated host + wiring and typed options. + evidence: >- + One [HostKit] plus one [ResourceDefinition] per resource replaces procedural AppHost code, and + the analyzer validates the wiring at compile time. + docsPage: examples + - audience: team-lead + title: Configure and toggle resources without editing the AppHost + scenario: >- + You want enablement rules and configuration to be declarative and discoverable instead of + buried in the AppHost entry point. + outcome: >- + Typed options and enablement toggles are generated for each resource kit, so behaviour is + configured rather than coded. + evidence: >- + Resource kits can be unit tested with the testing helpers, independent of a running AppHost. + docsPage: configuration-and-options - id: aspirec4 name: AspireC4 @@ -314,6 +543,36 @@ projects: - id: AspireC4.Hosting description: Aspire hosting extension for the AspireC4 plugin, generating a dynamic LikeC4 architecture diagram for your Aspire application. primary: true + useCases: + - audience: developer + title: A live architecture diagram with three lines of code + scenario: >- + You already describe your distributed application in Aspire and do not want to maintain a + separate architecture diagram that drifts from reality. + code: | + var builder = DistributedApplication.CreateBuilder(args); + + builder.AddAspireC4(); + + builder.Build().Run(); + language: csharp + outcome: >- + AspireC4 writes the generated LikeC4 model, starts the LikeC4 sidecar, and refreshes the + diagram whenever the Aspire application changes. + evidence: >- + The diagram is derived from the running resource graph, so it cannot silently fall out of date + the way a hand-drawn one can. + docsPage: dashboard-integration + - audience: architect + title: Validate architectural metadata at compile time + scenario: >- + You want tags, kinds, groups, and metadata in your architecture model checked before the + application even runs. + outcome: >- + A Roslyn source generator validates the architectural metadata while the AppHost compiles. + evidence: >- + Architectural metadata mistakes fail the build instead of surfacing in a diagram nobody reads. + docsPage: generated-output - id: build name: Build @@ -351,6 +610,38 @@ projects: - id: Purview.Build description: Pinned dotnet tool implementing the shared pipeline. primary: true + useCases: + - audience: team-lead + title: One reusable pipeline for every repository + scenario: >- + You maintain several repositories that each repeat the same build, test, pack, and release + logic, and the copies drift apart. + code: | + # .github/workflows/pr.yml + jobs: + build: + uses: purview-dev/build/.github/workflows/purview-build.yml@main + secrets: inherit + language: yaml + outcome: >- + Consumers reference the reusable workflow or composite action and configure behaviour with + purview-build.json instead of owning pipeline source. + evidence: >- + Pipeline code lives in one repository; a consuming repository is a short workflow file plus + configuration. + docsPage: repository-ci-cd + - audience: contributor + title: Dogfood the same pipeline the organisation uses + scenario: >- + You want local commands, pull-request CI, and releases to run the exact same tool and + conventions, rather than three approximations. + outcome: >- + The pipeline is a Modular Pipelines .NET tool packaged as a pinned dotnet tool, callable from + a composite action, reusable workflows, or the local command line. + evidence: >- + Purview's own repositories use the published pipeline, so the tool is exercised on every + build. + docsPage: pipeline-modules - id: build-sdk name: Build SDK @@ -388,6 +679,38 @@ projects: - id: Purview.BuildSdk description: MSBuild SDK for standardised project defaults. primary: true + useCases: + - audience: team-lead + title: Apply repository-wide defaults from one declaration + scenario: >- + You want standard .NET project defaults, code-style enforcement, test wiring, and central + package management without repeating the same properties in every project. + code: | + { + "msbuild-sdks": { + "Purview.BuildSdk": "1.0.0" + } + } + language: json + outcome: >- + Install the SDK once via global.json and Directory.Build.props; every project beneath the + repository root inherits everything automatically. + evidence: >- + One versioned SDK declaration replaces per-project property groups, so conventions are + upgraded in one place. + docsPage: configuration-reference + - audience: contributor + title: Bootstrap a new repository + scenario: >- + You are starting a repository and want the organisation's conventions in place before the + first project exists. + outcome: >- + The SDK can bootstrap a global.json at the repository root when one is missing, then applies + project-type detection, analyzers, and test wiring. + evidence: >- + Repository bootstrap, analyzers, and testing wiring are documented and generated, not copied + between repos by hand. + docsPage: repository-bootstrap - id: dotnet-logging-source-generators name: Logging Source Generators @@ -414,6 +737,18 @@ projects: - net6.0 - net7.0 packages: [] + useCases: + - audience: developer + title: Move to the supported successor + scenario: >- + You depend on the original interface-based logging generator and want logging, activities, and + metrics generated together. + outcome: >- + Telemetry SourceGenerator generates logging alongside Activities and Metrics from the same + interfaces, with DI registration and multi-targeting. + evidence: >- + This project is archived and no longer developed; the successor covers a superset of its + behaviour from one interface. externalProjects: - id: likec4 diff --git a/src/src/lib/manifest/load.ts b/src/src/lib/manifest/load.ts index bff3ec1..f9d8759 100644 --- a/src/src/lib/manifest/load.ts +++ b/src/src/lib/manifest/load.ts @@ -22,6 +22,7 @@ export interface ResolvedProject extends ProjectRecord { order: number; packages: NonNullable; related: NonNullable; + useCases: NonNullable; acknowledgments: NonNullable; discussions: boolean; install: NonNullable; @@ -55,6 +56,7 @@ function withDefaults(record: ProjectRecord): ResolvedProject { order: record.order ?? PROJECT_DEFAULTS.order, packages: record.packages ?? PROJECT_DEFAULTS.packages, related: record.related ?? PROJECT_DEFAULTS.related, + useCases: record.useCases ?? PROJECT_DEFAULTS.useCases, acknowledgments: record.acknowledgments ?? PROJECT_DEFAULTS.acknowledgments, discussions: record.discussions ?? PROJECT_DEFAULTS.discussions, install: record.install ?? PROJECT_DEFAULTS.install, diff --git a/src/src/lib/manifest/schema.ts b/src/src/lib/manifest/schema.ts index b40e9ca..3668ab9 100644 --- a/src/src/lib/manifest/schema.ts +++ b/src/src/lib/manifest/schema.ts @@ -15,6 +15,46 @@ export const STATUSES = ['stable', 'preview', 'archived'] as const; /** How the primary NuGet package is consumed, which drives the Install options. */ export const INSTALL_KINDS = ['nuget', 'msbuild-sdk', 'dotnet-tool'] as const; +/** + * Who a concrete use case speaks to. The vocabulary is deliberately small so + * the same project can be framed for the person adopting a package, the person + * approving its adoption, and the person extending it. + */ +export const AUDIENCES = ['developer', 'team-lead', 'architect', 'contributor'] as const; + +/** Human-facing labels for the audience tags (used in the UI). */ +export const AUDIENCE_LABELS: Record<(typeof AUDIENCES)[number], string> = { + developer: 'Developer', + 'team-lead': 'Team lead', + architect: 'Architect', + contributor: 'Contributor', +}; + +/** + * A concrete, ideally runnable example of the project solving a real problem. + * Screenshots of prose are not evidence: `code`/`evidence` carry the proof and + * `outcome` states what the reader gets. `docsPage` optionally deep-links into + * the project's aggregated documentation at `/docs///`. + */ +const useCaseSchema = z + .object({ + audience: z.enum(AUDIENCES), + title: z.string().min(1, 'must be a short, concrete scenario title'), + scenario: z.string().min(1, 'must describe the situation the reader is in'), + outcome: z.string().min(1, 'must describe what the reader gets from the project'), + code: z.string().min(1).optional(), + language: z.string().min(1).optional(), + evidence: z.string().min(1).optional(), + docsPage: z + .string() + .regex(/^[a-z0-9-]+$/, 'must be a lowercase docs page slug using [a-z0-9-] only') + .optional(), + }) + .refine((value) => value.code === undefined || value.language !== undefined, { + message: 'is required when "code" is provided', + path: ['language'], + }); + const docsPathSchema = z.object({ source: z.literal('github-path'), path: z.string().min(1, 'must be a non-empty repository path such as "docs"'), @@ -79,6 +119,7 @@ const projectSchema = z.object({ targetFrameworks: z.array(z.string()).optional(), packages: z.array(packageSchema).optional(), related: z.array(z.string()).optional(), + useCases: z.array(useCaseSchema).optional(), acknowledgments: z.array(acknowledgmentSchema).optional(), supersedes: z.string().optional(), supersededBy: z.string().optional(), @@ -113,12 +154,14 @@ export type ExternalProjectRecord = z.infer; export type ProjectDocsConfig = z.infer; export type ProjectPackage = z.infer; export type ProjectAcknowledgment = z.infer; +export type ProjectUseCase = z.infer; export const PROJECT_DEFAULTS = { featured: false, order: 1000, packages: [] as ProjectPackage[], related: [] as string[], + useCases: [] as ProjectUseCase[], acknowledgments: [] as ProjectAcknowledgment[], discussions: false, install: 'nuget', @@ -127,6 +170,7 @@ export const PROJECT_DEFAULTS = { order: number; packages: ProjectPackage[]; related: string[]; + useCases: ProjectUseCase[]; acknowledgments: ProjectAcknowledgment[]; discussions: boolean; install: (typeof INSTALL_KINDS)[number]; diff --git a/src/src/pages/index.astro b/src/src/pages/index.astro index bba5d07..6ec59ed 100644 --- a/src/src/pages/index.astro +++ b/src/src/pages/index.astro @@ -2,6 +2,7 @@ import SiteLayout from '~/layouts/SiteLayout.astro'; import ProjectCard from '~/components/ProjectCard.astro'; import ProjectVersionSnapshot from '~/components/ProjectVersionSnapshot.astro'; +import UseCaseCard from '~/components/UseCaseCard.astro'; import { loadProjects } from '~/lib/manifest/load'; import { projectReleaseSummary, projectVersionRollup } from '~/lib/releases/transform'; import { getReleaseIndex } from '~/lib/releases/runtime'; @@ -13,6 +14,16 @@ const featured = projects.filter((project) => project.featured); const active = projects.filter((project) => project.status !== 'archived'); const featuredGridClass = `featured-projects-grid${featured.length === 5 ? ' featured-projects-grid-five' : ''}`; +// A short, concrete taste of the catalogue: the first use case from the first +// few featured projects that carry one. The full, filterable list lives at +// /use-cases/. +const examples = featured + .flatMap((project) => { + const useCase = project.useCases[0]; + return useCase ? [{ project, useCase }] : []; + }) + .slice(0, 3); + const releaseIndex = getReleaseIndex(); const versionRows = active.map((project) => projectVersionRollup( @@ -119,6 +130,44 @@ const organizationStructuredData = { + { + examples.length > 0 && ( +
+
+
+

+ What it looks like in practice +

+

+ Concrete examples drawn from the projects — the code you write, and what the tooling + gives you back. +

+
+ +
+
+ {examples.map(({ project, useCase }, index) => ( +
+ +
+ ))} +
+
+ ) + } +
diff --git a/src/src/pages/projects/[project].astro b/src/src/pages/projects/[project].astro index f0afbd2..fdc1132 100644 --- a/src/src/pages/projects/[project].astro +++ b/src/src/pages/projects/[project].astro @@ -3,6 +3,7 @@ import SiteLayout from '~/layouts/SiteLayout.astro'; import InstallPanel from '~/components/islands/InstallPanel.tsx'; import PackageVersions, { type PackageRow } from '~/components/islands/PackageVersions.tsx'; import StatusBadge from '~/components/StatusBadge.astro'; +import UseCaseCard from '~/components/UseCaseCard.astro'; import { buildBadge, nugetDownloadsBadge, nugetVersionBadge, actionsUrl } from '~/lib/badges'; import { installAnchorId, installKindFor, installTabs } from '~/lib/install'; import { LLMS_LINK_ATTRS } from '~/lib/llms'; @@ -238,6 +239,28 @@ const packageRows: PackageRow[] = releases.packages.map((pkg) => ({
+ { + project.useCases.length > 0 && ( +
+

Use cases

+

+ Concrete examples of what {project.name} does, and who each one is for. +

+
+ {project.useCases.map((useCase) => ( + + ))} +
+
+ ) + } +

The problem

{project.description}

diff --git a/src/src/pages/use-cases/index.astro b/src/src/pages/use-cases/index.astro new file mode 100644 index 0000000..c9f4a74 --- /dev/null +++ b/src/src/pages/use-cases/index.astro @@ -0,0 +1,79 @@ +--- +import SiteLayout from '~/layouts/SiteLayout.astro'; +import UseCaseCard from '~/components/UseCaseCard.astro'; +import UseCaseFilter from '~/components/islands/UseCaseFilter.tsx'; +import { loadProjects } from '~/lib/manifest/load'; +import { AUDIENCES, AUDIENCE_LABELS } from '~/lib/manifest/schema'; +import { SITE } from '~/lib/site'; +import { withBase } from '~/lib/urls'; + +const projects = loadProjects().filter((project) => project.useCases.length > 0); +const entries = projects.flatMap((project) => + project.useCases.map((useCase) => ({ + useCase, + project, + docsBase: project.docs ? withBase(`/docs/${project.id}/`) : null, + search: [ + project.name, + useCase.title, + useCase.scenario, + useCase.outcome, + useCase.evidence ?? '', + useCase.audience, + ] + .join(' ') + .toLowerCase(), + })), +); +const audiences = AUDIENCES.map((value) => ({ value, label: AUDIENCE_LABELS[value] })); +--- + + +
+
+

Use cases

+

Use cases

+

+ {entries.length} concrete examples of what the projects do, tagged by who each one speaks to. + Every example shows the code involved and what you get back; follow the guide link for the + full detail. +

+
+ +
+ +
+ +
+ { + entries.map(({ useCase, project, docsBase, search }, index) => ( +
+ +
+ )) + } +
+ +
+

Looking for the tools themselves?

+

+ Browse the project catalogue for the full + family, or the documentation portal for the + complete guides behind each example. +

+
+
+
diff --git a/src/src/styles/global.css b/src/src/styles/global.css index 2f8c9fa..2def925 100644 --- a/src/src/styles/global.css +++ b/src/src/styles/global.css @@ -248,6 +248,22 @@ color: var(--color-muted); } + /* Audience tag on a use-case card. Branded like the affirmative "Where it + * fits" panel, mixing against --color-surface so it stays visible in dark + * mode. */ + .pv-audience-chip { + border-color: color-mix(in oklab, var(--color-brand) 40%, var(--color-border)); + background-color: color-mix(in oklab, var(--color-brand) 10%, var(--color-surface)); + color: var(--color-brand-emphasis); + } + + /* Concrete-evidence note on a use-case card: a branded rule rather than a + * full panel, so it reads as supporting detail next to the outcome. */ + .pv-evidence { + border-left: 2px solid color-mix(in oklab, var(--color-brand) 45%, var(--color-border)); + padding-left: 0.75rem; + } + /* Chips are single-line labels: a squeezed flex row must shrink the content * beside a chip, never wrap the chip's own label onto a second line (which * made the status chip grow two lines tall next to a card title). */ @@ -342,6 +358,11 @@ font-size: 0.8125rem; line-height: 1.6; color: #e7e3f5; + /* Shiki (Astro's built-in ) sets the theme background inline on the + * same element. Transparent keeps the frame's brand-tinted dark surface + * consistent with the un-highlighted install panel; token colours are + * emitted per span and are unaffected. */ + background-color: transparent !important; } .pv-install-tab { diff --git a/src/tests/dist/built-output.test.ts b/src/tests/dist/built-output.test.ts index e823f62..fa9bd0f 100644 --- a/src/tests/dist/built-output.test.ts +++ b/src/tests/dist/built-output.test.ts @@ -102,6 +102,41 @@ describe('built project pages', () => { }); }); +describe('built use cases', () => { + test('project pages render a use-case section with concrete code', () => { + const content = requireBuilt('projects/telemetry-sourcegenerator/index.html'); + expect(content).toContain('Use cases'); + expect(content).toContain('IOrderServiceTelemetry'); + expect(content).toContain('pv-code-frame'); + expect(content).toContain('What you get:'); + // Shiki (Astro's built-in ) highlights the snippet at build time. + expect(content).toContain('astro-code'); + expect(content).toContain('data-language="csharp"'); + // Long lines wrap rather than producing a horizontal scrollbar. + expect(content).toContain('white-space: pre-wrap'); + }); + + test('the home page shows what it looks like in practice', () => { + const content = requireBuilt('index.html'); + expect(content).toContain('What it looks like in practice'); + expect(content).toContain('/use-cases/'); + }); + + test('the use-cases page lists filterable, audience-tagged examples', () => { + const content = requireBuilt('use-cases/index.html'); + expect(content).toContain('All audiences'); + expect(content).toContain('data-usecase-audience="developer"'); + expect(content).toContain('data-usecase-search='); + }); + + test('deep-linked use cases resolve to real docs paths', () => { + // Existence of the target page is enforced by the post-build link crawl + // (`just check-links`); this guards the rendered link shape. + const content = requireBuilt('projects/event-sourcing/index.html'); + expect(content).toContain('/docs/event-sourcing/sql-server-guide/'); + }); +}); + describe('built llms links', () => { // The LLM text bundles are plain files rather than site pages, so every link // to one must opt into the external-link treatment. diff --git a/src/tests/unit/manifest.test.ts b/src/tests/unit/manifest.test.ts index 5832226..7fd670e 100644 --- a/src/tests/unit/manifest.test.ts +++ b/src/tests/unit/manifest.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { loadExternalProjects, loadProjects, parseManifest } from '../../src/lib/manifest/load'; import { ManifestValidationError } from '../../src/lib/manifest/load'; +import { AUDIENCES } from '../../src/lib/manifest/schema'; describe('project manifest', () => { test('loads and validates the real manifest', () => { @@ -141,6 +142,94 @@ describe('project manifest', () => { }); }); +describe('project use cases', () => { + test('loads concrete use cases from the real manifest', () => { + const projects = loadProjects(); + const withUseCases = projects.filter((project) => project.useCases.length > 0); + expect(withUseCases.length).toBeGreaterThanOrEqual(8); + + const telemetry = projects.find((project) => project.id === 'telemetry-sourcegenerator'); + const first = telemetry?.useCases[0]; + expect(first?.audience).toBe('developer'); + expect(first?.title.length).toBeGreaterThan(0); + expect(first?.scenario.length).toBeGreaterThan(0); + expect(first?.outcome.length).toBeGreaterThan(0); + expect(first?.code).toContain('interface IOrderServiceTelemetry'); + expect(first?.language).toBe('csharp'); + }); + + test('every use case carries a known audience and an outcome', () => { + for (const project of loadProjects()) { + for (const useCase of project.useCases) { + expect(AUDIENCES).toContain(useCase.audience); + expect(useCase.outcome.trim().length).toBeGreaterThan(0); + } + } + }); + + test('deep links only appear on projects that publish documentation', () => { + for (const project of loadProjects()) { + for (const useCase of project.useCases) { + if (useCase.docsPage !== undefined) { + expect(project.docs, `${project.id} links to docs without a docs config`).toBeDefined(); + } + } + } + }); + + test('projects without use cases resolve to an empty list', () => { + const projects = parseManifest({ projects: [makeProject('a')] }, 'fixture.yml'); + expect(projects[0]?.useCases).toEqual([]); + }); + + test('rejects an unknown audience', () => { + expect(() => + parseManifest( + { + projects: [ + { + ...makeProject('a'), + useCases: [ + { + audience: 'executive', + title: 'x', + scenario: 'x', + outcome: 'x', + }, + ], + }, + ], + }, + 'fixture.yml', + ), + ).toThrow(ManifestValidationError); + }); + + test('requires a language when code is provided', () => { + expect(() => + parseManifest( + { + projects: [ + { + ...makeProject('a'), + useCases: [ + { + audience: 'developer', + title: 'x', + scenario: 'x', + outcome: 'x', + code: 'Console.WriteLine("hi");', + }, + ], + }, + ], + }, + 'fixture.yml', + ), + ).toThrow(/language/); + }); +}); + function makeProject(id: string): { id: string; name: string;