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
6 changes: 4 additions & 2 deletions .agents/skills/website-audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ description: Audits a website for SEO, AI search performance, structured data, m

## RestClient.Net context

The website is in `Website/`, uses Eleventy and `eleventy-plugin-techdoc`, and builds with `npm ci` then `npm run build` from that directory. Edit sources, not `Website/_site/`. Use `npx @11ty/eleventy --serve --port=8081` for an isolated preview: the existing npm dev command terminates processes on port 8080. The deployment workflow is `.github/workflows/deploy-website.yml`.
The website is in `Website/`, uses Eleventy with local layouts, and builds with `npm ci` then `npm run build` from that directory. It needs Node.js 22 and the .NET 9 SDK for source API export. Edit sources, not `Website/_site/` or generated `Website/src/api/reference/`. `npm run dev` previews on port 4173 without terminating other processes. The deployment workflow is `.github/workflows/deploy-website.yml`.

The user's total CSS budget is 2,500 raw UTF-8 bytes. Keep the local theme and shared `.prose` styling; do not reintroduce `eleventy-plugin-techdoc`, whose mandatory stylesheet alone exceeds that budget. `npm run build` audits the generated output. Run `npm run test:unit` and `npm test` for export, budget, accessibility, motion, and route regressions. Production URLs come from `SITE_URL` and `SITE_PATH_PREFIX`, populated by GitHub Pages metadata.

# Website Audit

