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
24 changes: 21 additions & 3 deletions astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import { satteri } from '@astrojs/markdown-satteri';
import starlightLinksValidator from 'starlight-links-validator';
import starlightLlmsTxt from 'starlight-llms-txt';
import starlightSidebarTopics from 'starlight-sidebar-topics';
Expand Down Expand Up @@ -46,10 +47,10 @@ const sidebars = {
},
],
mcp: [
{ label: 'Start here', items: ['mcp/getting-started', 'mcp/guides/run-locally'] },
{ label: 'Start here', items: ['mcp/getting-started', 'mcp/guides/run-locally', 'mcp/guides/connect-a-client'] },
{
label: 'Guides',
items: ['mcp/guides/make-it-yours', 'mcp/guides/connect-a-client', 'mcp/guides/add-a-tool', 'mcp/guides/test-your-changes', 'mcp/guides/database', 'mcp/guides/secure-the-server', 'mcp/guides/ship-the-container', 'mcp/guides/set-up-ci', 'mcp/guides/work-with-a-coding-agent'],
items: ['mcp/guides/make-it-yours', 'mcp/guides/add-a-tool', 'mcp/guides/test-your-changes', 'mcp/guides/database', 'mcp/guides/secure-the-server', 'mcp/guides/ship-the-container', 'mcp/guides/set-up-ci', 'mcp/guides/work-with-a-coding-agent'],
},
{ label: 'Reference', items: [{ autogenerate: { directory: 'mcp/reference' } }] },
{
Expand All @@ -61,7 +62,7 @@ const sidebars = {
{ label: 'Start here', items: ['lambda/getting-started', 'lambda/guides/run-locally'] },
{
label: 'Guides',
items: ['lambda/guides/make-it-yours', 'lambda/guides/add-a-function', 'lambda/guides/test-your-changes', 'lambda/guides/database', 'lambda/guides/deploy-to-aws', 'lambda/guides/secure-the-function', 'lambda/guides/set-up-ci', 'lambda/guides/work-with-a-coding-agent'],
items: ['lambda/guides/make-it-yours', 'lambda/guides/add-a-function', 'lambda/guides/test-your-changes', 'lambda/guides/database', 'lambda/guides/secure-the-function', 'lambda/guides/deploy-to-aws', 'lambda/guides/set-up-ci', 'lambda/guides/work-with-a-coding-agent'],
},
{ label: 'Reference', items: [{ autogenerate: { directory: 'lambda/reference' } }] },
{
Expand All @@ -77,9 +78,26 @@ const sidebars = {
],
};

/* Marks inline code longer than a short name, usually a file path, so the stylesheet can let it wrap
like text instead of sitting on a line of its own. */
const LONG_CODE = 28;
const markLongCode = {
name: 'mark-long-code',
element: {
filter: ['code'],
visit(node, ctx) {
if (ctx.parent(node)?.tagName === 'pre') return;
if (ctx.textContent(node).length <= LONG_CODE) return;
const cls = node.properties?.className;
ctx.setProperty(node, 'className', Array.isArray(cls) ? [...cls, 'cl-long'] : ['cl-long']);
},
},
};

export default defineConfig({
site: 'https://leanmeancleanapps.github.io',
trailingSlash: 'always',
markdown: { processor: satteri({ hastPlugins: [markLongCode] }) },
integrations: [
starlight({
title: 'Lean Mean Clean Apps',
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"sync-brand": "node scripts/sync-brand.mjs"
},
"dependencies": {
"@astrojs/markdown-satteri": "0.4.6",
"@astrojs/starlight": "0.42.8",
"@fontsource-variable/bricolage-grotesque": "5.3.0",
"@fontsource-variable/inter": "5.3.0",
Expand Down
4 changes: 2 additions & 2 deletions src/components/docs/Footer.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ const community = 'https://github.com/LeanMeanCleanApps/.github/blob/main';
<a href="https://github.com/sponsors/stphnwlsh">Sponsor</a>
</nav>
<p>
Made by the <a href="/">Lean Mean Clean Apps</a> team with Clean Architecture, strong coffee and a healthy
disregard for pomp and ceremony. Not affiliated with Microsoft, AWS, Uncle Bob or anyone with a marketing budget.
Made by the <a href="/">Lean Mean Clean Apps</a> team with strong coffee and a healthy disregard for pomp and
ceremony. Not affiliated with Uncle Bob, a certain billionaire's chest piece, or anyone with a marketing budget.
</p>
</div>

Expand Down
13 changes: 9 additions & 4 deletions src/components/docs/Header.astro
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const isBrand = key === brand.key;
)
}
<Search />
<a class="cl-icon sl-hidden md:sl-flex" href="https://github.com/LeanMeanCleanApps" aria-label="Lean Mean Clean Apps on GitHub">
<a class="cl-icon" href="https://github.com/LeanMeanCleanApps" aria-label="Lean Mean Clean Apps on GitHub">
<svg viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" width="20" height="20">
<path d="M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.3.8-.6v-2c-3.3.7-4-1.6-4-1.6-.6-1.4-1.4-1.8-1.4-1.8-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.8 1.3 3.5 1 0-.8.4-1.3.7-1.6-2.7-.3-5.5-1.3-5.5-5.9 0-1.3.5-2.4 1.2-3.2 0-.3-.5-1.5.1-3.2 0 0 1-.3 3.3 1.2a11.5 11.5 0 0 1 6 0C17.3 4.7 18.3 5 18.3 5c.7 1.7.2 2.9.1 3.2.8.8 1.2 1.9 1.2 3.2 0 4.6-2.8 5.6-5.5 5.9.5.4.9 1 .9 2.2v3.3c0 .3.1.7.8.6A12 12 0 0 0 12 .3" />
</svg>
Expand Down Expand Up @@ -126,15 +126,20 @@ const isBrand = key === brand.key;
background: var(--sl-color-gray-6);
}

/* On a phone the organisation keeps only its mark and GitHub waits in the menu. */
/* On a phone the organisation keeps only its mark, and on the smallest screens it gives its space to the title. */
@media (max-width: 50rem) {
.cl-org span,
.cl-icon {
.cl-org span {
display: none;
}

.cl-org {
margin-inline-end: 0.25rem;
}
}

@media (max-width: 30rem) {
.cl-org {
display: none;
}
}
</style>
4 changes: 2 additions & 2 deletions src/components/docs/Hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ const isBrand = key === brand.key;
const match = /^(.*?),?\s*(minus the ceremony\.?)$/i.exec(title);
const [lead, accent] = match ? [match[1].trim() + ',', match[2].replace(/\.?$/, '.')] : [title, ''];

const solution = template?.key === 'lambda' ? 'WorksOnMyMachine.Reviews' : 'WorksOnMyMachine.Movies';
const solution = 'WorksOnMyMachine.Movies';
const commands = template
? [
`dotnet new install ${template.package}`,
`dotnet new ${template.shortName} --name ${solution} --authorName "Your Name"`,
`dotnet new ${template.shortName} --name ${solution}`,
`cd ${solution} && docker compose up -d`,
]
: [];
Expand Down
16 changes: 3 additions & 13 deletions src/components/docs/Sidebar.astro
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
// The five templates and the brand sit at the top of the sidebar, each with its ring logo, and the sidebar below belongs to the one you're in.
// The five templates and the brand sit at the top of the sidebar, one line each with its ring logo, and the sidebar below belongs to the one you're in.
import Default from '@astrojs/starlight/components/Sidebar.astro';
import { brand, templates } from '../../data/templates';
import { brand } from '../../data/templates';

const { topics } = Astro.locals.starlightSidebarTopics;
const keyOf = (link: string) => link.split('/').filter(Boolean)[0];
Expand All @@ -12,7 +12,6 @@ const keyOf = (link: string) => link.split('/').filter(Boolean)[0];
{
topics.map((topic) => {
const key = keyOf(topic.link);
const t = key === brand.key ? brand : templates.find((x) => x.key === key);
const logo = key === brand.key ? '/logos/org-spectrum-small-tile.svg' : `/logos/${key}-small.svg`;
const hue = key === brand.key ? 'org' : key;
return (
Expand All @@ -24,7 +23,6 @@ const keyOf = (link: string) => link.split('/').filter(Boolean)[0];
>
<img src={logo} alt="" width="24" height="24" />
<span>{topic.label}</span>
{t && <small>{t.subject}</small>}
</a>
</li>
);
Expand Down Expand Up @@ -55,7 +53,7 @@ const keyOf = (link: string) => link.split('/').filter(Boolean)[0];
grid-template-columns: 1.5rem 1fr;
column-gap: 0.6rem;
align-items: center;
padding: 0.35rem 0.6rem;
padding: 0.3rem 0.6rem;
border-radius: 0.75rem;
border: 1px solid transparent;
color: var(--sl-color-gray-2);
Expand All @@ -65,18 +63,10 @@ const keyOf = (link: string) => link.split('/').filter(Boolean)[0];
}

.cl-topics img {
grid-row: span 2;
width: 1.5rem;
height: 1.5rem;
}

.cl-topics small {
grid-column: 2;
font-weight: 400;
font-size: var(--sl-text-xs);
color: var(--sl-color-gray-3);
}

.cl-topics a:hover {
color: var(--sl-color-white);
border-color: color-mix(in srgb, var(--cl-topic-hue) 45%, transparent);
Expand Down
4 changes: 3 additions & 1 deletion src/content/docs/brand/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
---
title: Lean Mean Clean Apps – Brand
title: Brand kit
description: Logos, colours, type and social templates for every Lean Mean Clean Apps template.
template: splash
editUrl: false
lastUpdated: false
hero:
title: Everything that makes us look this clean
tagline: Logos, colours, type and social templates for every Lean Mean Clean Apps template. Take what you need and put it everywhere.
Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/graphql/explanation/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ There's a little CQRS in the style of the commands and queries, but reads and wr

In textbook Clean Architecture, Presentation never references Infrastructure. Here it does, so it can wire up dependency injection without a fourth project whose only job is to call two methods.

To keep that from turning into a free-for-all, every class in Infrastructure is `internal`. Presentation can call `AddInfrastructure` and nothing else. If you try to new up a repository from a resolver, the compiler will politely decline.
To keep that from turning into a free-for-all, every class in Infrastructure except `DependencyInjection` is `internal`. Presentation can call `AddInfrastructure` and nothing else. If you try to new up a repository from a resolver, the compiler will politely decline.

## What happens on a query

Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/graphql/explanation/decisions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,4 @@ The one rule we bend is letting Presentation reference Infrastructure for depend

## Tests that don't share

Every test class gets its own in-memory SQLite database, built from the real migrations and the same fixed seed. Tests can run in any order, in parallel, forever, and still agree with each other.
Every test gets its own in-memory SQLite database, built from the real migrations and the same fixed seed. Tests can run in any order, in parallel, forever, and still agree with each other.
27 changes: 4 additions & 23 deletions src/content/docs/graphql/explanation/how-its-built.mdx
Original file line number Diff line number Diff line change
@@ -1,36 +1,17 @@
---
title: "How it's built"
description: "The pipeline, the gates and the scanners that keep every pull request honest."
description: "How the template repository itself is run: the rules on main, the monthly release and where these docs come from."
sidebar:
order: 3
---

import ReportCard from '../../../../components/docs/ReportCard.astro';
This page is about the template repository, not your solution. Your solution starts with a simpler pipeline, and [Set up CI](/graphql/guides/set-up-ci/) covers what it runs, along with every check the template runs on itself and what each one needs if you want to borrow it.

This page is about how we build the template itself. Your solution starts with a simpler pipeline, covered in [Set up CI](/graphql/guides/set-up-ci/), and you can borrow any of the checks below when you're ready.

These are templates we build for fun, but the pipeline doesn't know that. Every pull request goes through the same checks, and some of them are allowed to say no.

**Gatekeepers** fail the build and block the merge. **Tattletales** report what they find and leave the decision to a human.

## The checks

<ReportCard
checks={[
{ name: 'Build', icon: 'approve-check', grade: 'Gatekeeper', href: 'https://github.com/LeanMeanCleanApps/CleanGraphQLApi/actions/workflows/build-pipeline.yml', what: 'Builds the solution and the image, checks formatting with dotnet format, and packs the template, failing if the package is over 1 MB. Warnings fail the build.' },
{ name: 'Test', icon: 'analytics', grade: 'Gatekeeper', href: 'https://github.com/LeanMeanCleanApps/CleanGraphQLApi/actions/workflows/build-pipeline.yml', what: 'Runs every test inside the Dockerfile and fails under 95% line coverage across the whole solution. Then it installs the packed template, creates a new solution and tests that too.' },
{ name: 'CodeQL', icon: 'seti:code-search', grade: 'Gatekeeper', href: 'https://codeql.github.com', what: 'Static analysis of the C# and of the GitHub Actions workflows themselves, with the security-and-quality queries.' },
{ name: 'Dependency review', icon: 'list-format', grade: 'Gatekeeper', href: 'https://github.com/actions/dependency-review-action', what: 'Fails a pull request that adds a package with a high or critical vulnerability, or one under a licence outside MIT, Apache 2.0 and BSD.' },
{ name: 'Smoke test', icon: 'rocket', grade: 'Gatekeeper', href: 'https://github.com/LeanMeanCleanApps/CleanGraphQLApi/actions/workflows/build-pipeline.yml', what: 'Starts the image and queries the first page of movies. A scan proves nothing about whether the thing actually runs.' },
{ name: 'Trivy', icon: 'magnifier', grade: 'Gatekeeper', href: 'https://trivy.dev', what: 'Scans the container image and the Dockerfile for high and critical problems that have a fix available, and reports them to GitHub code scanning.' },
{ name: 'Summary', icon: 'document', grade: 'Tattletale', href: 'https://github.com/LeanMeanCleanApps/CleanGraphQLApi/actions/workflows/build-pipeline.yml', what: 'Sums every check up in one comment on the pull request, so you know exactly who to blame. It needs no accounts, tokens or secrets, so a copy of the template gets it too.' },
{ name: 'Dependabot', icon: 'random', grade: 'Tattletale', href: 'https://docs.github.com/code-security/dependabot', what: 'One grouped pull request on the 15th of each month for NuGet packages, the Docker Compose images and GitHub Actions.' },
]}
/>
These are templates we build for fun, but the repository doesn't know that. Every pull request goes through the same checks, and some of them are allowed to say no.

## The rules on main

- Every change goes through a pull request, and every check above must pass.
- Every change goes through a pull request, and every check must pass.
- Only the maintainer can merge. Pull requests from anyone are welcome, but the green button has one owner.
- Commits must be signed, and merges are squashed so the history stays readable.
- Release tags can't be moved or deleted once they exist, so a version always means the same code.
Expand Down
2 changes: 1 addition & 1 deletion src/content/docs/graphql/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ By the end of this page you'll have your own solution, a GraphQL API running on

You need the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). [Docker](https://www.docker.com/products/docker-desktop) is optional, but it gets you the Aspire dashboard for the logs, traces and metrics, and a container you can ship, so it's worth having around.

## Make it yours
## Create and run your solution

<Steps>

Expand Down
6 changes: 3 additions & 3 deletions src/content/docs/graphql/guides/add-a-field.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ sidebar:

import { Steps, Aside } from '@astrojs/starlight/components';

This guide adds `bestReview` to the `Movie` type: the highest rated review of the movie, or null when it has none. `Movie` already carries its reviews, so this is pure Presentation work, a resolver on the type. For a field that needs new data, add a query to `src/Application` first and send it through Mediator the way the existing resolvers do.
This guide adds `bestReview` to the `Movie` type: the highest rated review of the movie, or null when it has none. `Movie` already carries its reviews, so this is pure Presentation work, a resolver on the type. For a field that needs new data, add a query to `src/Application` first and send it through Mediator the way the existing resolvers do. [Make it yours](/graphql/guides/make-it-yours/) walks through a whole new entity, from the Application layer to the tests.

<Aside type="note" title="Types are source generated">
Every object type in `src/Presentation/GraphQL/Types` is a `static partial class` with `[ObjectType<T>]`. Hot Chocolate's source generator turns the `Configure` method and any public static methods into the schema at build time, and `AddPresentationTypes()` in `ProgramExtensions.cs` registers the lot. Nothing is discovered by reflection at startup.
Expand Down Expand Up @@ -63,7 +63,7 @@ This guide adds `bestReview` to the `Movie` type: the highest rated review of th
curl "https://localhost:7331/graphql?sdl" > tests/Presentation.Tests.Integration/GraphQL/Schema/schema.graphql
```

The diff is the one field, described:
The snapshot is copied to the test output when the project builds, so run `dotnet test` without `--no-build` or it keeps comparing the old copy. The diff is the one field, described:

```graphql ins={3-4}
"A movie that can be reviewed."
Expand Down Expand Up @@ -93,7 +93,7 @@ This guide adds `bestReview` to the `Movie` type: the highest rated review of th
public async Task Movie_ShouldReturn_BestReview()
{
// Arrange
using var client = Application.CreateClient();
using var client = this.application.CreateClient();
using var setupResponse = await client.GetAsync("/graphql?query={movies(first:50){nodes{id,reviews{id}}}}");
var setupResult = (await setupResponse.Content.ReadAsStringAsync()).Deserialize<GraphData>();
var movieId = setupResult.Data.Movies.Nodes.First(m => m.Reviews.Length > 0).Id;
Expand Down
16 changes: 11 additions & 5 deletions src/content/docs/graphql/guides/database.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Set `ConnectionStrings__MovieReviews`. To keep everything in memory, which is wh
```bash
docker run --rm -p 7330:7330 -e DOTNET_ENVIRONMENT=docker \
-e ConnectionStrings__MovieReviews="Data Source=MovieReviews;Mode=Memory;Cache=Shared" \
acme-movies:local
worksonmymachine-movies:local
```

## Add a migration
Expand All @@ -41,15 +41,21 @@ After you change the data model in `src/Infrastructure`:

```bash
dotnet tool install --global dotnet-ef
dotnet ef migrations add AddDirectors --project ./src/Infrastructure --output-dir Databases/MovieReviews/Migrations
dotnet ef migrations add AddDirectors \
--project ./src/Infrastructure \
--output-dir Databases/MovieReviews/Migrations \
--namespace WorksOnMyMachine.Movies.Infrastructure.Databases.MovieReviews.Migrations
sed -i '1s/^\xEF\xBB\xBF//' src/Infrastructure/Databases/MovieReviews/Migrations/*.cs
```

The next start applies it.
Without `--namespace` the migration lands in a namespace of its own, and the `sed` strips the byte order mark the tool adds. On macOS, use `sed -i ''`. Then make `// <auto-generated />` the first line of the new `<timestamp>_AddDirectors.cs`, as it is in the other migrations, so the analysers and `dotnet format` leave it alone. Swap `WorksOnMyMachine.Movies` for your solution's name.

The next start applies it, and [Make it yours](/graphql/guides/make-it-yours/) adds a whole entity this way.

<Aside type="note" title="The tests have their own copy">
Every test class builds its own in-memory SQLite database from the same migrations and the same seed, so `dotnet test` never touches `movie-reviews.db`. You can run the tests while the API is running and neither will notice.
Every test builds its own in-memory SQLite database from the same migrations and the same seed, so `dotnet test` never touches `movie-reviews.db`. You can run the tests while the API is running and neither will notice.
</Aside>

## Swap SQLite for something real

Everything database-shaped lives in Infrastructure, and every class there is `internal`. To move to PostgreSQL, SQL Server or anything else, implement the repository interfaces from Application (`IMoviesRepository`, `IAuthorsRepository` and `IReviewsRepository`) against the new store and register them in `Infrastructure/DependencyInjection.cs`. Application and Presentation don't need to know it happened. That's the whole sales pitch of Clean Architecture in one paragraph.
Everything database-shaped lives in Infrastructure, and every class there but `DependencyInjection` is `internal`. To move to PostgreSQL, SQL Server or anything else, implement the repository interfaces from Application (`IMoviesRepository`, `IAuthorsRepository` and `IReviewsRepository`) against the new store and register them in `Infrastructure/DependencyInjection.cs`. Application and Presentation don't need to know it happened. That's the whole sales pitch of Clean Architecture in one paragraph.
Loading
Loading