Expand Down Expand Up @@ -36,7 +38,7 @@ Audit Progress:
- [ ] Step 12: Report findings
```

- **Theme:** dev-tool/docs sites MUST use [`eleventy-plugin-techdoc`](https://github.com/Nimblesite/eleventy-plugin-techdoc) on Eleventy 3.x. Verify it is the theme in use, and **upgrade it (and `@11ty/eleventy`) to the latest version** before auditing, then rebuild and audit the upgraded output.
- **Theme:** retain the repository's local Eleventy layouts and shared prose stylesheet within the user's CSS budget. Dependency upgrades are a separate authorized task; do not replace the theme as part of an audit.
- Check the outputted HTML/CSS/JavaScript AFTER the website is generated by the static content generator. - Don't just check the static content before the website is generated.
- Fix issues at the core where the static content templates are stored - not in the outputted HTML (e.g. _site)
- Never manually edit the generated website content directly
Expand Down
48 changes: 42 additions & 6 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
@@ -1,50 +1,86 @@
name: Deploy Website to GitHub Pages
name: Website

on:
pull_request:
branches:
- main
push:
branches:
- main

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: "pages"
group: "pages-${{ github.ref }}"
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pages: read
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
cache: "npm"
cache-dependency-path: Website/package-lock.json

- name: Setup .NET for source API export
uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"

- name: Install dependencies
working-directory: Website
run: npm ci

- name: Build website
- name: Test API export and website guards
working-directory: Website
run: npm run test:unit

- name: Build and audit local-root website
working-directory: Website
run: npm run build

- name: Install browser
working-directory: Website
run: npx playwright install --with-deps chromium

- name: Test website interactions and content
working-directory: Website
run: npm test

- name: Setup Pages
id: pages
uses: actions/configure-pages@v5

- name: Build and audit production URLs
working-directory: Website
env:
SITE_URL: ${{ steps.pages.outputs.origin }}
SITE_PATH_PREFIX: ${{ steps.pages.outputs.base_path }}
run: npm run build

- name: Upload artifact
if: github.event_name == 'push'
uses: actions/upload-pages-artifact@v3
with:
path: Website/_site

deploy:
if: github.event_name == 'push'
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ __pycache__/
# Website
Website/node_modules/
Website/_site/
Website/_site-prefix/
Website/playwright-report/
Website/test-results/
Website/src/api/
Website/src/api/reference/
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@ The existing dotnet commands are the local equivalents of the Makefile commands

Use .NET 8 and 9 runtimes for the repository's targets. Run focused tests while iterating, then the relevant CI checks. Preserve the existing assertions and regression coverage. Generated sample code is regenerated by the existing build targets.

## Website

The Eleventy site uses local layouts and a shared `.prose` class for docs, API references, and blog posts. Total shipped CSS must remain at or below **2,500 raw UTF-8 bytes**, including every stylesheet. Do not bypass the budget with inline styles, external CSS, or JavaScript that injects CSS. `npm run build` enforces the budget and checks generated links and metadata.

API reference pages are exported from C# declarations and XML comments by `Website/tools/ApiDocs/`; edit the source rather than generated `Website/src/api/reference/` files. Run `npm run test:unit`, `npm run build`, and `npm test` in `Website/`. The deployment workflow also verifies the GitHub Pages path prefix. See `Website/README.md`.

## Git and CI

Use a feature branch and a PR to `main`; derive the PR title and description from the diff with `origin/main`. Follow the user's authorization for committing, pushing, and merging. Monitor the latest PR commit's checks and resolve failures before merging. Do not add AI co-author trailers.
Expand Down
61 changes: 61 additions & 0 deletions Website/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# RestClient.Net website

The site uses Eleventy, local layouts, and one shared `.prose` class for guides,
API reference pages, and articles. Its interactive HTTP/Result diagram is drawn
in canvas; the code examples and outcome controls remain ordinary accessible HTML.

## Develop and verify

Install Node.js 22 and the .NET 9 SDK, then run these commands from `Website/`:

```sh
npm ci
npm run dev
```

The preview uses port 4173. It does not terminate other processes.

```sh
npm run test:unit
npm run build
npm test
```

The build exports API documentation, renders the site, and audits the result.
The browser tests require Chromium: `npx playwright install chromium` installs it.

English and Chinese example pages render `examples/Program.cs`. The unit checks
compile that source against RestClient.Net 7.3.1 and exercise requests and errors
through a local message handler, without making network requests.

The CSS budget is **2,500 raw UTF-8 bytes across all shipped CSS files**. The build
guard also rejects inline styles, external stylesheets, and JavaScript that injects
CSS. Canvas drawing is used for the actual motion graphic, not to replace content
layout or typography. Reduced-motion preferences and the animation pause control
are respected.

## API reference

`npm run generate-api` runs `scripts/generate-api-docs.js`, which invokes the
Roslyn exporter under `tools/ApiDocs/`. It reads public C# declarations and XML
comments from the library source and writes deterministic Markdown and JSON to
`src/api/reference/`. This generated directory is ignored by Git and recreated
on every build. Edit C# declarations and comments to update the reference; curated
API guides remain tracked under `src/api/`.

Exporter tests change fixture declarations and comments, exercise overloads, and
check that private implementation details stay out of the reference.

## Deployment URLs

Local previews use root-relative routes. Set `SITE_URL` to the production origin
and `SITE_PATH_PREFIX` to the mount path when deploying, for example:

```sh
SITE_URL=https://melbournedeveloper.github.io \
SITE_PATH_PREFIX=/RestClient.Net/ npm run build
```

The GitHub Pages workflow reads these values from `actions/configure-pages`.
It runs unit and browser tests before building and auditing the production URLs.
Pull requests run these checks; only pushes to `main` deploy the resulting site.
67 changes: 41 additions & 26 deletions Website/eleventy.config.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
import techdoc from "eleventy-plugin-techdoc";
import syntaxHighlight from "@11ty/eleventy-plugin-syntaxhighlight";
import markdownIt from "markdown-it";
import anchor from "markdown-it-anchor";

export default function(eleventyConfig) {
eleventyConfig.addPlugin(techdoc, {
site: {
name: "RestClient.Net",
url: "https://restclient.net",
description: "The safest way to make REST calls in C#. Built with functional programming, type safety, and modern .NET patterns.",
},
features: {
blog: true,
docs: true,
darkMode: true,
i18n: true,
},
i18n: {
defaultLanguage: 'en',
languages: ['en', 'zh'],
},
const origin = process.env.SITE_URL || "https://melbournedeveloper.github.io";
const prefix = "/" + (process.env.SITE_PATH_PREFIX || "").replace(/^\/+|\/+$/g, "") + "/";
const basePath = prefix === "//" ? "/" : prefix;
const absoluteUrl = value => new URL(String(value || "/").replace(/^\//,""), new URL(basePath,origin)).href;
export default function (config) {
config.addPlugin(syntaxHighlight);
config.setLibrary("md", markdownIt({html:true,linkify:true}).use(anchor, {slugify:s=>s.toLowerCase().replace(/\s+/g,"-").replace(/[^\p{L}\p{N}_-]/gu,"")}));
config.addPassthroughCopy("src/assets");
config.addPassthroughCopy({"src/api/reference/api.json":"api/reference/api.json", "src/api/reference/schema.json":"api/reference/schema.json"});
config.addCollection("posts", api=>api.getFilteredByGlob("src/blog/*.md").sort((a,b)=>b.date-a.date));
config.addCollection("zhposts", api=>api.getFilteredByGlob("src/zh/blog/*.md").sort((a,b)=>b.date-a.date));
config.addCollection("journalArchives", api => {
const archives = [];
for (const lang of ["en", "zh"]) {
const prefix = lang === "zh" ? "/zh" : "";
const posts = api.getFilteredByGlob(`src${prefix}/blog/*.md`).sort((a,b)=>b.date-a.date);
for (const kind of ["tags", "categories"]) {
archives.push({url:`${prefix}/blog/${kind}/`,lang,title:lang === "zh" ? (kind === "tags" ? "博客主题" : "博客分类") : (kind === "tags" ? "Journal topics" : "Journal categories"),posts});
const values = [...new Set(posts.flatMap(p=>kind === "tags" ? (p.data.tags || []).filter(t=>!["post","posts"].includes(t)) : p.data.category ? [p.data.category] : []))];
for (const value of values) archives.push({url:`${prefix}/blog/${kind}/${value.toLowerCase().replace(/[^a-z0-9]+/g,"-")}/`,lang,title:(lang === "zh" ? "博客 / " : "Journal / ")+value,posts:posts.filter(p=>kind === "tags" ? (p.data.tags || []).includes(value) : p.data.category === value)});
}
}
return archives;
});

eleventyConfig.addPassthroughCopy("src/assets");

return {
dir: { input: "src", output: "_site" },
markdownTemplateEngine: "njk",
pathPrefix: "/RestClient.Net/",
};
config.addCollection("publicPages", api=>api.getAll().filter(p=>p.url && !p.data.eleventyExcludeFromCollections));
config.addFilter("isoDate", value=>new Date(value).toISOString());
config.addFilter("dateFormat", value=>new Date(value).toLocaleDateString("en",{year:"numeric",month:"long",day:"numeric",timeZone:"UTC"}));
config.addFilter("xmlEscape", value=>String(value??"").replace(/[<>&"']/g,c=>({"<":"&lt;",">":"&gt;","&":"&amp;",'"':"&quot;","'":"&apos;"}[c])));
config.addFilter("absoluteUrl", absoluteUrl);
config.addFilter("translationUrl", (url,lang,pages=[])=>{const route=(lang==="zh"?"/zh":"")+url.replace(/^\/zh(?=\/)/,"");return pages.some(p=>p.url===route)?route:(lang==="zh"?"/zh/":"/");});
config.addFilter("hasTranslation",(url,lang,pages=[])=>pages.some(p=>p.url===(lang==="zh"?"/zh":"")+url.replace(/^\/zh(?=\/)/,"")));
config.addFilter("section", url=>url.replace(/^\/zh(?=\/)/,"").split("/")[1]||"home");
config.addFilter("withoutHeading", html=>html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/,""));
config.addFilter("json", value=>JSON.stringify(value).replace(/</g,"\\u003c"));
config.addTransform("deploymentPaths", function (content) {
if (!(this.page?.outputPath || this.outputPath || "").endsWith(".html") || basePath === "/") return content;
return content.replace(/(href|src|action)=("|')\/(?!\/)([^"']*)\2/g, (all,attr,quote,path)=>`${attr}=${quote}${path.startsWith(basePath.slice(1))?"/":""}${path.startsWith(basePath.slice(1))?path:basePath+path}${quote}`);
});
return {dir:{input:"src",output:"_site"},markdownTemplateEngine:"njk",pathPrefix:basePath};
}
12 changes: 12 additions & 0 deletions Website/examples/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<EnableNETAnalyzers>false</EnableNETAnalyzers>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="RestClient.Net" Version="7.3.1" />
</ItemGroup>
</Project>
8 changes: 8 additions & 0 deletions Website/examples/Examples.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Tests/**/*.cs" />
</ItemGroup>
</Project>
86 changes: 86 additions & 0 deletions Website/examples/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Net.Http.Json;
using Outcome;
using RestClient.Net;
using Urls;

namespace WebsiteExamples;

public static class RestClientExamples
{
// This runnable entry point calls JSONPlaceholder. Tests use a local handler instead.
public static async Task Main()
{
using var client = new HttpClient();
using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(15));
var result = await GetPostAsync(client, 1, cancellation.Token);
Console.WriteLine(Describe(result));
}

public static Task<Result<Post, HttpError<string>>> GetPostAsync(
HttpClient client,
int id,
CancellationToken cancellationToken = default
) =>
client.GetAsync(
url: $"https://jsonplaceholder.typicode.com/posts/{id}".ToAbsoluteUrl(),
deserializeSuccess: ReadPostAsync,
deserializeError: ReadErrorAsync,
headers: new Dictionary<string, string> { ["Accept"] = "application/json" },
cancellationToken: cancellationToken
);

public static Task<Result<Post, HttpError<string>>> CreatePostAsync(
HttpClient client,
CreatePost request,
CancellationToken cancellationToken = default
) =>
client.PostAsync(
url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(),
requestBody: JsonContent.Create(request),
deserializeSuccess: ReadPostAsync,
deserializeError: ReadErrorAsync,
cancellationToken: cancellationToken
);

// Register a named client with services.AddHttpClient("posts") in your application.
public static Task<Result<Post, HttpError<string>>> GetUsingFactoryAsync(
IHttpClientFactory factory,
int id,
CancellationToken cancellationToken = default
) =>
factory.GetAsync(
clientName: "posts",
url: $"https://jsonplaceholder.typicode.com/posts/{id}".ToAbsoluteUrl(),
deserializeSuccess: ReadPostAsync,
deserializeError: ReadErrorAsync,
cancellationToken: cancellationToken
);

// Both success and failure are explicit. HTTP failures retain the response body.
public static string Describe(Result<Post, HttpError<string>> result) =>
result.Match(
onSuccess: post => $"Post {post.Id}: {post.Title}",
onError: error =>
error.Match(
onException: exception => $"Connection failed: {exception.Message}",
onErrorResponse: (body, status, headers) => $"HTTP {(int)status}: {body}"
)
);

// Deserialize<T> receives HttpResponseMessage: read its Content property.
private static async Task<Post> ReadPostAsync(
HttpResponseMessage response,
CancellationToken cancellationToken
) =>
await response.Content.ReadFromJsonAsync<Post>(cancellationToken)
?? throw new InvalidDataException("The response contained no post.");

private static Task<string> ReadErrorAsync(
HttpResponseMessage response,
CancellationToken cancellationToken
) => response.Content.ReadAsStringAsync(cancellationToken);
}

public sealed record Post(int UserId, int Id, string Title, string Body);

public sealed record CreatePost(int UserId, string Title, string Body);
9 changes: 9 additions & 0 deletions Website/examples/Tests/Examples.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<StartupObject>WebsiteExamples.ExampleTests</StartupObject>
</PropertyGroup>
<ItemGroup>
<Compile Include="../Program.cs" Link="DocumentedExample.cs" />
</ItemGroup>
</Project>
Loading
Loading