From d6609beac6bde16cfd6be80f12523e5c4e8d45e6 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:33:07 +1000 Subject: [PATCH 1/3] Rebuild documentation site with native motion and source API references --- .agents/skills/website-audit/SKILL.md | 6 +- .github/workflows/deploy-website.yml | 48 +- .gitignore | 3 +- AGENTS.md | 6 + Website/README.md | 61 + Website/eleventy.config.js | 67 +- Website/examples/Directory.Build.props | 12 + Website/examples/Examples.csproj | 8 + Website/examples/Program.cs | 86 ++ Website/examples/Tests/Examples.Tests.csproj | 9 + Website/examples/Tests/Program.cs | 183 +++ Website/package-lock.json | 887 +++-------- Website/package.json | 20 +- Website/playwright.config.js | 13 +- Website/scripts/check-site.js | 185 +++ Website/scripts/generate-api-docs.js | 1285 +-------------- Website/scripts/generate-api-docs.sh | 19 +- Website/src/_data/examples.js | 4 + Website/src/_data/site.js | 8 + Website/src/_data/site.json | 12 - Website/src/_includes/home.njk | 41 + Website/src/_includes/layouts/api.njk | 4 + Website/src/_includes/layouts/base.njk | 29 + Website/src/_includes/layouts/blog.njk | 4 + Website/src/_includes/layouts/docs.njk | 5 + Website/src/api/deleteasync.md | 58 + Website/src/api/getasync.md | 73 + Website/src/api/httpclient-extensions.md | 33 + Website/src/api/index.njk | 7 + Website/src/api/mcp-generator.md | 107 ++ Website/src/api/openapi-generator.md | 103 ++ Website/src/api/patchasync.md | 57 + Website/src/api/postasync.md | 73 + Website/src/api/putasync.md | 57 + Website/src/api/result-types.md | 168 ++ Website/src/api/serialization.md | 163 ++ Website/src/assets/css/styles.css | 1462 +----------------- Website/src/assets/images/mark.svg | 1 + Website/src/assets/images/social-card.png | Bin 0 -> 36202 bytes Website/src/assets/images/social-card.svg | 1 + Website/src/assets/js/site.js | 124 ++ Website/src/blog/index.njk | 7 + Website/src/docs/api/mcp-generator.md | 4 +- Website/src/docs/api/openapi-generator.md | 4 +- Website/src/docs/exhaustion.md | 5 + Website/src/examples/index.njk | 298 +--- Website/src/feed.njk | 22 + Website/src/index.njk | 114 +- Website/src/journal-archives.njk | 13 + Website/src/llms.txt.njk | 22 + Website/src/robots.txt.njk | 9 + Website/src/sitemap.njk | 9 + Website/src/zh/api/index.njk | 77 +- Website/src/zh/blog/index.njk | 7 + Website/src/zh/docs/api/index.md | 16 +- Website/src/zh/docs/exhaustion.md | 5 + Website/src/zh/examples/index.njk | 295 +--- Website/src/zh/index.njk | 109 +- Website/tests-node/api-export.test.js | 179 +++ Website/tests-node/examples.test.js | 28 + Website/tests-node/site-guards.test.js | 160 ++ Website/tests/api.test.js | 6 +- Website/tests/blog.test.js | 7 +- Website/tests/chinese-i18n.test.js | 6 +- Website/tests/docs.test.js | 2 +- Website/tests/fixtures.js | 25 + Website/tests/homepage.test.js | 2 +- Website/tests/motion-prose.test.js | 195 +++ Website/tests/seo.test.js | 2 +- Website/tests/syntax-highlighting.test.js | 2 +- Website/tests/visual-qa.test.js | 365 ++--- Website/tools/ApiDocs/ApiDocs.csproj | 12 + Website/tools/ApiDocs/Directory.Build.props | 11 + Website/tools/ApiDocs/Program.cs | 584 +++++++ Website/tools/ApiDocs/README.md | 43 + Website/tools/ApiDocs/schema.json | 51 + 76 files changed, 3684 insertions(+), 4504 deletions(-) create mode 100644 Website/README.md create mode 100644 Website/examples/Directory.Build.props create mode 100644 Website/examples/Examples.csproj create mode 100644 Website/examples/Program.cs create mode 100644 Website/examples/Tests/Examples.Tests.csproj create mode 100644 Website/examples/Tests/Program.cs create mode 100644 Website/scripts/check-site.js create mode 100644 Website/src/_data/examples.js create mode 100644 Website/src/_data/site.js delete mode 100644 Website/src/_data/site.json create mode 100644 Website/src/_includes/home.njk create mode 100644 Website/src/_includes/layouts/api.njk create mode 100644 Website/src/_includes/layouts/base.njk create mode 100644 Website/src/_includes/layouts/blog.njk create mode 100644 Website/src/_includes/layouts/docs.njk create mode 100644 Website/src/api/deleteasync.md create mode 100644 Website/src/api/getasync.md create mode 100644 Website/src/api/httpclient-extensions.md create mode 100644 Website/src/api/index.njk create mode 100644 Website/src/api/mcp-generator.md create mode 100644 Website/src/api/openapi-generator.md create mode 100644 Website/src/api/patchasync.md create mode 100644 Website/src/api/postasync.md create mode 100644 Website/src/api/putasync.md create mode 100644 Website/src/api/result-types.md create mode 100644 Website/src/api/serialization.md create mode 100644 Website/src/assets/images/mark.svg create mode 100644 Website/src/assets/images/social-card.png create mode 100644 Website/src/assets/images/social-card.svg create mode 100644 Website/src/assets/js/site.js create mode 100644 Website/src/blog/index.njk create mode 100644 Website/src/feed.njk create mode 100644 Website/src/journal-archives.njk create mode 100644 Website/src/llms.txt.njk create mode 100644 Website/src/robots.txt.njk create mode 100644 Website/src/sitemap.njk create mode 100644 Website/src/zh/blog/index.njk create mode 100644 Website/tests-node/api-export.test.js create mode 100644 Website/tests-node/examples.test.js create mode 100644 Website/tests-node/site-guards.test.js create mode 100644 Website/tests/fixtures.js create mode 100644 Website/tests/motion-prose.test.js create mode 100644 Website/tools/ApiDocs/ApiDocs.csproj create mode 100644 Website/tools/ApiDocs/Directory.Build.props create mode 100644 Website/tools/ApiDocs/Program.cs create mode 100644 Website/tools/ApiDocs/README.md create mode 100644 Website/tools/ApiDocs/schema.json diff --git a/.agents/skills/website-audit/SKILL.md b/.agents/skills/website-audit/SKILL.md index 990cd96f..b1270fac 100644 --- a/.agents/skills/website-audit/SKILL.md +++ b/.agents/skills/website-audit/SKILL.md @@ -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 @@ -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 diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 9851adb2..5adfede4 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -1,22 +1,27 @@ -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 @@ -24,27 +29,58 @@ jobs: - 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 }} diff --git a/.gitignore b/.gitignore index e7e9c6f6..3d371c48 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index 23191bf5..efd7c2f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Website/README.md b/Website/README.md new file mode 100644 index 00000000..9cb0282e --- /dev/null +++ b/Website/README.md @@ -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. diff --git a/Website/eleventy.config.js b/Website/eleventy.config.js index a8e3ca95..496c5f02 100644 --- a/Website/eleventy.config.js +++ b/Website/eleventy.config.js @@ -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=>({"<":"<",">":">","&":"&",'"':""","'":"'"}[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(/]*>[\s\S]*?<\/h1>/,"")); + config.addFilter("json", value=>JSON.stringify(value).replace(/`${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}; } diff --git a/Website/examples/Directory.Build.props b/Website/examples/Directory.Build.props new file mode 100644 index 00000000..68594e4c --- /dev/null +++ b/Website/examples/Directory.Build.props @@ -0,0 +1,12 @@ + + + net9.0 + enable + enable + false + true + + + + + diff --git a/Website/examples/Examples.csproj b/Website/examples/Examples.csproj new file mode 100644 index 00000000..c1581b5d --- /dev/null +++ b/Website/examples/Examples.csproj @@ -0,0 +1,8 @@ + + + Exe + + + + + diff --git a/Website/examples/Program.cs b/Website/examples/Program.cs new file mode 100644 index 00000000..99e76347 --- /dev/null +++ b/Website/examples/Program.cs @@ -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>> 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 { ["Accept"] = "application/json" }, + cancellationToken: cancellationToken + ); + + public static Task>> 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>> 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> 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 receives HttpResponseMessage: read its Content property. + private static async Task ReadPostAsync( + HttpResponseMessage response, + CancellationToken cancellationToken + ) => + await response.Content.ReadFromJsonAsync(cancellationToken) + ?? throw new InvalidDataException("The response contained no post."); + + private static Task 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); diff --git a/Website/examples/Tests/Examples.Tests.csproj b/Website/examples/Tests/Examples.Tests.csproj new file mode 100644 index 00000000..86aa4422 --- /dev/null +++ b/Website/examples/Tests/Examples.Tests.csproj @@ -0,0 +1,9 @@ + + + Exe + WebsiteExamples.ExampleTests + + + + + diff --git a/Website/examples/Tests/Program.cs b/Website/examples/Tests/Program.cs new file mode 100644 index 00000000..4a094feb --- /dev/null +++ b/Website/examples/Tests/Program.cs @@ -0,0 +1,183 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using Outcome; + +namespace WebsiteExamples; + +internal static class ExampleTests +{ + private static int assertions; + + public static async Task Main() + { + using var handler = new LocalHandler(); + using var client = new HttpClient(handler); + var success = await RestClientExamples.GetPostAsync(client, 7); + Check(success.IsOk && !success.IsError, "GET returns a success"); + Check(RestClientExamples.Describe(success) == "Post 7: Local post", "success display"); + Check(handler.LastMethod == HttpMethod.Get, "GET method"); + Check( + handler.LastUri == "https://jsonplaceholder.typicode.com/posts/7", + "absolute GET URL" + ); + Check(handler.Accept == "application/json", "request headers"); + success.Tap(post => + Check(post.UserId == 3 && post.Body == "Local body", "typed response fields") + ); + + handler.Status = HttpStatusCode.NotFound; + var missing = await RestClientExamples.GetPostAsync(client, 404); + Check(missing.IsError && !missing.IsOk, "HTTP error is a failure result"); + Check( + RestClientExamples.Describe(missing) == "HTTP 404: Missing post", + "HTTP error retains status and body" + ); + missing.Tap(onError: error => + { + Check(error.IsErrorResponse && !error.IsExceptionError, "HTTP error classification"); + _ = error.Match( + onException: _ => throw new InvalidOperationException("Unexpected network error"), + onErrorResponse: (body, status, headers) => + { + Check( + body == "Missing post" && status == HttpStatusCode.NotFound, + "structured HTTP error" + ); + Check( + headers.GetValues("X-Test").Single() == "local", + "response headers preserved" + ); + return body; + } + ); + }); + + handler.Failure = new HttpRequestException("Offline fixture"); + var offline = await RestClientExamples.GetPostAsync(client, 1); + Check(offline.IsError, "network exceptions become failure results"); + Check( + RestClientExamples.Describe(offline) == "Connection failed: Offline fixture", + "network error display" + ); + offline.Tap(onError: error => + Check(error.IsExceptionError && !error.IsErrorResponse, "network error classification") + ); + + handler.Failure = null; + handler.Status = HttpStatusCode.Created; + var created = await RestClientExamples.CreatePostAsync( + client, + new CreatePost(3, "A new post", "New body") + ); + Check(created.IsOk, "POST returns success"); + Check(handler.LastMethod == HttpMethod.Post, "POST method"); + Check(handler.LastUri == "https://jsonplaceholder.typicode.com/posts", "absolute POST URL"); + Check( + handler.ContentType == "application/json; charset=utf-8", + "JSON request content type" + ); + using var body = JsonDocument.Parse(handler.Body!); + Check( + body.RootElement.GetProperty("title").GetString() == "A new post", + "POST title serialized" + ); + Check( + body.RootElement.GetProperty("body").GetString() == "New body", + "POST body serialized" + ); + Check(body.RootElement.GetProperty("userId").GetInt32() == 3, "POST user ID serialized"); + + var factory = new LocalFactory(client); + var factoryResult = await RestClientExamples.GetUsingFactoryAsync(factory, 19); + Check(factoryResult.IsOk, "factory request succeeds"); + Check(factory.Name == "posts", "named factory client selected"); + Check( + handler.LastUri!.EndsWith("/posts/19", StringComparison.Ordinal), + "factory receives absolute request URL" + ); + + using var cancelled = new CancellationTokenSource(); + cancelled.Cancel(); + var cancellation = await RestClientExamples.GetPostAsync(client, 1, cancelled.Token); + Check(cancellation.IsError, "cancellation is explicit"); + cancellation.Tap(onError: error => + { + Check(error.IsExceptionError, "cancellation classification"); + _ = error.Match( + onException: exception => + { + Check( + exception is OperationCanceledException, + "original cancellation exception retained" + ); + return "cancelled"; + }, + onErrorResponse: (_, _, _) => + throw new InvalidOperationException("Unexpected HTTP response") + ); + }); + Check(handler.Calls == 6, "all live calls were intercepted by the local handler"); + Console.WriteLine($"PASS: {assertions} example assertions; no network requests."); + } + + private static void Check(bool condition, string description) + { + if (!condition) + throw new InvalidOperationException(description); + assertions++; + } + + private sealed class LocalFactory(HttpClient client) : IHttpClientFactory + { + public string? Name { get; private set; } + + public HttpClient CreateClient(string name) + { + Name = name; + return client; + } + } + + private sealed class LocalHandler : HttpMessageHandler + { + public HttpStatusCode Status { get; set; } = HttpStatusCode.OK; + public Exception? Failure { get; set; } + public int Calls { get; private set; } + public HttpMethod? LastMethod { get; private set; } + public string? LastUri { get; private set; } + public string? Accept { get; private set; } + public string? Body { get; private set; } + public string? ContentType { get; private set; } + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken + ) + { + Calls++; + LastMethod = request.Method; + LastUri = request.RequestUri!.AbsoluteUri; + Accept = string.Join(",", request.Headers.Accept); + cancellationToken.ThrowIfCancellationRequested(); + if (Failure is not null) + throw Failure; + Body = request.Content is null + ? null + : await request.Content.ReadAsStringAsync(cancellationToken); + ContentType = request.Content?.Headers.ContentType?.ToString(); + var response = new HttpResponseMessage(Status) + { + Content = new StringContent( + Status == HttpStatusCode.NotFound + ? "Missing post" + : "{\"userId\":3,\"id\":7,\"title\":\"Local post\",\"body\":\"Local body\"}", + Encoding.UTF8, + "application/json" + ), + }; + response.Headers.Add("X-Test", "local"); + return response; + } + } +} diff --git a/Website/package-lock.json b/Website/package-lock.json index 1ce1af13..92567d72 100644 --- a/Website/package-lock.json +++ b/Website/package-lock.json @@ -8,17 +8,22 @@ "name": "restclient-net-website", "version": "1.0.0", "dependencies": { - "eleventy-plugin-techdoc": "^0.1.0" + "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2", + "markdown-it": "14.3.2", + "markdown-it-anchor": "9.2.0" }, "devDependencies": { "@11ty/eleventy": "^3.1.2", - "@playwright/test": "^1.40.0" + "@playwright/test": "^1.40.0", + "acorn": "8.18.0", + "parse5": "8.0.1" } }, "node_modules/@11ty/dependency-tree": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@11ty/dependency-tree/-/dependency-tree-4.0.2.tgz", "integrity": "sha512-RTF6VTZHatYf7fSZBUN3RKwiUeJh5dhWV61gDPrHhQF2/gzruAkYz8yXuvGLx3w3ZBKreGrR+MfYpSVkdbdbLA==", + "dev": true, "license": "MIT", "dependencies": { "@11ty/eleventy-utils": "^2.0.1" @@ -28,6 +33,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@11ty/dependency-tree-esm/-/dependency-tree-esm-2.0.4.tgz", "integrity": "sha512-MYKC0Ac77ILr1HnRJalzKDlb9Z8To3kXQCltx299pUXXUFtJ1RIONtULlknknqW8cLe19DLVgmxVCtjEFm7h0A==", + "dev": true, "license": "MIT", "dependencies": { "@11ty/eleventy-utils": "^2.0.7", @@ -37,44 +43,45 @@ } }, "node_modules/@11ty/eleventy": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.2.tgz", - "integrity": "sha512-IcsDlbXnBf8cHzbM1YBv3JcTyLB35EK88QexmVyFdVJVgUU6bh9g687rpxryJirHzo06PuwnYaEEdVZQfIgRGg==", + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@11ty/eleventy/-/eleventy-3.1.6.tgz", + "integrity": "sha512-ZlSiR1PLdS2lv7TelBgWAhcvMiLNZkPBlLEb+lh7kGYZ+Mk0bo9qcYgVsewvw9W7Em0RH3wd01h5fAstNDh0zA==", + "dev": true, "license": "MIT", "dependencies": { - "@11ty/dependency-tree": "^4.0.0", - "@11ty/dependency-tree-esm": "^2.0.0", + "@11ty/dependency-tree": "^4.0.2", + "@11ty/dependency-tree-esm": "^2.0.4", "@11ty/eleventy-dev-server": "^2.0.8", - "@11ty/eleventy-plugin-bundle": "^3.0.6", + "@11ty/eleventy-plugin-bundle": "^3.0.7", "@11ty/eleventy-utils": "^2.0.7", "@11ty/lodash-custom": "^4.17.21", - "@11ty/posthtml-urls": "^1.0.1", - "@11ty/recursive-copy": "^4.0.2", + "@11ty/posthtml-urls": "^1.0.3", + "@11ty/recursive-copy": "^4.0.4", "@sindresorhus/slugify": "^2.2.1", "bcp-47-normalize": "^2.3.0", "chokidar": "^3.6.0", - "debug": "^4.4.1", + "debug": "^4.4.3", "dependency-graph": "^1.0.0", "entities": "^6.0.1", "filesize": "^10.1.6", "gray-matter": "^4.0.3", "iso-639-1": "^3.1.5", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "kleur": "^4.1.5", - "liquidjs": "^10.21.1", - "luxon": "^3.6.1", - "markdown-it": "^14.1.0", + "liquidjs": "^10.27.0", + "luxon": "^3.7.2", + "markdown-it": "^14.2.0", "minimist": "^1.2.8", - "moo": "^0.5.2", + "moo": "0.5.2", "node-retrieve-globals": "^6.0.1", "nunjucks": "^3.2.4", - "picomatch": "^4.0.2", + "picomatch": "^4.0.4", "please-upgrade-node": "^3.2.0", - "posthtml": "^0.16.6", + "posthtml": "^0.16.7", "posthtml-match-helper": "^2.0.3", - "semver": "^7.7.2", - "slugify": "^1.6.6", - "tinyglobby": "^0.2.14" + "semver": "^7.8.1", + "slugify": "^1.6.9", + "tinyglobby": "^0.2.16" }, "bin": { "eleventy": "cmd.cjs" @@ -91,6 +98,7 @@ "version": "2.0.8", "resolved": "https://registry.npmjs.org/@11ty/eleventy-dev-server/-/eleventy-dev-server-2.0.8.tgz", "integrity": "sha512-15oC5M1DQlCaOMUq4limKRYmWiGecDaGwryr7fTE/oM9Ix8siqMvWi+I8VjsfrGr+iViDvWcH/TVI6D12d93mA==", + "dev": true, "license": "MIT", "dependencies": { "@11ty/eleventy-utils": "^2.0.1", @@ -117,32 +125,11 @@ "url": "https://opencollective.com/11ty" } }, - "node_modules/@11ty/eleventy-navigation": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-navigation/-/eleventy-navigation-0.3.5.tgz", - "integrity": "sha512-4aKW5aIQDFed8xs1G1pWcEiFPcDSwZtA4IH1eERtoJ+Xy+/fsoe0pzbDmw84bHZ9ACny5jblENhfZhcCxklqQw==", - "license": "MIT", - "dependencies": { - "dependency-graph": "^0.11.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/11ty" - } - }, - "node_modules/@11ty/eleventy-navigation/node_modules/dependency-graph": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", - "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/@11ty/eleventy-plugin-bundle": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-bundle/-/eleventy-plugin-bundle-3.0.7.tgz", "integrity": "sha512-QK1tRFBhQdZASnYU8GMzpTdsMMFLVAkuU0gVVILqNyp09xJJZb81kAS3AFrNrwBCsgLxTdWHJ8N64+OTTsoKkA==", + "dev": true, "license": "MIT", "dependencies": { "@11ty/eleventy-utils": "^2.0.2", @@ -157,22 +144,6 @@ "url": "https://opencollective.com/11ty" } }, - "node_modules/@11ty/eleventy-plugin-rss": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-rss/-/eleventy-plugin-rss-2.0.4.tgz", - "integrity": "sha512-LF60sGVlxGTryQe3hTifuzrwF8R7XbrNsM2xfcDcNMSliLN4kmB+7zvoLRySRx0AQDjqhPTAeeeT0ra6/9zHUQ==", - "license": "MIT", - "dependencies": { - "@11ty/eleventy-utils": "^2.0.0", - "@11ty/posthtml-urls": "^1.0.1", - "debug": "^4.4.0", - "posthtml": "^0.16.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/11ty" - } - }, "node_modules/@11ty/eleventy-plugin-syntaxhighlight": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@11ty/eleventy-plugin-syntaxhighlight/-/eleventy-plugin-syntaxhighlight-5.0.2.tgz", @@ -190,6 +161,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@11ty/eleventy-utils/-/eleventy-utils-2.0.7.tgz", "integrity": "sha512-6QE+duqSQ0GY9rENXYb4iPR4AYGdrFpqnmi59tFp9VrleOl0QSh8VlBr2yd6dlhkdtj7904poZW5PvGr9cMiJQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -203,6 +175,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/@11ty/lodash-custom/-/lodash-custom-4.17.21.tgz", "integrity": "sha512-Mqt6im1xpb1Ykn3nbcCovWXK3ggywRJa+IXIdoz4wIIK+cvozADH63lexcuPpGS/gJ6/m2JxyyXDyupkMr5DHw==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -213,9 +186,10 @@ } }, "node_modules/@11ty/posthtml-urls": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.2.tgz", - "integrity": "sha512-0vaV3Wt0surZ+oS1VdKKe0axeeupuM+l7W/Z866WFQwF+dGg2Tc/nmhk/5l74/Y55P8KyImnLN9CdygNw2huHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@11ty/posthtml-urls/-/posthtml-urls-1.0.3.tgz", + "integrity": "sha512-1YvhnkaNlFnnJic1rBMWmTC2adbuy+JQiBfl1Hecr1Wjjik1pQZmGyk/eC9zKX/FQv52s2Nht1Gi/UwhYqrBeg==", + "dev": true, "license": "MIT", "dependencies": { "evaluate-value": "^2.0.0", @@ -228,354 +202,21 @@ } }, "node_modules/@11ty/recursive-copy": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.3.tgz", - "integrity": "sha512-SX48BTLEGX8T/OsKWORsHAAeiDsbFl79Oa/0Wg/mv/d27b7trCVZs7fMHvpSgDvZz/fZqx5rDk8+nx5oyT7xBw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@11ty/recursive-copy/-/recursive-copy-4.0.4.tgz", + "integrity": "sha512-oI7m8pa7/IAU/3lqRU9vjBbs20iKFo7x+1K9kT3aVira6scc1X9MjBdgLCHzLJeJ7iB6wydioA+kr9/qPnvmlQ==", + "dev": true, "license": "ISC", "dependencies": { "errno": "^1.0.0", "junk": "^3.1.0", - "maximatch": "^0.1.0", + "minimatch": "^3.1.5", "slash": "^3.0.0" }, "engines": { "node": ">=18" } }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@playwright/test": { "version": "1.58.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", @@ -596,6 +237,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/transliterate": "^1.0.0", @@ -612,6 +254,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^5.0.0" @@ -631,9 +274,9 @@ "peer": true }, "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", "license": "MIT", "peer": true, "dependencies": { @@ -652,12 +295,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "dev": true, "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -670,6 +315,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -678,34 +324,11 @@ "node": ">=0.4.0" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -716,9 +339,10 @@ } }, "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -733,61 +357,25 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, - "node_modules/array-differ": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", - "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-union": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", - "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", - "license": "MIT", - "dependencies": { - "array-uniq": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-uniq": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", - "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/bcp-47": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.0.tgz", "integrity": "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==", + "dev": true, "license": "MIT", "dependencies": { "is-alphabetical": "^2.0.0", @@ -803,6 +391,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -813,6 +402,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/bcp-47-normalize/-/bcp-47-normalize-2.3.0.tgz", "integrity": "sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==", + "dev": true, "license": "MIT", "dependencies": { "bcp-47": "^2.0.0", @@ -827,6 +417,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -839,6 +430,7 @@ "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -849,6 +441,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -857,16 +450,11 @@ "node": ">=8" } }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "license": "MIT" - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -887,37 +475,11 @@ "fsevents": "~2.3.2" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -927,12 +489,14 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -950,6 +514,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -959,6 +524,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz", "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -968,6 +534,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.0.1", @@ -982,6 +549,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, "license": "BSD-2-Clause", "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -991,6 +559,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, "funding": [ { "type": "github", @@ -1003,6 +572,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.2.0" @@ -1018,6 +588,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^1.0.1", @@ -1032,41 +603,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/eleventy-plugin-techdoc": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/eleventy-plugin-techdoc/-/eleventy-plugin-techdoc-0.1.0.tgz", - "integrity": "sha512-XAMXG5j+jpOpmojAet5ZwSyB7hV5JhFkAW6viZn9nxu0BjLUwg7hMQ7MKmpyKdWZiBjOQpcHlDmSgK75pD2qTw==", - "license": "MIT", - "dependencies": { - "@11ty/eleventy-navigation": "^0.3.5", - "@11ty/eleventy-plugin-rss": "^2.0.2", - "@11ty/eleventy-plugin-syntaxhighlight": "^5.0.0", - "@inquirer/prompts": "^7.0.0", - "markdown-it": "^14.1.0", - "markdown-it-anchor": "^9.2.0" - }, - "bin": { - "eleventy-plugin-techdoc": "bin/init.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@11ty/eleventy": "^3.1.2" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1076,6 +620,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1088,6 +633,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/errno/-/errno-1.0.0.tgz", "integrity": "sha512-3zV5mFS1E8/1bPxt/B0xxzI1snsg3uSCIh6Zo1qKg6iMw93hzPANk9oBFzSFBFrwuVoQuE3rLoouAUfwOAj1wQ==", + "dev": true, "license": "MIT", "dependencies": { "prr": "~1.0.1" @@ -1100,12 +646,14 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, "license": "MIT" }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1118,6 +666,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/esm-import-transformer/-/esm-import-transformer-3.0.5.tgz", "integrity": "sha512-1GKLvfuMnnpI75l8c6sHoz0L3Z872xL5akGuBudgqTDPv4Vy6f2Ec7jEMKTxlqWl/3kSvNbHELeimJtnqgYniw==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.15.0" @@ -1127,6 +676,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -1140,6 +690,7 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1149,6 +700,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/evaluate-value/-/evaluate-value-2.0.0.tgz", "integrity": "sha512-VonfiuDJc0z4sOO7W0Pd130VLsXN6vmBWZlrog1mCb/o7o/Nl5Lr25+Kj/nkCCAhG+zqeeGjxhkK9oHpkgTHhQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -1158,6 +710,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, "license": "MIT", "dependencies": { "is-extendable": "^0.1.0" @@ -1170,6 +723,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1187,6 +741,7 @@ "version": "10.1.6", "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">= 10.4.0" @@ -1196,6 +751,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -1208,6 +764,7 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dev": true, "license": "MIT", "dependencies": { "debug": "2.6.9", @@ -1226,6 +783,7 @@ "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "2.0.0" @@ -1235,12 +793,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, "license": "MIT" }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -1250,6 +810,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1264,6 +825,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -1276,6 +838,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, "license": "MIT", "dependencies": { "js-yaml": "^3.13.1", @@ -1291,6 +854,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -1300,6 +864,7 @@ "version": "3.15.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -1313,6 +878,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-7.2.0.tgz", "integrity": "sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==", + "dev": true, "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -1332,6 +898,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz", "integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1344,6 +911,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-equiv-refresh/-/http-equiv-refresh-2.0.1.tgz", "integrity": "sha512-XJpDL/MLkV3dKwLzHwr2dY05dYNfBNlyPu4STQ8WvKCFdc6vC5tPXuq28of663+gHVg03C+16pHHs/+FmmDjcw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -1353,6 +921,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, "license": "MIT", "dependencies": { "depd": "~2.0.0", @@ -1369,32 +938,18 @@ "url": "https://opencollective.com/express" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1405,6 +960,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dev": true, "license": "MIT", "dependencies": { "is-alphabetical": "^2.0.0", @@ -1419,6 +975,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -1431,6 +988,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1441,6 +999,7 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1450,24 +1009,17 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -1480,12 +1032,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-json/-/is-json-2.0.1.tgz", "integrity": "sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==", + "dev": true, "license": "ISC" }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -1495,6 +1049,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz", "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0" @@ -1504,6 +1059,7 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, "funding": [ { "type": "github", @@ -1526,6 +1082,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/junk/-/junk-3.1.0.tgz", "integrity": "sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1535,6 +1092,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1544,6 +1102,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1572,6 +1131,7 @@ "version": "10.30.0", "resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.30.0.tgz", "integrity": "sha512-Fw4wA+8CZsJkaeLEIlPE3AoponfjEZhj7FQvYNcAEcg7jNRik7mEMoaco382FBdrYlykVf6w4vClPNctz2OYjg==", + "dev": true, "license": "MIT", "dependencies": { "commander": "^10.0.0" @@ -1592,12 +1152,14 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/list-to-array/-/list-to-array-1.1.0.tgz", "integrity": "sha512-+dAZZ2mM+/m+vY9ezfoueVvrgnHIGi5FvgSymbIgJOFwiznWyA59mav95L+Mc6xPtL3s9gm5eNTlNtxJLbNM1g==", + "dev": true, "license": "MIT" }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1652,21 +1214,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/maximatch": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/maximatch/-/maximatch-0.1.0.tgz", - "integrity": "sha512-9ORVtDUFk4u/NFfo0vG/ND/z7UQCVZBL539YW0+U1I7H1BkZwizcPx5foFv7LCPcBnm2U6RjFnQOsIvN4/Vm2A==", - "license": "MIT", - "dependencies": { - "array-differ": "^1.0.0", - "array-union": "^1.0.1", - "arrify": "^1.0.0", - "minimatch": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -1677,6 +1224,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -1689,6 +1237,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -1698,6 +1247,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -1711,9 +1261,10 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -1726,6 +1277,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -1735,6 +1287,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -1744,33 +1297,28 @@ "version": "0.5.2", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/morphdom": { "version": "2.7.8", "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.8.tgz", "integrity": "sha512-D/fR4xgGUyVRbdMGU6Nejea1RFzYxYtyurG4Fbv2Fi/daKlWKuXGLOdXtl+3eIwL110cI2hz1ZojGICjjFLgTg==", + "dev": true, "license": "MIT" }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/node-retrieve-globals": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/node-retrieve-globals/-/node-retrieve-globals-6.0.1.tgz", "integrity": "sha512-j0DeFuZ/Wg3VlklfbxUgZF/mdHMTEiEipBb3q0SpMMbHaV3AVfoUQF8UGxh1s/yjqO0TgRZd4Pi/x2yRqoQ4Eg==", + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.14.1", @@ -1782,6 +1330,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -1791,6 +1340,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "a-sync-waterfall": "^1.0.0", @@ -1816,6 +1366,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 6" @@ -1825,6 +1376,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -1837,21 +1389,50 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "dev": true, "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1911,6 +1492,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", "integrity": "sha512-gQR3WpIgNIKwBMVLkpMUeR3e1/E1y42bqDQZfql+kDeXd8COYfM8PQA4X6y7a8u9Ua9FHmsrrmirW2vHs45hWg==", + "dev": true, "license": "MIT", "dependencies": { "semver-compare": "^1.0.0" @@ -1920,6 +1502,7 @@ "version": "0.16.7", "resolved": "https://registry.npmjs.org/posthtml/-/posthtml-0.16.7.tgz", "integrity": "sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==", + "dev": true, "license": "MIT", "dependencies": { "posthtml-parser": "^0.11.0", @@ -1933,6 +1516,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/posthtml-match-helper/-/posthtml-match-helper-2.0.3.tgz", "integrity": "sha512-p9oJgTdMF2dyd7WE54QI1LvpBIkNkbSiiECKezNnDVYhGhD1AaOnAkw0Uh0y5TW+OHO8iBdSqnd8Wkpb6iUqmw==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1945,6 +1529,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/posthtml-parser/-/posthtml-parser-0.11.0.tgz", "integrity": "sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==", + "dev": true, "license": "MIT", "dependencies": { "htmlparser2": "^7.1.1" @@ -1957,6 +1542,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/posthtml-render/-/posthtml-render-3.0.0.tgz", "integrity": "sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==", + "dev": true, "license": "MIT", "dependencies": { "is-json": "^2.0.1" @@ -1978,6 +1564,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, "license": "MIT" }, "node_modules/punycode.js": { @@ -1993,6 +1580,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -2002,6 +1590,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -2011,9 +1600,10 @@ } }, "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -2022,16 +1612,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, "node_modules/section-matter": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, "license": "MIT", "dependencies": { "extend-shallow": "^2.0.1", @@ -2042,9 +1627,10 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2057,12 +1643,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "dev": true, "license": "MIT" }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", @@ -2089,33 +1677,24 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/slugify": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.6.tgz", - "integrity": "sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==", + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.0.0" @@ -2125,12 +1704,14 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/ssri": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/ssri/-/ssri-11.0.0.tgz", "integrity": "sha512-aZpUoMN/Jj2MqA4vMCeiKGnc/8SuSyHbGSBdgFbZxP8OJGF/lFkIuElzPxsN0q8TQQ+prw3P4EDfB3TBHHgfXw==", + "dev": true, "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -2143,54 +1724,31 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom-string": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -2203,6 +1761,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -2215,6 +1774,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -2230,6 +1790,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -2239,26 +1800,14 @@ "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "dev": true, "license": "MIT" }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2275,18 +1824,6 @@ "optional": true } } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/Website/package.json b/Website/package.json index dcb947d6..3c7746d9 100644 --- a/Website/package.json +++ b/Website/package.json @@ -5,15 +5,21 @@ "type": "module", "scripts": { "generate-api": "node scripts/generate-api-docs.js", - "dev": "lsof -ti:8080 | xargs kill -9 2>/dev/null; npx @11ty/eleventy --serve --port=8080", - "build": "npm run generate-api && npx @11ty/eleventy", - "test": "playwright test" - }, - "dependencies": { - "eleventy-plugin-techdoc": "^0.1.0" + "dev": "npm run generate-api && eleventy --serve --port=4173", + "build": "npm run generate-api && npm run clean && eleventy && node scripts/check-site.js", + "test:unit": "node --test tests-node/*.test.js", + "test": "playwright test", + "clean": "node -e \"require('node:fs').rmSync('_site', {recursive: true, force: true})\"" }, "devDependencies": { "@11ty/eleventy": "^3.1.2", - "@playwright/test": "^1.40.0" + "@playwright/test": "^1.40.0", + "acorn": "8.18.0", + "parse5": "8.0.1" + }, + "dependencies": { + "@11ty/eleventy-plugin-syntaxhighlight": "5.0.2", + "markdown-it": "14.3.2", + "markdown-it-anchor": "9.2.0" } } diff --git a/Website/playwright.config.js b/Website/playwright.config.js index 94f93b70..b585e19e 100644 --- a/Website/playwright.config.js +++ b/Website/playwright.config.js @@ -1,20 +1,23 @@ import { defineConfig } from '@playwright/test'; +const baseURL = process.env.WEBSITE_TEST_BASE_URL || 'http://127.0.0.1:4173'; +const port = new URL(baseURL).port || '4173'; + export default defineConfig({ testDir: './tests', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, + workers: process.env.CI ? 2 : 4, reporter: 'list', use: { - baseURL: 'http://localhost:8080', + baseURL, trace: 'on-first-retry', }, webServer: { - command: 'npx @11ty/eleventy --serve --port=8080', - url: 'http://localhost:8080', - reuseExistingServer: !process.env.CI, + command: `python3 -m http.server ${port} --bind 127.0.0.1 --directory _site`, + url: baseURL, + reuseExistingServer: false, timeout: 120000, }, }); diff --git a/Website/scripts/check-site.js b/Website/scripts/check-site.js new file mode 100644 index 00000000..d0cf1224 --- /dev/null +++ b/Website/scripts/check-site.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parse as parseHtml } from 'parse5'; +import { parse as parseJavaScript } from 'acorn'; + +export const CSS_BUDGET = 2500; + +async function filesUnder(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + return (await Promise.all(entries.map(entry => entry.isDirectory() + ? filesUnder(path.join(directory, entry.name)) : path.join(directory, entry.name)))).flat(); +} + +function elements(document) { + const result = []; + const visit = node => { + if (node.tagName) result.push(node); + for (const child of node.childNodes ?? []) visit(child); + if (node.content) visit(node.content); + }; + visit(document); + return result; +} + +const attribute = (node, name) => node.attrs?.find(item => item.name === name)?.value; +const content = node => node.nodeName === '#text' ? node.value : (node.childNodes ?? []).map(content).join(''); +const routeFor = file => `/${file.replaceAll(path.sep, '/').replace(/index\.html$/, '')}`; +const decodeXml = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); + +export function cssInjectionErrors(source, filename = 'script.js') { + const errors = []; + let tree; + try { tree = parseJavaScript(source, { ecmaVersion: 'latest', sourceType: 'module' }); } + catch (error) { return [`${filename}: invalid JavaScript: ${error.message}`]; } + const literal = node => node?.type === 'Literal' ? node.value : node?.type === 'BinaryExpression' && node.operator === '+' && typeof literal(node.left) === 'string' && typeof literal(node.right) === 'string' ? literal(node.left) + literal(node.right) : undefined; + const property = node => node?.computed ? literal(node.property) : node?.property?.name; + const forbidden = new Set(['style', 'cssText', 'styleSheet', 'adoptedStyleSheets', 'CSSStyleSheet', 'insertRule', 'addRule', 'replaceSync', 'setProperty', 'animate']); + const visit = node => { + if (!node || typeof node !== 'object') return; + if (node.type === 'MemberExpression' && forbidden.has(property(node))) errors.push(`${filename}: CSS injection through ${property(node)} is outside the stylesheet budget`); + if (node.type === 'Identifier' && node.name === 'CSSStyleSheet') errors.push(`${filename}: constructed stylesheets bypass the CSS budget`); + if (node.type === 'CallExpression' && property(node.callee) === 'createElement' && node.arguments[0]?.value?.toLowerCase() === 'style') errors.push(`${filename}: dynamically created style element`); + if (node.type === 'CallExpression' && property(node.callee) === 'setAttribute' && node.arguments[0]?.value?.toLowerCase() === 'style') errors.push(`${filename}: dynamically created style attribute`); + if (node.type === 'AssignmentExpression' && property(node.left) === 'rel' && literal(node.right) === 'stylesheet') errors.push(`${filename}: dynamically created stylesheet link`); + if (node.type === 'CallExpression' && property(node.callee) === 'setAttribute' && literal(node.arguments[0]) === 'rel' && literal(node.arguments[1]) === 'stylesheet') errors.push(`${filename}: dynamically created stylesheet link`); + const value = node.type === 'Literal' ? node.value : node.type === 'TemplateElement' ? node.value.raw : null; + if (typeof value === 'string' && /]*\sstyle\s*=/i.test(value)) errors.push(`${filename}: HTML string injects unbudgeted styles`); + for (const [key, value] of Object.entries(node)) { + if (key === 'start' || key === 'end') continue; + if (Array.isArray(value)) value.forEach(visit); + else if (value && typeof value === 'object') visit(value); + } + }; + visit(tree); + return [...new Set(errors)]; +} + +export async function auditSite(directory, options = {}) { + const root = path.resolve(directory); + const configured = new URL(options.siteUrl ?? process.env.SITE_URL ?? 'https://melbournedeveloper.github.io'); + const prefix = `/${(options.pathPrefix ?? process.env.SITE_PATH_PREFIX ?? configured.pathname).replace(/^\/+|\/+$/g, '')}`.replace(/\/?$/, '/'); + const absolute = route => new URL(`${prefix}${route.replace(/^\//, '')}`, configured.origin).href; + const errors = []; + const files = await filesUnder(root); + const css = files.filter(file => /\.css$/i.test(file)); + const cssBytes = (await Promise.all(css.map(file => stat(file)))).reduce((sum, file) => sum + file.size, 0); + if (!css.length) errors.push('No stylesheet was emitted'); + if (cssBytes > (options.cssBudget ?? CSS_BUDGET)) errors.push(`CSS budget exceeded: ${cssBytes} raw UTF-8 bytes > ${options.cssBudget ?? CSS_BUDGET}`); + for (const file of css) if (/@import\b/i.test(await readFile(file, 'utf8'))) errors.push(`${path.relative(root, file)}: @import can load unbudgeted CSS`); + for (const file of files.filter(file => /\.svg$/i.test(file))) { + const nodes = elements(parseHtml(await readFile(file, 'utf8'))); + if (nodes.some(node => node.tagName === 'style' || attribute(node, 'style') !== undefined)) errors.push(`${path.relative(root, file)}: SVG styles bypass the shared stylesheet budget`); + } + + const pages = new Map(); + for (const file of files.filter(file => file.endsWith('.html'))) { + const relative = path.relative(root, file); + const html = await readFile(file, 'utf8'); + const nodes = elements(parseHtml(html)); + const route = routeFor(relative); + pages.set(route, { relative, html, nodes, url: absolute(route), ids: new Set(nodes.map(node => attribute(node, 'id')).filter(Boolean)) }); + } + if (!pages.size) errors.push('No HTML pages were emitted'); + + const resolveLocal = (reference, page) => { + if (/^(?:mailto:|tel:|data:|javascript:)/i.test(reference)) return null; + let url; + try { url = new URL(reference, page.url); } catch { errors.push(`${page.relative}: invalid URL ${reference}`); return null; } + if (url.origin !== configured.origin) return null; + if (!url.pathname.startsWith(prefix)) { errors.push(`${page.relative}: link escapes deployment prefix: ${reference}`); return null; } + let relative; + try { relative = decodeURIComponent(url.pathname.slice(prefix.length)); } catch { errors.push(`${page.relative}: invalid encoded URL ${reference}`); return null; } + let file = path.resolve(root, relative.endsWith('/') || !relative ? `${relative}index.html` : relative); + if (!files.includes(file) && files.includes(path.join(file, 'index.html'))) file = path.join(file, 'index.html'); + if (file !== root && !file.startsWith(`${root}${path.sep}`)) { errors.push(`${page.relative}: link escapes output directory`); return null; } + if (!files.includes(file)) errors.push(`${page.relative}: broken internal link ${reference}`); + const target = pages.get(routeFor(path.relative(root, file))); + if (url.hash && !url.hash.startsWith('#:~:') && target) { + let fragment; + try { fragment = decodeURIComponent(url.hash.slice(1)); } catch { fragment = url.hash.slice(1); } + if (!target.ids.has(fragment)) errors.push(`${page.relative}: missing anchor ${reference}`); + } + return { url, target }; + }; + + const titles = new Map(); + for (const page of pages.values()) { + const { nodes, relative, url } = page; + const matches = tag => nodes.filter(node => node.tagName === tag); + const meta = name => nodes.find(node => node.tagName === 'meta' && (attribute(node, 'name') === name || attribute(node, 'property') === name)); + const title = matches('title').map(content).join('').trim(); + if (!title) errors.push(`${relative}: missing page title`); + if (titles.has(title)) errors.push(`${relative}: duplicate title also used by ${titles.get(title)}`); + titles.set(title, relative); + if (matches('h1').length !== 1) errors.push(`${relative}: expected exactly one h1`); + if (!attribute(meta('description') ?? {}, 'content')?.trim()) errors.push(`${relative}: missing description`); + if (!attribute(meta('viewport') ?? {}, 'content')?.includes('width=device-width')) errors.push(`${relative}: missing responsive viewport`); + if (attribute(meta('robots') ?? {}, 'content')?.includes('noindex')) errors.push(`${relative}: unintended noindex`); + const canonicals = matches('link').filter(node => attribute(node, 'rel') === 'canonical'); + if (canonicals.length !== 1 || attribute(canonicals[0], 'href') !== url) errors.push(`${relative}: canonical must be ${url}`); + for (const name of ['og:title', 'og:description', 'og:image', 'og:url', 'og:type', 'twitter:card', 'twitter:title', 'twitter:description', 'twitter:image']) { + if (!attribute(meta(name) ?? {}, 'content')) errors.push(`${relative}: missing ${name}`); + } + if (attribute(meta('og:url') ?? {}, 'content') !== url) errors.push(`${relative}: og:url differs from canonical`); + const schemas = matches('script').filter(node => attribute(node, 'type') === 'application/ld+json'); + if (!schemas.length) errors.push(`${relative}: missing structured data`); + for (const schema of schemas) { + try { JSON.parse(content(schema)); } catch { errors.push(`${relative}: invalid JSON-LD`); } + } + for (const node of nodes) { + if (node.tagName === 'style' || attribute(node, 'style') !== undefined) errors.push(`${relative}: inline CSS bypasses the shared stylesheet budget`); + if (node.tagName === 'img' && attribute(node, 'alt') === undefined) errors.push(`${relative}: image missing alt text`); + for (const name of ['href', 'src', 'action']) { + const reference = attribute(node, name); + if (reference) resolveLocal(reference, page); + } + if (node.tagName === 'link' && (attribute(node, 'rel')?.split(/\s+/).includes('stylesheet') || attribute(node, 'as') === 'style')) { + const href = attribute(node, 'href') ?? ''; + const target = new URL(href, url); + if (target.origin !== configured.origin || !target.pathname.endsWith('.css')) errors.push(`${relative}: external or embedded stylesheet ${href}`); + } + if (node.tagName === 'script' && attribute(node, 'type') !== 'application/ld+json') { + const src = attribute(node, 'src'); + if (src && new URL(src, url).origin !== configured.origin) errors.push(`${relative}: external JavaScript could inject unbudgeted styles`); + if (!src && content(node).trim()) errors.push(...cssInjectionErrors(content(node), relative)); + } + if (node.tagName === 'link' && attribute(node, 'hreflang')) { + const reference = attribute(node, 'href'); + const target = reference ? resolveLocal(reference, page)?.target : null; + const language = attribute(node, 'hreflang'); + if (!target) errors.push(`${relative}: hreflang does not point to a real local page`); + else if (language !== 'x-default' && attribute(target.nodes.find(item => item.tagName === 'html'), 'lang') !== language) errors.push(`${relative}: hreflang language differs from destination`); + } + } + } + for (const file of files.filter(file => /\.(?:m?js)$/i.test(file))) errors.push(...cssInjectionErrors(await readFile(file, 'utf8'), path.relative(root, file))); + + const required = ['sitemap.xml', 'feed.xml', 'robots.txt', 'llms.txt']; + for (const filename of required) if (!files.includes(path.join(root, filename))) errors.push(`Missing ${filename}`); + if (files.includes(path.join(root, 'sitemap.xml'))) { + const sitemap = await readFile(path.join(root, 'sitemap.xml'), 'utf8'); + const urls = [...sitemap.matchAll(/(.*?)<\/loc>/gs)].map(match => decodeXml(match[1].trim())); + const expected = new Set([...pages.values()].map(page => page.url)); + if (new Set(urls).size !== urls.length) errors.push('Sitemap contains duplicate pages'); + for (const url of urls) if (!expected.has(url)) errors.push(`Sitemap has a nonexistent page: ${url}`); + for (const url of expected) if (!urls.includes(url)) errors.push(`Sitemap omits ${url}`); + } + if (files.includes(path.join(root, 'robots.txt')) && !(await readFile(path.join(root, 'robots.txt'), 'utf8')).includes(`Sitemap: ${absolute('/sitemap.xml')}`)) errors.push('robots.txt points to the wrong sitemap'); + if (files.includes(path.join(root, 'feed.xml'))) { + const feed = await readFile(path.join(root, 'feed.xml'), 'utf8'); + for (const match of feed.matchAll(/]*href="([^"]+)"/g)) resolveLocal(decodeXml(match[1]), { relative: 'feed.xml', url: absolute('/feed.xml') }); + } + return { cssBytes, cssBudget: options.cssBudget ?? CSS_BUDGET, pageCount: pages.size, errors: [...new Set(errors)] }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const report = await auditSite(process.argv[2] ?? '_site'); + console.log(`Website: ${report.pageCount} pages; CSS ${report.cssBytes}/${report.cssBudget} raw bytes.`); + if (report.errors.length) { + console.error(report.errors.join('\n')); + process.exitCode = 1; + } +} diff --git a/Website/scripts/generate-api-docs.js b/Website/scripts/generate-api-docs.js index 4ee9afa4..631c4c8b 100644 --- a/Website/scripts/generate-api-docs.js +++ b/Website/scripts/generate-api-docs.js @@ -1,1255 +1,40 @@ #!/usr/bin/env node - -/** - * Generate API documentation for RestClient.Net from C# source files. - * Extracts XML documentation comments and generates Markdown with proper links. - * - * Source: RestClient.Net repo root (parent of Website folder) - * Output: Website/src/api/ - */ - -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const WEBSITE_DIR = path.dirname(__dirname); -const RESTCLIENT_NET_DIR = path.dirname(WEBSITE_DIR); -const API_OUTPUT_DIR = path.join(WEBSITE_DIR, 'src', 'api'); -const API_OUTPUT_DIR_ZH = path.join(WEBSITE_DIR, 'src', 'zh', 'api'); - -// .NET type to Microsoft docs URL mapping -const DOTNET_DOCS = { - 'HttpClient': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient', - 'IHttpClientFactory': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.ihttpclientfactory', - 'HttpContent': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpcontent', - 'HttpResponseMessage': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage', - 'HttpStatusCode': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode', - 'HttpMethod': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpmethod', - 'CancellationToken': 'https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtoken', - 'Task': 'https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1', - 'Exception': 'https://learn.microsoft.com/en-us/dotnet/api/system.exception', - 'Func': 'https://learn.microsoft.com/en-us/dotnet/api/system.func-2', - 'JsonSerializerOptions': 'https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions', - 'FormUrlEncodedContent': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.formurlencodedcontent', - 'MultipartFormDataContent': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.multipartformdatacontent', - 'XmlSerializer': 'https://learn.microsoft.com/en-us/dotnet/api/system.xml.serialization.xmlserializer', - 'Stream': 'https://learn.microsoft.com/en-us/dotnet/api/system.io.stream', - 'IReadOnlyDictionary': 'https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.ireadonlydictionary-2', -}; - -// Internal links within the API docs -const INTERNAL_LINKS = { - 'Result': '/api/result-types/#resulttsuccessterror', - 'HttpError': '/api/result-types/#httperrorterror', - 'ResponseError': '/api/result-types/#responseerror-properties', - 'ExceptionError': '/api/result-types/#exceptionerror-properties', - 'Deserialize': '/api/serialization/', - 'Serialize': '/api/serialization/', -}; - -// External documentation links -const EXTERNAL_DOCS = { - 'OpenAPI': 'https://swagger.io/specification/', - 'MCP': 'https://modelcontextprotocol.io/', - 'record': 'https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record', - 'switch expression': 'https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression', - 'global using': 'https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive#global-modifier', - 'Roslyn analyzer': 'https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/', -}; - -/** - * Parse XML documentation from a C# file - */ -function parseXmlDoc(content) { - const members = []; - - // Match /// blocks followed by method/class declarations - const docPattern = /\/\/\/\s*\s*([\s\S]*?)\/\/\/\s*<\/summary>([\s\S]*?)(?=public|private|protected|internal|\[)/g; - - let match; - while ((match = docPattern.exec(content)) !== null) { - const summaryLines = match[1].split('\n') - .map(line => line.replace(/^\s*\/\/\/\s*/, '').trim()) - .filter(line => line.length > 0) - .join(' '); - - const additionalDoc = match[2]; - - // Extract param tags - const params = []; - const paramPattern = /\/\/\/\s*(.*?)<\/param>/g; - let paramMatch; - while ((paramMatch = paramPattern.exec(additionalDoc)) !== null) { - params.push({ name: paramMatch[1], description: paramMatch[2].trim() }); - } - - // Extract typeparam tags - const typeParams = []; - const typeParamPattern = /\/\/\/\s*(.*?)<\/typeparam>/g; - let typeParamMatch; - while ((typeParamMatch = typeParamPattern.exec(additionalDoc)) !== null) { - typeParams.push({ name: typeParamMatch[1], description: typeParamMatch[2].trim() }); - } - - // Extract returns tag - const returnsMatch = /\/\/\/\s*(.*?)<\/returns>/s.exec(additionalDoc); - const returns = returnsMatch ? returnsMatch[1].trim() : null; - - members.push({ summary: summaryLines, params, typeParams, returns }); - } - - return members; -} - -/** - * Convert a type name to a linked version - */ -function linkType(typeName) { - // Check .NET docs first - for (const [type, url] of Object.entries(DOTNET_DOCS)) { - if (typeName.includes(type)) { - return typeName.replace(type, `[${type}](${url})`); - } - } - - // Check internal links - for (const [type, url] of Object.entries(INTERNAL_LINKS)) { - if (typeName.includes(type)) { - return typeName.replace(type, `[${type}](${url})`); - } - } - - return typeName; -} - -/** - * Process see cref tags to create links - */ -function processSeeCref(text) { - return text.replace(//g, (match, cref) => { - const typeName = cref.split('.').pop(); - const url = DOTNET_DOCS[typeName] || INTERNAL_LINKS[typeName]; - return url ? `[\`${typeName}\`](${url})` : `\`${typeName}\``; +/** Export actual public C# declarations and XML docs; curated guides stay hand-written. */ +import { execFileSync } from 'node:child_process'; +import { copyFileSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const filename = fileURLToPath(import.meta.url); +const website = path.resolve(path.dirname(filename), '..'); +export const toolProject = path.join(website, 'tools/ApiDocs/ApiDocs.csproj'); + +export function generateApi({ root = path.dirname(website), output = path.join(website, 'src/api/reference'), sourceRef = 'main', projects = [] } = {}) { + const argumentsList = ['run', '--project', toolProject, '--configuration', 'Release', '--no-launch-profile', '--', + '--root', path.resolve(root), '--output', path.resolve(output), '--source-ref', sourceRef]; + if (projects.length) argumentsList.push('--projects', projects.join(',')); + const result = execFileSync('dotnet', argumentsList, { + cwd: website, + encoding: 'utf8', + timeout: 180_000, + maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, DOTNET_PROCESSOR_COUNT: '2', DOTNET_GCHeapHardLimit: '0x40000000', + DOTNET_CLI_TELEMETRY_OPTOUT: '1', MSBUILDDISABLENODEREUSE: '1', UseSharedCompilation: 'false' }, }); -} - -/** - * Generate markdown for a parameter table - */ -function generateParamTable(params, typeParams) { - if (params.length === 0 && typeParams.length === 0) return ''; - - let md = '### Parameters\n\n'; - md += '| Parameter | Type | Description |\n'; - md += '|-----------|------|-------------|\n'; - - for (const tp of typeParams) { - md += `| \`${tp.name}\` | Type parameter | ${processSeeCref(tp.description)} |\n`; - } - - for (const p of params) { - const desc = processSeeCref(p.description); - md += `| \`${p.name}\` | See signature | ${desc} |\n`; - } - - return md + '\n'; -} - -/** - * Generate HttpClient Extensions CLASS SUMMARY page (just a table of methods with links) - */ -function generateHttpClientExtensions() { - return `--- -layout: layouts/api.njk -title: HttpClientExtensions Class -description: Extension methods for HttpClient that return Result types instead of throwing exceptions. -keywords: HttpClientExtensions, HttpClient, REST API, C# HTTP client, extension methods -eleventyNavigation: - key: HttpClient Extensions - parent: API Reference - order: 1 -permalink: /api/httpclient-extensions/ ---- - -Extension methods for [\`HttpClient\`](${DOTNET_DOCS.HttpClient}) that return [\`Result>\`](/api/result/) instead of throwing exceptions. - -## Namespace - -\`RestClient.Net\` - -## Methods - -| Method | Description | -|--------|-------------| -| [GetAsync<TSuccess, TError>](/api/getasync/) | Make a type-safe GET request | -| [PostAsync<TRequest, TSuccess, TError>](/api/postasync/) | Make a type-safe POST request with body | -| [PutAsync<TRequest, TSuccess, TError>](/api/putasync/) | Make a type-safe PUT request for full replacement | -| [DeleteAsync<TSuccess, TError>](/api/deleteasync/) | Make a type-safe DELETE request | -| [PatchAsync<TRequest, TSuccess, TError>](/api/patchasync/) | Make a type-safe PATCH request for partial updates | - -## See Also - -- [Result<TSuccess, TError>](/api/result/) - The discriminated union return type -- [HttpError<TError>](/api/httperror/) - HTTP-specific error wrapper -- [Serialization](/api/serialization/) - Custom serialization and deserialization -`; -} - -/** - * Generate GetAsync METHOD DETAIL page - */ -function generateGetAsync() { - return `--- -layout: layouts/api.njk -title: GetAsync Method -description: Make a type-safe GET request that returns Result instead of throwing exceptions. -keywords: GetAsync, HTTP GET, RestClient.Net, type-safe HTTP -eleventyNavigation: - key: GetAsync - parent: HttpClient Extensions - order: 1 -permalink: /api/getasync/ ---- - -Make a type-safe GET request. - -## Namespace - -\`RestClient.Net\` - -## Containing Type - -[HttpClientExtensions](/api/httpclient-extensions/) - -## Signature - -\`\`\`csharp -public static async Task>> GetAsync( - this HttpClient httpClient, - AbsoluteUrl url, - Func> deserializeSuccess, - Func> deserializeError, - IReadOnlyDictionary? headers = null, - CancellationToken cancellationToken = default -) -\`\`\` - -## Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| \`url\` | \`AbsoluteUrl\` | The request URL (use \`.ToAbsoluteUrl()\` extension) | -| \`deserializeSuccess\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize success response | -| \`deserializeError\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize error response | -| \`headers\` | [\`IReadOnlyDictionary?\`](${DOTNET_DOCS.IReadOnlyDictionary}) | Optional request headers | -| \`cancellationToken\` | [\`CancellationToken\`](${DOTNET_DOCS.CancellationToken}) | Optional cancellation token | - -## Returns - -[\`Task>>\`](${DOTNET_DOCS.Task}) - A discriminated union that is either: -- [\`Ok\`](/api/ok/) - Success with deserialized data -- [\`Error>\`](/api/error/) - Error with [ResponseError](/api/responseerror/) or [ExceptionError](/api/exceptionerror/) - -## Example - -\`\`\`csharp -var result = await httpClient.GetAsync( - url: "https://api.example.com/users/1".ToAbsoluteUrl(), - deserializeSuccess: DeserializeUser, - deserializeError: DeserializeApiError -); - -var output = result switch -{ - OkUser(var user) => $"Found: {user.Name}", - ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", - ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", -}; -\`\`\` - -## See Also - -- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods -- [Result<TSuccess, TError>](/api/result/) - The return type -- [Serialization](/api/serialization/) - Deserializer examples -`; -} - -/** - * Generate PostAsync METHOD DETAIL page - */ -function generatePostAsync() { - return `--- -layout: layouts/api.njk -title: PostAsync Method -description: Make a type-safe POST request with a request body that returns Result instead of throwing exceptions. -keywords: PostAsync, HTTP POST, RestClient.Net, type-safe HTTP -eleventyNavigation: - key: PostAsync - parent: HttpClient Extensions - order: 2 -permalink: /api/postasync/ ---- - -Make a type-safe POST request with a request body. - -## Namespace - -\`RestClient.Net\` - -## Containing Type - -[HttpClientExtensions](/api/httpclient-extensions/) - -## Signature - -\`\`\`csharp -public static async Task>> PostAsync( - this HttpClient httpClient, - AbsoluteUrl url, - TRequest body, - Func serializeRequest, - Func> deserializeSuccess, - Func> deserializeError, - IReadOnlyDictionary? headers = null, - CancellationToken cancellationToken = default -) -\`\`\` - -## Parameters - -| Parameter | Type | Description | -|-----------|------|-------------| -| \`url\` | \`AbsoluteUrl\` | The request URL | -| \`body\` | \`TRequest\` | The request body object | -| \`serializeRequest\` | [\`Func\`](${DOTNET_DOCS.Func}) | Function to serialize the request body | -| \`deserializeSuccess\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize success response | -| \`deserializeError\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize error response | -| \`headers\` | [\`IReadOnlyDictionary?\`](${DOTNET_DOCS.IReadOnlyDictionary}) | Optional request headers | -| \`cancellationToken\` | [\`CancellationToken\`](${DOTNET_DOCS.CancellationToken}) | Optional cancellation token | - -## Returns - -[\`Task>>\`](${DOTNET_DOCS.Task}) - A discriminated union that is either: -- [\`Ok\`](/api/ok/) - Success with deserialized data -- [\`Error>\`](/api/error/) - Error with [ResponseError](/api/responseerror/) or [ExceptionError](/api/exceptionerror/) - -## Example - -\`\`\`csharp -var newUser = new CreateUserRequest("John", "john@example.com"); - -var result = await httpClient.PostAsync( - url: "https://api.example.com/users".ToAbsoluteUrl(), - body: newUser, - serializeRequest: SerializeJson, - deserializeSuccess: DeserializeUser, - deserializeError: DeserializeApiError -); -\`\`\` - -## See Also - -- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods -- [Serialization](/api/serialization/) - Serializer examples -`; -} - -/** - * Generate PutAsync METHOD DETAIL page - */ -function generatePutAsync() { - return `--- -layout: layouts/api.njk -title: PutAsync Method -description: Make a type-safe PUT request for full resource replacement. -keywords: PutAsync, HTTP PUT, RestClient.Net, type-safe HTTP -eleventyNavigation: - key: PutAsync - parent: HttpClient Extensions - order: 3 -permalink: /api/putasync/ ---- - -Make a type-safe PUT request for full resource replacement. - -## Namespace - -\`RestClient.Net\` - -## Containing Type - -[HttpClientExtensions](/api/httpclient-extensions/) - -## Signature - -Same signature as [PostAsync](/api/postasync/). - -\`\`\`csharp -public static async Task>> PutAsync( - this HttpClient httpClient, - AbsoluteUrl url, - TRequest body, - Func serializeRequest, - Func> deserializeSuccess, - Func> deserializeError, - IReadOnlyDictionary? headers = null, - CancellationToken cancellationToken = default -) -\`\`\` - -## Example - -\`\`\`csharp -var updatedUser = new UpdateUserRequest("John Updated", "john.updated@example.com"); - -var result = await httpClient.PutAsync( - url: "https://api.example.com/users/123".ToAbsoluteUrl(), - body: updatedUser, - serializeRequest: SerializeJson, - deserializeSuccess: DeserializeUser, - deserializeError: DeserializeApiError -); -\`\`\` - -## See Also - -- [PostAsync](/api/postasync/) - Same signature, for creating resources -- [PatchAsync](/api/patchasync/) - For partial updates -`; -} - -/** - * Generate DeleteAsync METHOD DETAIL page - */ -function generateDeleteAsync() { - return `--- -layout: layouts/api.njk -title: DeleteAsync Method -description: Make a type-safe DELETE request. -keywords: DeleteAsync, HTTP DELETE, RestClient.Net, type-safe HTTP -eleventyNavigation: - key: DeleteAsync - parent: HttpClient Extensions - order: 4 -permalink: /api/deleteasync/ ---- - -Make a type-safe DELETE request. - -## Namespace - -\`RestClient.Net\` - -## Containing Type - -[HttpClientExtensions](/api/httpclient-extensions/) - -## Signature - -Same signature as [GetAsync](/api/getasync/). - -\`\`\`csharp -public static async Task>> DeleteAsync( - this HttpClient httpClient, - AbsoluteUrl url, - Func> deserializeSuccess, - Func> deserializeError, - IReadOnlyDictionary? headers = null, - CancellationToken cancellationToken = default -) -\`\`\` - -## Example - -\`\`\`csharp -var result = await httpClient.DeleteAsync( - url: "https://api.example.com/users/123".ToAbsoluteUrl(), - deserializeSuccess: async (r, ct) => true, - deserializeError: DeserializeApiError -); - -var output = result switch -{ - Ok(true) => "User deleted", - Error(ResponseError(var err, var status, _)) => $"Error {status}: {err.Message}", - Error(ExceptionError(var ex)) => $"Exception: {ex.Message}", -}; -\`\`\` - -## See Also - -- [GetAsync](/api/getasync/) - Same signature -- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods -`; -} - -/** - * Generate PatchAsync METHOD DETAIL page - */ -function generatePatchAsync() { - return `--- -layout: layouts/api.njk -title: PatchAsync Method -description: Make a type-safe PATCH request for partial updates. -keywords: PatchAsync, HTTP PATCH, RestClient.Net, type-safe HTTP -eleventyNavigation: - key: PatchAsync - parent: HttpClient Extensions - order: 5 -permalink: /api/patchasync/ ---- - -Make a type-safe PATCH request for partial updates. - -## Namespace - -\`RestClient.Net\` - -## Containing Type - -[HttpClientExtensions](/api/httpclient-extensions/) - -## Signature - -Same signature as [PostAsync](/api/postasync/). - -\`\`\`csharp -public static async Task>> PatchAsync( - this HttpClient httpClient, - AbsoluteUrl url, - TRequest body, - Func serializeRequest, - Func> deserializeSuccess, - Func> deserializeError, - IReadOnlyDictionary? headers = null, - CancellationToken cancellationToken = default -) -\`\`\` - -## Example - -\`\`\`csharp -var patch = new PatchUserRequest { Email = "new.email@example.com" }; - -var result = await httpClient.PatchAsync( - url: "https://api.example.com/users/123".ToAbsoluteUrl(), - body: patch, - serializeRequest: SerializeJson, - deserializeSuccess: DeserializeUser, - deserializeError: DeserializeApiError -); -\`\`\` - -## See Also - -- [PostAsync](/api/postasync/) - Same signature, for creating resources -- [PutAsync](/api/putasync/) - For full replacement -`; -} - -/** - * Generate Result Types page - */ -function generateResultTypes() { - return `--- -layout: layouts/api.njk -title: Result Types -description: Complete reference for RestClient.Net Result types - discriminated unions for type-safe HTTP error handling with pattern matching. -keywords: Result types, HttpError, discriminated unions, pattern matching, C# error handling -eleventyNavigation: - key: Result Types - parent: API Reference - order: 2 -permalink: /api/result-types/ ---- - -RestClient.Net uses [discriminated unions](${EXTERNAL_DOCS.record}) to represent HTTP responses. This forces you to handle all possible outcomes at compile time. - -## Result<TSuccess, TError> - -The core result type that represents either success or failure. - -\`\`\`csharp -public abstract record Result -{ - public record Ok(TSuccess Value) : Result; - public record Error(TError Value) : Result; -} -\`\`\` - -### Pattern Matching - -Use [switch expressions](${EXTERNAL_DOCS['switch expression']}) to handle all cases: - -\`\`\`csharp -var message = result switch -{ - Result>.Ok(var user) => - $"Got user: {user.Name}", - - Result>.Error(var error) => - $"Error occurred: {error}" -}; -\`\`\` - -## HttpError<TError> - -Represents HTTP-specific errors. Can be either a response error (server returned an error status) or an exception error (network failure, timeout, etc.). - -\`\`\`csharp -public abstract record HttpError -{ - public record ResponseError( - TError Error, - HttpStatusCode StatusCode, - HttpResponseHeaders Headers - ) : HttpError; - - public record ExceptionError(Exception Exception) : HttpError; -} -\`\`\` - -### ResponseError Properties - -| Property | Type | Description | -|----------|------|-------------| -| \`Error\` | \`TError\` | Your deserialized error model | -| \`StatusCode\` | [\`HttpStatusCode\`](${DOTNET_DOCS.HttpStatusCode}) | The HTTP status code (e.g., 404, 500) | -| \`Headers\` | \`HttpResponseHeaders\` | Response headers for accessing metadata | - -### ExceptionError Properties - -| Property | Type | Description | -|----------|------|-------------| -| \`Exception\` | [\`Exception\`](${DOTNET_DOCS.Exception}) | The caught exception (timeout, network error, etc.) | - -### Full Pattern Matching Example - -\`\`\`csharp -var message = result switch -{ - Result>.Ok(var user) => - $"Success: {user.Name}", - - Result>.Error( - HttpError.ResponseError(var err, var status, _)) => - $"API Error {status}: {err.Message}", - - Result>.Error( - HttpError.ExceptionError(var ex)) => - $"Exception: {ex.Message}", -}; -\`\`\` - -## Type Aliases - -The full type names are verbose. Define [global using aliases](${EXTERNAL_DOCS['global using']}) in \`GlobalUsings.cs\`: - -\`\`\`csharp -// GlobalUsings.cs - Define once, use everywhere - -// Result aliases -global using OkUser = Outcome.Result> - .Ok>; - -global using ErrorUser = Outcome.Result> - .Error>; - -// HttpError aliases -global using ResponseErrorUser = Outcome.HttpError.ResponseError; -global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; -\`\`\` - -### Using Type Aliases - -With aliases defined, pattern matching becomes much cleaner: - -\`\`\`csharp -var message = result switch -{ - OkUser(var user) => $"Success: {user.Name}", - ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", - ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", -}; -\`\`\` - -## Exhaustion Analyzer - -The Exhaustion [Roslyn analyzer](${EXTERNAL_DOCS['Roslyn analyzer']}) ensures you handle all cases: - -\`\`\`csharp -// This won't compile! -var message = result switch -{ - OkUser(var user) => "Success", - ErrorUser(ResponseErrorUser(...)) => "API Error", - // COMPILE ERROR: Missing ExceptionError case! -}; -\`\`\` - -The compiler error: - -\`\`\` -error EXHAUSTION001: Switch on Result is not exhaustive; -Missing: Error> with ExceptionError -\`\`\` - -## Handling Specific Status Codes - -\`\`\`csharp -var message = result switch -{ - OkUser(var user) => $"Success: {user.Name}", - - ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => - "User not found", - - ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => - "Authentication required", - - ErrorUser(ResponseErrorUser(var err, var status, _)) => - $"Error {(int)status}: {err.Message}", - - ErrorUser(ExceptionErrorUser(var ex)) => - $"Network error: {ex.Message}", -}; -\`\`\` - -## See Also - -- [HttpClient Extensions](/api/httpclient-extensions/) - Extension methods that return Result types -- [Serialization](/api/serialization/) - Custom serialization and deserialization -`; -} - -/** - * Generate Serialization page - */ -function generateSerialization() { - return `--- -layout: layouts/api.njk -title: Serialization -description: Complete guide to serialization in RestClient.Net - JSON, custom serializers, request/response handling. -keywords: RestClient.Net serialization, JSON serialization, HttpContent, custom serializers -eleventyNavigation: - key: Serialization - parent: API Reference - order: 3 -permalink: /api/serialization/ ---- - -RestClient.Net gives you full control over how requests are serialized and responses are deserialized. - -## Deserializers - -Deserializers convert [\`HttpResponseMessage\`](${DOTNET_DOCS.HttpResponseMessage}) to your model types: - -\`\`\`csharp -Func> deserializeSuccess -Func> deserializeError -\`\`\` - -### JSON Deserialization - -Using \`System.Text.Json\`: - -\`\`\`csharp -var result = await httpClient.GetAsync( - url: "https://api.example.com/users/1".ToAbsoluteUrl(), - deserializeSuccess: async (response, ct) => - await response.Content.ReadFromJsonAsync(ct) - ?? throw new InvalidOperationException("Null response"), - deserializeError: async (response, ct) => - await response.Content.ReadFromJsonAsync(ct) - ?? new ApiError("Unknown error") -); -\`\`\` - -### Reusable Deserializers - -Create a static class with reusable deserializer methods: - -\`\`\`csharp -public static class Deserializers -{ - public static async Task Json(HttpResponseMessage response, CancellationToken ct) - where T : class => - await response.Content.ReadFromJsonAsync(ct) - ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); - - public static async Task Error(HttpResponseMessage response, CancellationToken ct) => - await response.Content.ReadFromJsonAsync(ct) - ?? new ApiError("Unknown error"); -} - -// Usage -var result = await httpClient.GetAsync( - url: "https://api.example.com/users/1".ToAbsoluteUrl(), - deserializeSuccess: Deserializers.Json, - deserializeError: Deserializers.Error -); -\`\`\` - -### Custom JSON Options - -Configure [\`JsonSerializerOptions\`](${DOTNET_DOCS.JsonSerializerOptions}) for custom serialization behavior: - -\`\`\`csharp -public static class Deserializers -{ - private static readonly JsonSerializerOptions Options = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - public static async Task Json(HttpResponseMessage response, CancellationToken ct) - where T : class => - await response.Content.ReadFromJsonAsync(Options, ct) - ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); -} -\`\`\` - -## Serializers - -Serializers convert your request body to [\`HttpContent\`](${DOTNET_DOCS.HttpContent}). They're used with [POST](/api/httpclient-extensions/#postasync), [PUT](/api/httpclient-extensions/#putasync), and [PATCH](/api/httpclient-extensions/#patchasync) requests. - -\`\`\`csharp -Func serializeRequest -\`\`\` - -### JSON Serialization - -\`\`\`csharp -var result = await httpClient.PostAsync( - url: "https://api.example.com/users".ToAbsoluteUrl(), - body: new CreateUserRequest("John", "john@example.com"), - serializeRequest: body => JsonContent.Create(body), - deserializeSuccess: Deserializers.Json, - deserializeError: Deserializers.Error -); -\`\`\` - -### Custom Content Types - -#### Form URL Encoded - -Using [\`FormUrlEncodedContent\`](${DOTNET_DOCS.FormUrlEncodedContent}): - -\`\`\`csharp -serializeRequest: body => new FormUrlEncodedContent(new Dictionary -{ - ["name"] = body.Name, - ["email"] = body.Email -}) -\`\`\` - -#### Multipart Form Data - -Using [\`MultipartFormDataContent\`](${DOTNET_DOCS.MultipartFormDataContent}) for file uploads: - -\`\`\`csharp -serializeRequest: body => -{ - var content = new MultipartFormDataContent(); - content.Add(new StringContent(body.Name), "name"); - content.Add(new ByteArrayContent(body.FileBytes), "file", body.FileName); - return content; -} -\`\`\` - -#### XML - -Using [\`XmlSerializer\`](${DOTNET_DOCS.XmlSerializer}): - -\`\`\`csharp -serializeRequest: body => -{ - var serializer = new XmlSerializer(typeof(TBody)); - using var writer = new StringWriter(); - serializer.Serialize(writer, body); - return new StringContent(writer.ToString(), Encoding.UTF8, "application/xml"); -} -\`\`\` - -### Stream Response - -Using [\`Stream\`](${DOTNET_DOCS.Stream}) for large files: - -\`\`\`csharp -var result = await httpClient.GetAsync( - url: "https://api.example.com/files/large".ToAbsoluteUrl(), - deserializeSuccess: async (response, ct) => await response.Content.ReadAsStreamAsync(ct), - deserializeError: Deserializers.Error -); -\`\`\` - -## See Also - -- [HttpClient Extensions](/api/httpclient-extensions/) - Extension methods using serializers -- [Result Types](/api/result-types/) - Understanding the return types -`; -} - -/** - * Generate OpenAPI Generator page - */ -function generateOpenApiGenerator() { - return `--- -layout: layouts/docs.njk -title: OpenAPI Generator -lang: en -permalink: /api/openapi-generator/ -eleventyNavigation: - key: OpenAPI Generator - parent: API Reference - order: 5 ---- - -Generate type-safe C# clients from [OpenAPI 3.x](${EXTERNAL_DOCS.OpenAPI}) specifications. - -## Installation - -\`\`\`bash -dotnet add package RestClient.Net.OpenApiGenerator -\`\`\` - -## CLI Usage - -\`\`\`bash -dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \\ - -u api.yaml \\ - -o Generated \\ - -n YourApi.Generated -\`\`\` - -### CLI Options - -| Option | Short | Description | -|--------|-------|-------------| -| \`--openapi-url\` | \`-u\` | Path to OpenAPI spec (YAML or JSON) | -| \`--output\` | \`-o\` | Output directory for generated files | -| \`--namespace\` | \`-n\` | C# namespace for generated code | -| \`--client-name\` | \`-c\` | Prefix for generated client class names | - -## Generated Code - -The generator creates: - -1. **Model classes** - [Records](${EXTERNAL_DOCS.record}) for all schemas -2. **[HttpClient](/api/httpclient-extensions/) extension methods** - For each endpoint -3. **[Result type aliases](/api/result-types/#type-aliases)** - For concise pattern matching - -### Example Output - -For an OpenAPI spec with a \`/users/{id}\` endpoint: - -\`\`\`csharp -// Generated extension method -public static async Task GetUserById( - this HttpClient httpClient, - string id, - CancellationToken ct = default) -{ - return await httpClient.GetAsync( - url: $"https://api.example.com/users/{id}".ToAbsoluteUrl(), - deserializeSuccess: async (r, c) => await r.Content.ReadFromJsonAsync(c), - deserializeError: async (r, c) => await r.Content.ReadFromJsonAsync(c), - ct - ); -} - -// Generated type alias -global using ResultUser = Outcome.Result>; -global using OkUser = ResultUser.Ok>; -global using ErrorUser = ResultUser.Error>; -\`\`\` - -### Usage - -\`\`\`csharp -using YourApi.Generated; - -var httpClient = factory.CreateClient(); - -// Type-safe API call -var result = await httpClient.GetUserById("123"); - -// Pattern match on result -var output = result switch -{ - OkUser(var user) => $"Found: {user.Name}", - ErrorUser(ResponseErrorUser(var err, var status, _)) => $"Error {status}", - ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", -}; -\`\`\` - -## Supported OpenAPI Features - -- **HTTP Methods:** GET, POST, PUT, DELETE, PATCH -- **Parameters:** path, query, header -- **Request Bodies:** JSON, form data -- **Responses:** All status codes, multiple content types -- **Schemas:** objects, arrays, enums, [oneOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/), [allOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/), [anyOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/) -- **References:** [$ref](https://swagger.io/docs/specification/using-ref/) for local and remote schemas - -## See Also - -- [HttpClient Extensions](/api/httpclient-extensions/) - The extension methods generated -- [Result Types](/api/result-types/) - Understanding the return types -- [MCP Generator](/api/mcp-generator/) - Generate AI tools from OpenAPI -`; -} - -/** - * Generate MCP Generator page - */ -function generateMcpGenerator() { - return `--- -layout: layouts/docs.njk -title: MCP Generator -lang: en -permalink: /api/mcp-generator/ -eleventyNavigation: - key: MCP Generator - parent: API Reference - order: 6 ---- - -Generate [Model Context Protocol (MCP)](${EXTERNAL_DOCS.MCP}) servers from OpenAPI specifications for AI integration with Claude Code and other tools. - -## Installation - -\`\`\`bash -dotnet add package RestClient.Net.McpGenerator -\`\`\` - -## Prerequisites - -First, generate the REST client using the [OpenAPI Generator](/api/openapi-generator/): - -\`\`\`bash -dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \\ - -u api.yaml -o Generated -n YourApi.Generated -\`\`\` - -## CLI Usage - -\`\`\`bash -dotnet run --project RestClient.Net.McpGenerator.Cli -- \\ - --openapi-url api.yaml \\ - --output-file Generated/McpTools.g.cs \\ - --namespace YourApi.Mcp \\ - --server-name YourApi \\ - --ext-namespace YourApi.Generated \\ - --tags "Search,Resources" -\`\`\` - -### CLI Options - -| Option | Description | -|--------|-------------| -| \`--openapi-url\` | Path to [OpenAPI](${EXTERNAL_DOCS.OpenAPI}) specification | -| \`--output-file\` | Output file for generated MCP tools | -| \`--namespace\` | C# namespace for MCP server | -| \`--server-name\` | Name of the MCP server | -| \`--ext-namespace\` | Namespace of generated REST client | -| \`--tags\` | OpenAPI tags to include (comma-separated) | - -## Generated Code - -The generator creates MCP tool definitions that wrap the [HttpClient extensions](/api/httpclient-extensions/): - -\`\`\`csharp -[McpServerToolType] -public static partial class McpTools -{ - [McpServerTool(Name = "get_user")] - [Description("Get user by ID")] - public static async Task GetUser( - [Description("User ID")] string id, - HttpClient httpClient, - CancellationToken ct) - { - var result = await httpClient.GetUserById(id, ct); - return result switch - { - OkUser(var user) => JsonSerializer.Serialize(user), - ErrorUser(var error) => $"Error: {error}" - }; - } -} -\`\`\` - -## Claude Code Integration - -Add to your Claude Code configuration: - -\`\`\`json -{ - "mcpServers": { - "yourapi": { - "command": "dotnet", - "args": ["run", "--project", "YourApi.McpServer"] + mkdirSync(output, { recursive: true }); + copyFileSync(path.join(website, 'tools/ApiDocs/schema.json'), path.join(output, 'schema.json')); + return result; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === filename) { + const options = {}; + for (let index = 2; index < process.argv.length; index += 2) { + const flag = process.argv[index]; + const value = process.argv[index + 1]; + if (!value || !['--root', '--output', '--source-ref', '--projects'].includes(flag)) { + throw new Error('Use [--root PATH] [--output PATH] [--source-ref REF] [--projects dir,dir]'); } + options[{ '--root': 'root', '--output': 'output', '--source-ref': 'sourceRef', '--projects': 'projects' }[flag]] = flag === '--projects' ? value.split(',') : value; } + process.stdout.write(generateApi(options)); } -\`\`\` - -## Tool Naming - -OpenAPI operations are converted to MCP tool names: - -| OpenAPI | MCP Tool | -|---------|----------| -| \`GET /users/{id}\` | \`get_user\` | -| \`POST /users\` | \`create_user\` | -| \`PUT /users/{id}\` | \`update_user\` | -| \`DELETE /users/{id}\` | \`delete_user\` | - -## See Also - -- [OpenAPI Generator](/api/openapi-generator/) - Generate the REST client first -- [Result Types](/api/result-types/) - How results are handled -- [HttpClient Extensions](/api/httpclient-extensions/) - The underlying HTTP methods -`; -} - -/** - * Generate API index page - */ -function generateIndex() { - return `--- -layout: layouts/base.njk -title: API Reference -lang: en -permalink: /api/ ---- -
-

API Reference

-

Complete API documentation for RestClient.Net

- -

Core API

- - -

Code Generators

- - -

Guides

- - -

NuGet Packages

- - - - - - - - - - - - - - - - - - - - - - - - - -
PackageDescription
RestClient.NetCore library with HttpClient extensions
RestClient.Net.OpenApiGeneratorOpenAPI 3.x client generator
RestClient.Net.McpGeneratorMCP server generator
ExhaustionRoslyn analyzer for switch exhaustiveness
- -

External References

- -
-`; -} - -/** - * Main entry point - */ -function main() { - console.log('Generating API documentation for RestClient.Net...'); - console.log(`Source: ${RESTCLIENT_NET_DIR}`); - console.log(`Output: ${API_OUTPUT_DIR}`); - - // Ensure output directory exists - fs.mkdirSync(API_OUTPUT_DIR, { recursive: true }); - - // Generate all pages - // Class summary pages link to individual member detail pages - const pages = [ - { file: 'index.njk', content: generateIndex() }, - // HttpClientExtensions class summary + individual method pages - { file: 'httpclient-extensions.md', content: generateHttpClientExtensions() }, - { file: 'getasync.md', content: generateGetAsync() }, - { file: 'postasync.md', content: generatePostAsync() }, - { file: 'putasync.md', content: generatePutAsync() }, - { file: 'deleteasync.md', content: generateDeleteAsync() }, - { file: 'patchasync.md', content: generatePatchAsync() }, - // Other pages - { file: 'result-types.md', content: generateResultTypes() }, - { file: 'serialization.md', content: generateSerialization() }, - { file: 'openapi-generator.md', content: generateOpenApiGenerator() }, - { file: 'mcp-generator.md', content: generateMcpGenerator() }, - ]; - - for (const { file, content } of pages) { - const outputPath = path.join(API_OUTPUT_DIR, file); - fs.writeFileSync(outputPath, content); - console.log(` Generated: ${file}`); - } - - console.log('\n=== API documentation generation complete ==='); -} - -main(); diff --git a/Website/scripts/generate-api-docs.sh b/Website/scripts/generate-api-docs.sh index decb7363..5203982c 100755 --- a/Website/scripts/generate-api-docs.sh +++ b/Website/scripts/generate-api-docs.sh @@ -1,14 +1,5 @@ -#!/bin/bash - -# Generate API documentation for RestClient.Net -# This script generates markdown files from C# source in the RestClient.Net repo - -set -e - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WEBSITE_DIR="$(dirname "$SCRIPT_DIR")" - -cd "$WEBSITE_DIR" - -# Run the Node.js generator -node "$SCRIPT_DIR/generate-api-docs.js" +#!/usr/bin/env bash +# Export source-backed API reference with the same tool used by npm run build. +set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +node "$script_dir/generate-api-docs.js" "$@" diff --git a/Website/src/_data/examples.js b/Website/src/_data/examples.js new file mode 100644 index 00000000..fe8de5e1 --- /dev/null +++ b/Website/src/_data/examples.js @@ -0,0 +1,4 @@ +import { readFileSync } from 'node:fs'; +export default { + source: readFileSync(new URL('../../examples/Program.cs', import.meta.url), 'utf8'), +}; diff --git a/Website/src/_data/site.js b/Website/src/_data/site.js new file mode 100644 index 00000000..c167f537 --- /dev/null +++ b/Website/src/_data/site.js @@ -0,0 +1,8 @@ +const origin = process.env.SITE_URL || "https://melbournedeveloper.github.io"; +const prefix = "/" + (process.env.SITE_PATH_PREFIX || "").replace(/^\/+|\/+$/g, ""); +export default { + name: "RestClient.Net", title: "RestClient.Net", + description: "Typed HTTP for C#. Explicit results, functional error handling, and exhaustiveness checking for every outcome.", + url: new URL(prefix, origin).href.replace(/\/$/, ""), author: "Christian Findlay", + ogImage: "/assets/images/social-card.png", themeColor: "#101313" +}; diff --git a/Website/src/_data/site.json b/Website/src/_data/site.json deleted file mode 100644 index 382886d3..00000000 --- a/Website/src/_data/site.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "RestClient.Net", - "title": "RestClient.Net", - "description": "The safest way to make REST calls in C#. Built with functional programming, type safety, and modern .NET patterns.", - "url": "https://restclient.net", - "stylesheet": "/assets/css/styles.css", - "author": "Christian Findlay", - "twitterSite": "@caborean", - "ogImage": "/assets/images/Logo.jpg", - "themeColor": "#3D9970", - "keywords": "C#, .NET, REST, HTTP, API, functional programming, type safety, HttpClient" -} diff --git a/Website/src/_includes/home.njk b/Website/src/_includes/home.njk new file mode 100644 index 00000000..782ff2b5 --- /dev/null +++ b/Website/src/_includes/home.njk @@ -0,0 +1,41 @@ +{% set zh = lang == 'zh' %}{% set prefix = '/zh' if zh else '' %} +
+

{{ '面向 .NET 的函数式 HTTP 工具箱' if zh else 'THE FUNCTIONAL HTTP TOOLKIT FOR .NET' }} ↗ OPEN SOURCE

+

{{ '每种结果。' if zh else 'Every outcome.' }}
{{ '尽在掌握。' if zh else 'In view.' }}

+

{{ '把不确定的网络,变成明确的类型。使用 Result、模式匹配和穷尽性检查,让每一个 C# HTTP 请求都有清晰的结果。' if zh else 'The network is unpredictable. Your code doesn’t have to be. Make every HTTP outcome explicit with typed results, pattern matching, and exhaustiveness checks.' }}

+
+
+

01 / {{ '请求实验室' if zh else 'THE REQUEST LAB' }}

HTTP → RESULT<T, E>
+ + +
+

{{ '200 OK → 成功结果中包含已反序列化的响应。' if zh else '200 OK → Your deserialized response, wrapped in a typed success.' }}

+
var message = result switch
+{
+    OkPost(var post) => post.Title,
+    ErrorPost(ResponseErrorPost(var error, var status, _)) => $"HTTP {status}",
+    ErrorPost(ExceptionErrorPost(var exception)) => exception.Message
+};
+{{ '交互演示 · 无网络请求 · 类型别名请参阅文档' if zh else 'Interactive illustration · No requests leave your browser · Type aliases explained in the docs' }} +
+

02 / {{ '为实际开发而设计' if zh else 'DESIGNED FOR THE REAL WORLD' }}

{{ '可靠,源于明确。' if zh else 'Confidence is a type.' }}

+01 — OUTCOME

{{ '让错误成为数据。' if zh else 'Errors become data.' }}

{{ '成功、响应错误、异常:每种结果都拥有明确类型。' if zh else 'Success, response errors, exceptions. Represent what happened in the return type.' }}

Result<T, E> ↗
+02 — EXHAUSTION

{{ '在构建时检查。' if zh else 'Catch the missing case.' }}

{{ '穷尽性分析发现遗漏的分支;复杂分析达到上限时会给出诊断。' if zh else 'Find missing switch branches at build time. Bounded analysis reports when a hierarchy exceeds its limits.' }}

{{ '探索分析器' if zh else 'Meet the analyzer' }} ↗
+03 — HTTPCLIENT

{{ '延续熟悉的工具。' if zh else 'Keep your HttpClient.' }}

{{ '扩展现有 HttpClient 与连接池,保留你熟悉的处理器。' if zh else 'Extend the client you already use. Keep your handlers, connection pools, and dependency injection.' }}

IHttpClientFactory ↗
+04 — OPENAPI

{{ '从规范到类型。' if zh else 'From spec to safe calls.' }}

{{ '将 OpenAPI 规范转换为类型明确的 C# 客户端。' if zh else 'Turn OpenAPI specifications into typed C# clients, models, and result aliases.' }}

{{ '生成客户端' if zh else 'Generate a client' }} ↗
+05 — MCP

{{ '让 API 成为工具。' if zh else 'Make your API a tool.' }}

{{ '通过生成的 Model Context Protocol 服务器公开 API 操作。' if zh else 'Expose API operations through generated Model Context Protocol servers.' }}

{{ '探索 MCP' if zh else 'Explore MCP' }} ↗
+06 — REFERENCE

{{ '源码,直接可查。' if zh else 'Straight from the source.' }}

{{ '查阅由真实 C# 声明与 XML 注释生成的 API 文档。' if zh else 'Browse API signatures and XML documentation exported directly from the C# source.' }}

{{ '查阅 API' if zh else 'Read the reference' }} ↗
+
+

03 / {{ '开始构建' if zh else 'FROM FIRST CALL TO PRODUCTION' }}

{{ '从一个请求开始。' if zh else 'One small install. +A clearer way to code.' }}

{{ '使用熟悉的 C#,明确处理每一个结果。' if zh else 'Familiar C#. Explicit outcomes. Start with one request, then build from there.' }}

{{ '安装指南' if zh else 'Installation guide' }} ↗
dotnet add package RestClient.Net
{% highlight "csharp" %}using RestClient.Net; +using Urls; + +var result = await httpClient.GetAsync( + "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); + +// All outcomes stay in the return value. +// Add your aliases and handle each branch.{% endhighlight %}
+

04 / {{ '开发笔记' if zh else 'NOTES FROM THE WORKBENCH' }}

{{ '思考更清晰,构建更可靠。' if zh else 'Better ways to build.' }}

{{ '全部文章' if zh else 'Read the journal' }} ↗
{% set articles = collections.zhposts if zh else collections.posts %}{% for post in articles.slice(0,3) %}{{ post.date | dateFormat }}

{{ post.data.title }}

{{ post.data.excerpt or post.data.description }}

{{ '阅读全文' if zh else 'Read article' }} ↗
{% endfor %}
diff --git a/Website/src/_includes/layouts/api.njk b/Website/src/_includes/layouts/api.njk new file mode 100644 index 00000000..1113aa89 --- /dev/null +++ b/Website/src/_includes/layouts/api.njk @@ -0,0 +1,4 @@ +--- +layout: layouts/docs.njk +--- +{{ content | safe }} diff --git a/Website/src/_includes/layouts/base.njk b/Website/src/_includes/layouts/base.njk new file mode 100644 index 00000000..eab5df7d --- /dev/null +++ b/Website/src/_includes/layouts/base.njk @@ -0,0 +1,29 @@ + + + + +{{ title }} · {{ page.url | section | upper }} · RestClient.Net + + + + +{% for locale in ['en','zh'] %}{% if page.url | hasTranslation(locale, collections.all) %}{% endif %}{% endfor %} + + + + + + + + +
+ RESTCLIENT.NET +
{{ '菜单' if lang == 'zh' else 'Menu' }}
+
{{ '中文' if lang == 'zh' else 'EN' }}
↗ GitHub
+
+
{{ content | safe }}
+ + diff --git a/Website/src/_includes/layouts/blog.njk b/Website/src/_includes/layouts/blog.njk new file mode 100644 index 00000000..64d8cb9c --- /dev/null +++ b/Website/src/_includes/layouts/blog.njk @@ -0,0 +1,4 @@ +--- +layout: layouts/base.njk +--- + diff --git a/Website/src/_includes/layouts/docs.njk b/Website/src/_includes/layouts/docs.njk new file mode 100644 index 00000000..e66240f7 --- /dev/null +++ b/Website/src/_includes/layouts/docs.njk @@ -0,0 +1,5 @@ +--- +layout: layouts/base.njk +--- +{% set prefix = '/zh' if lang == 'zh' else '' %} +

RESTCLIENT.NET / {{ 'API REFERENCE' if page.url | section == 'api' else 'DOCUMENTATION' }}

{{ title }}

{{ content | withoutHeading | safe }}
diff --git a/Website/src/api/deleteasync.md b/Website/src/api/deleteasync.md new file mode 100644 index 00000000..e09b0398 --- /dev/null +++ b/Website/src/api/deleteasync.md @@ -0,0 +1,58 @@ +--- +layout: layouts/api.njk +title: DeleteAsync Method +description: Make a type-safe DELETE request. +keywords: DeleteAsync, HTTP DELETE, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: DeleteAsync + parent: HttpClient Extensions + order: 4 +permalink: /api/deleteasync/ +--- + +Make a type-safe DELETE request. + +## Namespace + +`RestClient.Net` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +Same signature as [GetAsync](/api/getasync/). + +```csharp +public static async Task>> DeleteAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +## Example + +```csharp +var result = await httpClient.DeleteAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + deserializeSuccess: async (r, ct) => true, + deserializeError: DeserializeApiError +); + +var output = result switch +{ + Ok(true) => "User deleted", + Error(ResponseError(var err, var status, _)) => $"Error {status}: {err.Message}", + Error(ExceptionError(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## See Also + +- [GetAsync](/api/getasync/) - Same signature +- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods diff --git a/Website/src/api/getasync.md b/Website/src/api/getasync.md new file mode 100644 index 00000000..1ce924e6 --- /dev/null +++ b/Website/src/api/getasync.md @@ -0,0 +1,73 @@ +--- +layout: layouts/api.njk +title: GetAsync Method +description: Make a type-safe GET request that returns Result instead of throwing exceptions. +keywords: GetAsync, HTTP GET, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: GetAsync + parent: HttpClient Extensions + order: 1 +permalink: /api/getasync/ +--- + +Make a type-safe GET request. + +## Namespace + +`RestClient.Net` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +```csharp +public static async Task>> GetAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `url` | `AbsoluteUrl` | The request URL (use `.ToAbsoluteUrl()` extension) | +| `deserializeSuccess` | [`Func>`](https://learn.microsoft.com/en-us/dotnet/api/system.func-2) | Function to deserialize success response | +| `deserializeError` | [`Func>`](https://learn.microsoft.com/en-us/dotnet/api/system.func-2) | Function to deserialize error response | +| `headers` | [`IReadOnlyDictionary?`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.ireadonlydictionary-2) | Optional request headers | +| `cancellationToken` | [`CancellationToken`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtoken) | Optional cancellation token | + +## Returns + +[`Task>>`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1) - A discriminated union that is either: +- [`Ok`](/api/reference/outcome-result-2-ok-2/) - Success with deserialized data +- [`Error>`](/api/reference/outcome-result-2-error-2/) - Error with [ResponseError](/api/reference/outcome-httperror-1-errorresponseerror/) or [ExceptionError](/api/reference/outcome-httperror-1-exceptionerror/) + +## Example + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); + +var output = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## See Also + +- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods +- [Result<TSuccess, TError>](/api/reference/outcome-result-2/) - The return type +- [Serialization](/api/serialization/) - Deserializer examples diff --git a/Website/src/api/httpclient-extensions.md b/Website/src/api/httpclient-extensions.md new file mode 100644 index 00000000..2d205b0f --- /dev/null +++ b/Website/src/api/httpclient-extensions.md @@ -0,0 +1,33 @@ +--- +layout: layouts/api.njk +title: HttpClientExtensions Class +description: Extension methods for HttpClient that return Result types instead of throwing exceptions. +keywords: HttpClientExtensions, HttpClient, REST API, C# HTTP client, extension methods +eleventyNavigation: + key: HttpClient Extensions + parent: API Reference + order: 1 +permalink: /api/httpclient-extensions/ +--- + +Extension methods for [`HttpClient`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient) that return [`Result>`](/api/reference/outcome-result-2/) instead of throwing exceptions. + +## Namespace + +`RestClient.Net` + +## Methods + +| Method | Description | +|--------|-------------| +| [GetAsync<TSuccess, TError>](/api/getasync/) | Make a type-safe GET request | +| [PostAsync<TRequest, TSuccess, TError>](/api/postasync/) | Make a type-safe POST request with body | +| [PutAsync<TRequest, TSuccess, TError>](/api/putasync/) | Make a type-safe PUT request for full replacement | +| [DeleteAsync<TSuccess, TError>](/api/deleteasync/) | Make a type-safe DELETE request | +| [PatchAsync<TRequest, TSuccess, TError>](/api/patchasync/) | Make a type-safe PATCH request for partial updates | + +## See Also + +- [Result<TSuccess, TError>](/api/reference/outcome-result-2/) - The discriminated union return type +- [HttpError<TError>](/api/reference/outcome-httperror-1/) - HTTP-specific error wrapper +- [Serialization](/api/serialization/) - Custom serialization and deserialization diff --git a/Website/src/api/index.njk b/Website/src/api/index.njk new file mode 100644 index 00000000..7fda74d6 --- /dev/null +++ b/Website/src/api/index.njk @@ -0,0 +1,7 @@ +--- +layout: layouts/base.njk +title: API reference +lang: en +permalink: /api/ +--- +

RESTCLIENT.NET / REFERENCE

Know every signature.

Source-derived C# signatures, XML documentation, and practical guides.

RestClient.Net on NuGet ↗ · Exhaustion on NuGet ↗

diff --git a/Website/src/api/mcp-generator.md b/Website/src/api/mcp-generator.md new file mode 100644 index 00000000..64d7e2b8 --- /dev/null +++ b/Website/src/api/mcp-generator.md @@ -0,0 +1,107 @@ +--- +layout: layouts/docs.njk +title: MCP Generator +lang: en +permalink: /api/mcp-generator/ +eleventyNavigation: + key: MCP Generator + parent: API Reference + order: 6 +--- + +Generate [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers from OpenAPI specifications for AI integration with Claude Code and other tools. + +## Installation + +```bash +dotnet add package RestClient.Net.McpGenerator +``` + +## Prerequisites + +First, generate the REST client using the [OpenAPI Generator](/api/openapi-generator/): + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml -o Generated -n YourApi.Generated +``` + +## CLI Usage + +```bash +dotnet run --project RestClient.Net.McpGenerator.Cli -- \ + --openapi-url api.yaml \ + --output-file Generated/McpTools.g.cs \ + --namespace YourApi.Mcp \ + --server-name YourApi \ + --ext-namespace YourApi.Generated \ + --tags "Search,Resources" +``` + +### CLI Options + +| Option | Description | +|--------|-------------| +| `--openapi-url` | Path to [OpenAPI](https://swagger.io/specification/) specification | +| `--output-file` | Output file for generated MCP tools | +| `--namespace` | C# namespace for MCP server | +| `--server-name` | Name of the MCP server | +| `--ext-namespace` | Namespace of generated REST client | +| `--tags` | OpenAPI tags to include (comma-separated) | + +## Generated Code + +The generator creates MCP tool definitions that wrap the [HttpClient extensions](/api/httpclient-extensions/): + +```csharp +[McpServerToolType] +public static partial class McpTools +{ + [McpServerTool(Name = "get_user")] + [Description("Get user by ID")] + public static async Task GetUser( + [Description("User ID")] string id, + HttpClient httpClient, + CancellationToken ct) + { + var result = await httpClient.GetUserById(id, ct); + return result switch + { + OkUser(var user) => JsonSerializer.Serialize(user), + ErrorUser(var error) => $"Error: {error}" + }; + } +} +``` + +## Claude Code Integration + +Add to your Claude Code configuration: + +```json +{ + "mcpServers": { + "yourapi": { + "command": "dotnet", + "args": ["run", "--project", "YourApi.McpServer"] + } + } +} +``` + +## Tool Naming + +OpenAPI operations are converted to MCP tool names: + +| OpenAPI | MCP Tool | +|---------|----------| +| `GET /users/{id}` | `get_user` | +| `POST /users` | `create_user` | +| `PUT /users/{id}` | `update_user` | +| `DELETE /users/{id}` | `delete_user` | + +## See Also + +- [OpenAPI Generator](/api/openapi-generator/) - Generate the REST client first +- [Result Types](/api/result-types/) - How results are handled +- [HttpClient Extensions](/api/httpclient-extensions/) - The underlying HTTP methods diff --git a/Website/src/api/openapi-generator.md b/Website/src/api/openapi-generator.md new file mode 100644 index 00000000..566e517d --- /dev/null +++ b/Website/src/api/openapi-generator.md @@ -0,0 +1,103 @@ +--- +layout: layouts/docs.njk +title: OpenAPI Generator +lang: en +permalink: /api/openapi-generator/ +eleventyNavigation: + key: OpenAPI Generator + parent: API Reference + order: 5 +--- + +Generate type-safe C# clients from [OpenAPI 3.x](https://swagger.io/specification/) specifications. + +## Installation + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +## CLI Usage + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml \ + -o Generated \ + -n YourApi.Generated +``` + +### CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--openapi-url` | `-u` | Path to OpenAPI spec (YAML or JSON) | +| `--output` | `-o` | Output directory for generated files | +| `--namespace` | `-n` | C# namespace for generated code | +| `--client-name` | `-c` | Prefix for generated client class names | + +## Generated Code + +The generator creates: + +1. **Model classes** - [Records](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record) for all schemas +2. **[HttpClient](/api/httpclient-extensions/) extension methods** - For each endpoint +3. **[Result type aliases](/api/result-types/#type-aliases)** - For concise pattern matching + +### Example Output + +For an OpenAPI spec with a `/users/{id}` endpoint: + +```csharp +// Generated extension method +public static async Task GetUserById( + this HttpClient httpClient, + string id, + CancellationToken ct = default) +{ + return await httpClient.GetAsync( + url: $"https://api.example.com/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: async (r, c) => await r.Content.ReadFromJsonAsync(c), + deserializeError: async (r, c) => await r.Content.ReadFromJsonAsync(c), + ct + ); +} + +// Generated type alias +global using ResultUser = Outcome.Result>; +global using OkUser = ResultUser.Ok>; +global using ErrorUser = ResultUser.Error>; +``` + +### Usage + +```csharp +using YourApi.Generated; + +var httpClient = factory.CreateClient(); + +// Type-safe API call +var result = await httpClient.GetUserById("123"); + +// Pattern match on result +var output = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"Error {status}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## Supported OpenAPI Features + +- **HTTP Methods:** GET, POST, PUT, DELETE, PATCH +- **Parameters:** path, query, header +- **Request Bodies:** JSON, form data +- **Responses:** All status codes, multiple content types +- **Schemas:** objects, arrays, enums, [oneOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/), [allOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/), [anyOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/) +- **References:** [$ref](https://swagger.io/docs/specification/using-ref/) for local and remote schemas + +## See Also + +- [HttpClient Extensions](/api/httpclient-extensions/) - The extension methods generated +- [Result Types](/api/result-types/) - Understanding the return types +- [MCP Generator](/api/mcp-generator/) - Generate AI tools from OpenAPI diff --git a/Website/src/api/patchasync.md b/Website/src/api/patchasync.md new file mode 100644 index 00000000..af8e2c33 --- /dev/null +++ b/Website/src/api/patchasync.md @@ -0,0 +1,57 @@ +--- +layout: layouts/api.njk +title: PatchAsync Method +description: Make a type-safe PATCH request for partial updates. +keywords: PatchAsync, HTTP PATCH, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: PatchAsync + parent: HttpClient Extensions + order: 5 +permalink: /api/patchasync/ +--- + +Make a type-safe PATCH request for partial updates. + +## Namespace + +`RestClient.Net` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +Same signature as [PostAsync](/api/postasync/). + +```csharp +public static async Task>> PatchAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +## Example + +```csharp +var patch = new PatchUserRequest { Email = "new.email@example.com" }; + +var result = await httpClient.PatchAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + body: patch, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +``` + +## See Also + +- [PostAsync](/api/postasync/) - Same signature, for creating resources +- [PutAsync](/api/putasync/) - For full replacement diff --git a/Website/src/api/postasync.md b/Website/src/api/postasync.md new file mode 100644 index 00000000..b0e13b56 --- /dev/null +++ b/Website/src/api/postasync.md @@ -0,0 +1,73 @@ +--- +layout: layouts/api.njk +title: PostAsync Method +description: Make a type-safe POST request with a request body that returns Result instead of throwing exceptions. +keywords: PostAsync, HTTP POST, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: PostAsync + parent: HttpClient Extensions + order: 2 +permalink: /api/postasync/ +--- + +Make a type-safe POST request with a request body. + +## Namespace + +`RestClient.Net` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +```csharp +public static async Task>> PostAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `url` | `AbsoluteUrl` | The request URL | +| `body` | `TRequest` | The request body object | +| `serializeRequest` | [`Func`](https://learn.microsoft.com/en-us/dotnet/api/system.func-2) | Function to serialize the request body | +| `deserializeSuccess` | [`Func>`](https://learn.microsoft.com/en-us/dotnet/api/system.func-2) | Function to deserialize success response | +| `deserializeError` | [`Func>`](https://learn.microsoft.com/en-us/dotnet/api/system.func-2) | Function to deserialize error response | +| `headers` | [`IReadOnlyDictionary?`](https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.ireadonlydictionary-2) | Optional request headers | +| `cancellationToken` | [`CancellationToken`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtoken) | Optional cancellation token | + +## Returns + +[`Task>>`](https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1) - A discriminated union that is either: +- [`Ok`](/api/reference/outcome-result-2-ok-2/) - Success with deserialized data +- [`Error>`](/api/reference/outcome-result-2-error-2/) - Error with [ResponseError](/api/reference/outcome-httperror-1-errorresponseerror/) or [ExceptionError](/api/reference/outcome-httperror-1-exceptionerror/) + +## Example + +```csharp +var newUser = new CreateUserRequest("John", "john@example.com"); + +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + body: newUser, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +``` + +## See Also + +- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods +- [Serialization](/api/serialization/) - Serializer examples diff --git a/Website/src/api/putasync.md b/Website/src/api/putasync.md new file mode 100644 index 00000000..0f39702c --- /dev/null +++ b/Website/src/api/putasync.md @@ -0,0 +1,57 @@ +--- +layout: layouts/api.njk +title: PutAsync Method +description: Make a type-safe PUT request for full resource replacement. +keywords: PutAsync, HTTP PUT, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: PutAsync + parent: HttpClient Extensions + order: 3 +permalink: /api/putasync/ +--- + +Make a type-safe PUT request for full resource replacement. + +## Namespace + +`RestClient.Net` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +Same signature as [PostAsync](/api/postasync/). + +```csharp +public static async Task>> PutAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +## Example + +```csharp +var updatedUser = new UpdateUserRequest("John Updated", "john.updated@example.com"); + +var result = await httpClient.PutAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + body: updatedUser, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +``` + +## See Also + +- [PostAsync](/api/postasync/) - Same signature, for creating resources +- [PatchAsync](/api/patchasync/) - For partial updates diff --git a/Website/src/api/result-types.md b/Website/src/api/result-types.md new file mode 100644 index 00000000..83721555 --- /dev/null +++ b/Website/src/api/result-types.md @@ -0,0 +1,168 @@ +--- +layout: layouts/api.njk +title: Result Types +description: Complete reference for RestClient.Net Result types - discriminated unions for type-safe HTTP error handling with pattern matching. +keywords: Result types, HttpError, discriminated unions, pattern matching, C# error handling +eleventyNavigation: + key: Result Types + parent: API Reference + order: 2 +permalink: /api/result-types/ +--- + +RestClient.Net uses [discriminated unions](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record) to represent HTTP responses. This forces you to handle all possible outcomes at compile time. + +## Result<TSuccess, TError> + +The core result type that represents either success or failure. + +```csharp +public abstract record Result +{ + public record Ok(TSuccess Value) : Result; + public record Error(TError Value) : Result; +} +``` + +### Pattern Matching + +Use [switch expressions](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression) to handle all cases: + +```csharp +var message = result switch +{ + Result>.Ok(var user) => + $"Got user: {user.Name}", + + Result>.Error(var error) => + $"Error occurred: {error}" +}; +``` + +## HttpError<TError> + +Represents HTTP-specific errors. Can be either a response error (server returned an error status) or an exception error (network failure, timeout, etc.). + +```csharp +public abstract record HttpError +{ + public record ResponseError( + TError Error, + HttpStatusCode StatusCode, + HttpResponseHeaders Headers + ) : HttpError; + + public record ExceptionError(Exception Exception) : HttpError; +} +``` + +### ResponseError Properties + +| Property | Type | Description | +|----------|------|-------------| +| `Error` | `TError` | Your deserialized error model | +| `StatusCode` | [`HttpStatusCode`](https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode) | The HTTP status code (e.g., 404, 500) | +| `Headers` | `HttpResponseHeaders` | Response headers for accessing metadata | + +### ExceptionError Properties + +| Property | Type | Description | +|----------|------|-------------| +| `Exception` | [`Exception`](https://learn.microsoft.com/en-us/dotnet/api/system.exception) | The caught exception (timeout, network error, etc.) | + +### Full Pattern Matching Example + +```csharp +var message = result switch +{ + Result>.Ok(var user) => + $"Success: {user.Name}", + + Result>.Error( + HttpError.ResponseError(var err, var status, _)) => + $"API Error {status}: {err.Message}", + + Result>.Error( + HttpError.ExceptionError(var ex)) => + $"Exception: {ex.Message}", +}; +``` + +## Type Aliases + +The full type names are verbose. Define [global using aliases](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive#global-modifier) in `GlobalUsings.cs`: + +```csharp +// GlobalUsings.cs - Define once, use everywhere + +// Result aliases +global using OkUser = Outcome.Result> + .Ok>; + +global using ErrorUser = Outcome.Result> + .Error>; + +// HttpError aliases +global using ResponseErrorUser = Outcome.HttpError.ResponseError; +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +``` + +### Using Type Aliases + +With aliases defined, pattern matching becomes much cleaner: + +```csharp +var message = result switch +{ + OkUser(var user) => $"Success: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## Exhaustion Analyzer + +The Exhaustion [Roslyn analyzer](https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/) ensures you handle all cases: + +```csharp +// This won't compile! +var message = result switch +{ + OkUser(var user) => "Success", + ErrorUser(ResponseErrorUser(...)) => "API Error", + // COMPILE ERROR: Missing ExceptionError case! +}; +``` + +The compiler error: + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Missing: Error> with ExceptionError +``` + +## Handling Specific Status Codes + +```csharp +var message = result switch +{ + OkUser(var user) => $"Success: {user.Name}", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => + "User not found", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => + "Authentication required", + + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"Error {(int)status}: {err.Message}", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"Network error: {ex.Message}", +}; +``` + +## See Also + +- [HttpClient Extensions](/api/httpclient-extensions/) - Extension methods that return Result types +- [Serialization](/api/serialization/) - Custom serialization and deserialization diff --git a/Website/src/api/serialization.md b/Website/src/api/serialization.md new file mode 100644 index 00000000..0e1e8163 --- /dev/null +++ b/Website/src/api/serialization.md @@ -0,0 +1,163 @@ +--- +layout: layouts/api.njk +title: Serialization +description: Complete guide to serialization in RestClient.Net - JSON, custom serializers, request/response handling. +keywords: RestClient.Net serialization, JSON serialization, HttpContent, custom serializers +eleventyNavigation: + key: Serialization + parent: API Reference + order: 3 +permalink: /api/serialization/ +--- + +RestClient.Net gives you full control over how requests are serialized and responses are deserialized. + +## Deserializers + +Deserializers convert [`HttpResponseMessage`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage) to your model types: + +```csharp +Func> deserializeSuccess +Func> deserializeError +``` + +### JSON Deserialization + +Using `System.Text.Json`: + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? new ApiError("Unknown error") +); +``` + +### Reusable Deserializers + +Create a static class with reusable deserializer methods: + +```csharp +public static class Deserializers +{ + public static async Task Json(HttpResponseMessage response, CancellationToken ct) + where T : class => + await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); + + public static async Task Error(HttpResponseMessage response, CancellationToken ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? new ApiError("Unknown error"); +} + +// Usage +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +``` + +### Custom JSON Options + +Configure [`JsonSerializerOptions`](https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions) for custom serialization behavior: + +```csharp +public static class Deserializers +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static async Task Json(HttpResponseMessage response, CancellationToken ct) + where T : class => + await response.Content.ReadFromJsonAsync(Options, ct) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); +} +``` + +## Serializers + +Serializers convert your request body to [`HttpContent`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpcontent). They're used with [POST](/api/postasync/), [PUT](/api/putasync/), and [PATCH](/api/patchasync/) requests. + +```csharp +Func serializeRequest +``` + +### JSON Serialization + +```csharp +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + body: new CreateUserRequest("John", "john@example.com"), + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +``` + +### Custom Content Types + +#### Form URL Encoded + +Using [`FormUrlEncodedContent`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.formurlencodedcontent): + +```csharp +serializeRequest: body => new FormUrlEncodedContent(new Dictionary +{ + ["name"] = body.Name, + ["email"] = body.Email +}) +``` + +#### Multipart Form Data + +Using [`MultipartFormDataContent`](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.multipartformdatacontent) for file uploads: + +```csharp +serializeRequest: body => +{ + var content = new MultipartFormDataContent(); + content.Add(new StringContent(body.Name), "name"); + content.Add(new ByteArrayContent(body.FileBytes), "file", body.FileName); + return content; +} +``` + +#### XML + +Using [`XmlSerializer`](https://learn.microsoft.com/en-us/dotnet/api/system.xml.serialization.xmlserializer): + +```csharp +serializeRequest: body => +{ + var serializer = new XmlSerializer(typeof(TBody)); + using var writer = new StringWriter(); + serializer.Serialize(writer, body); + return new StringContent(writer.ToString(), Encoding.UTF8, "application/xml"); +} +``` + +### Stream Response + +Using [`Stream`](https://learn.microsoft.com/en-us/dotnet/api/system.io.stream) for large files: + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/files/large".ToAbsoluteUrl(), + deserializeSuccess: async (response, ct) => await response.Content.ReadAsStreamAsync(ct), + deserializeError: Deserializers.Error +); +``` + +## See Also + +- [HttpClient Extensions](/api/httpclient-extensions/) - Extension methods using serializers +- [Result Types](/api/result-types/) - Understanding the return types diff --git a/Website/src/assets/css/styles.css b/Website/src/assets/css/styles.css index a9c41eed..7384e0ad 100644 --- a/Website/src/assets/css/styles.css +++ b/Website/src/assets/css/styles.css @@ -1,1461 +1 @@ -/* RestClient.Net Theme - Colors from Logo.jpg - Teal/Seafoam Green (#3D9970) + White - Based on dart_node website styling patterns for consistency -*/ - -/* ============================================ - CSS CUSTOM PROPERTIES - ============================================ */ - -:root { - /* RestClient.Net brand colors - from logo */ - --color-primary: #3D9970; - --color-primary-light: #5AB88E; - --color-primary-dark: #2D7A56; - --color-secondary: #E85D3B; - --color-secondary-light: #F07858; - --color-secondary-dark: #C74A2A; - --color-accent: #2C89C7; - --color-accent-light: #4AA3E0; - --color-accent-dark: #1E6A9E; - - /* Neutral colors */ - --color-gray-50: #FAFBFC; - --color-gray-100: #F3F4F6; - --color-gray-200: #E5E7EB; - --color-gray-300: #D1D5DB; - --color-gray-400: #9CA3AF; - --color-gray-500: #6B7280; - --color-gray-600: #4B5563; - --color-gray-700: #374151; - --color-gray-800: #1F2937; - --color-gray-900: #111827; - - /* Semantic colors */ - --color-success: #10B981; - --color-warning: #F59E0B; - --color-error: #EF4444; - --color-info: #3B82F6; - - /* Light theme (default) */ - --bg-primary: var(--color-gray-50); - --bg-secondary: #FFFFFF; - --bg-tertiary: var(--color-gray-100); - --text-primary: var(--color-gray-900); - --text-secondary: var(--color-gray-600); - --text-tertiary: var(--color-gray-500); - --border-color: var(--color-gray-200); - --code-bg: var(--color-gray-100); - - /* Typography */ - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace; - - /* Font sizes */ - --text-xs: 0.75rem; - --text-sm: 0.875rem; - --text-base: 1rem; - --text-lg: 1.125rem; - --text-xl: 1.25rem; - --text-2xl: 1.5rem; - --text-3xl: 1.875rem; - --text-4xl: 2.25rem; - --text-5xl: 3rem; - - /* Spacing */ - --space-1: 0.25rem; - --space-2: 0.5rem; - --space-3: 0.75rem; - --space-4: 1rem; - --space-5: 1.25rem; - --space-6: 1.5rem; - --space-8: 2rem; - --space-10: 2.5rem; - --space-12: 3rem; - --space-16: 4rem; - --space-20: 5rem; - - /* Border radius */ - --radius-sm: 0.25rem; - --radius-md: 0.5rem; - --radius-lg: 0.75rem; - --radius-xl: 1rem; - --radius-full: 9999px; - - /* Shadows */ - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - - /* Transitions */ - --transition-fast: 150ms ease; - --transition-base: 200ms ease; - --transition-slow: 300ms ease; - --transition-smooth: 250ms cubic-bezier(0.4, 0, 0.2, 1); - - /* Layout */ - --max-width: 1200px; - --header-height: 64px; - --sidebar-width: 280px; - --content-width: 720px; -} - -/* Dark theme */ -[data-theme="dark"] { - --bg-primary: #0F1419; - --bg-secondary: #1A1F26; - --bg-tertiary: #242B33; - --text-primary: #F3F4F6; - --text-secondary: #9CA3AF; - --text-tertiary: #6B7280; - --border-color: #374151; - --code-bg: #1A1F26; -} - -/* ============================================ - BASE STYLES - ============================================ */ - -html { - scroll-behavior: smooth; - scroll-padding-top: calc(var(--header-height) + var(--space-4)); -} - -body { - font-family: var(--font-sans); - font-size: var(--text-base); - line-height: 1.6; - color: var(--text-primary); - background-color: var(--bg-primary); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* ============================================ - TYPOGRAPHY - ============================================ */ - -h1, h2, h3, h4, h5, h6 { - font-weight: 600; - line-height: 1.3; - color: var(--text-primary); -} - -h1 { - font-size: var(--text-4xl); - font-weight: 700; - letter-spacing: -0.02em; -} - -h2 { - font-size: var(--text-3xl); - letter-spacing: -0.01em; -} - -h3 { - font-size: var(--text-2xl); -} - -h4 { - font-size: var(--text-xl); -} - -h5 { - font-size: var(--text-lg); -} - -h6 { - font-size: var(--text-base); -} - -p { - margin-bottom: var(--space-4); - color: var(--text-secondary); -} - -a { - color: var(--color-primary); - text-decoration: none; - transition: color var(--transition-fast); -} - -a:hover { - color: var(--color-primary-light); -} - -strong { - font-weight: 600; -} - -/* Lists */ -ul, ol { - margin-bottom: var(--space-4); - padding-left: var(--space-6); -} - -li { - margin-bottom: var(--space-2); - color: var(--text-secondary); -} - -/* ============================================ - CODE - ============================================ */ - -code { - font-family: var(--font-mono); - font-size: 0.9em; - padding: 0.2em 0.4em; - background: var(--code-bg); - border-radius: var(--radius-sm); - color: var(--color-secondary); - word-break: break-word; -} - -pre { - font-family: var(--font-mono); - font-size: var(--text-sm); - line-height: 1.7; - padding: var(--space-4); - background: var(--code-bg); - border-radius: var(--radius-lg); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - margin-bottom: var(--space-4); -} - -pre code { - padding: 0; - background: none; - color: inherit; - word-break: normal; - white-space: pre; - display: block; -} - -/* ============================================ - SKIP LINK - ============================================ */ - -.skip-link { - background: var(--color-primary); - color: white; - border-radius: var(--radius-md); -} - -.skip-link:focus { - color: white; -} - -/* ============================================ - HEADER & NAVIGATION - ============================================ */ - -.site-header { - height: var(--header-height); - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); -} - -.nav { - height: 100%; - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--space-4); - max-width: var(--max-width); - margin-inline: auto; - padding: 0 var(--space-4); -} - -.nav-actions { - display: flex; - align-items: center; - gap: var(--space-3); - flex-shrink: 0; -} - -.logo { - font-weight: 700; - font-size: var(--text-xl); - color: var(--text-primary); - transition: all var(--transition-fast); -} - -.logo:hover { - color: var(--color-primary); - transform: scale(1.03); -} - -.nav-links { - list-style: none; - margin: 0; - padding: 0; - display: flex; - align-items: center; - gap: var(--space-1); -} - -.nav-links li { - margin: 0; -} - -.nav-link { - display: inline-block; - font-weight: 500; - color: var(--text-secondary); - transition: color var(--transition-fast); - position: relative; - padding: var(--space-2) var(--space-3); -} - -.nav-link::after { - content: ''; - position: absolute; - bottom: 0; - left: var(--space-3); - right: var(--space-3); - height: 2px; - background: var(--color-primary); - transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); - transform: scaleX(0); - border-radius: 2px; -} - -.nav-link:hover::after, -.nav-link.active::after { - transform: scaleX(1); -} - -.nav-link:hover, -.nav-link.active { - color: var(--color-primary); -} - -/* ============================================ - LANGUAGE SWITCHER - ============================================ */ - -.language-btn { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - height: 40px; - background: transparent; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition-fast); - font-family: var(--font-sans); - font-size: var(--text-sm); - font-weight: 500; -} - -.language-btn:hover { - background: var(--bg-tertiary); - color: var(--text-primary); - border-color: var(--color-primary); -} - -.language-dropdown { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - box-shadow: var(--shadow-lg); - padding: var(--space-2); - margin-top: var(--space-2); -} - -.language-dropdown li { - margin: 0; -} - -.language-dropdown a { - display: block; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: var(--text-sm); - transition: all var(--transition-fast); -} - -.language-dropdown a:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.language-dropdown a.active { - background: var(--color-primary); - color: white; -} - -/* ============================================ - THEME TOGGLE - ============================================ */ - -.theme-toggle { - display: flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - background: transparent; - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - color: var(--text-secondary); - cursor: pointer; - transition: all var(--transition-fast); -} - -.theme-toggle:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -[data-theme="light"] .theme-icon-dark { - display: none; -} - -[data-theme="dark"] .theme-icon-light { - display: none; -} - -/* ============================================ - MOBILE MENU - ============================================ */ - -.mobile-menu-toggle span { - background: var(--text-primary); - transition: all var(--transition-fast); -} - -@media (max-width: 768px) { - .nav-links { - background: var(--bg-secondary); - border-bottom: 1px solid var(--border-color); - } -} - -/* ============================================ - BUTTONS - ============================================ */ - -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-2); - padding: var(--space-3) var(--space-6); - font-family: var(--font-sans); - font-size: var(--text-base); - font-weight: 500; - text-decoration: none; - border-radius: var(--radius-md); - border: none; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} - -.btn:hover { - transform: translateY(-4px) scale(1.02); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); -} - -.btn:active { - transform: translateY(-1px) scale(1); - transition-duration: 0.1s; -} - -.btn-primary { - background: var(--color-primary); - color: white; -} - -.btn-primary:hover { - background: var(--color-primary-light); - color: white; - box-shadow: 0 10px 30px rgba(61, 153, 112, 0.5); -} - -.btn-secondary { - background: transparent; - color: var(--text-primary); - border: 1px solid var(--border-color); -} - -.btn-secondary:hover { - background: var(--bg-tertiary); -} - -.btn-large { - padding: var(--space-4) var(--space-8); - font-size: var(--text-lg); -} - -.btn-prev, -.btn-next { - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); - padding: var(--space-2) var(--space-4); - border-radius: var(--radius-md); - font-weight: 500; - transition: all var(--transition-fast); -} - -.btn-prev:hover, -.btn-next:hover { - background: var(--color-primary); - color: white; - border-color: var(--color-primary); -} - -/* ============================================ - HERO SECTION - ============================================ */ - -.hero { - padding: var(--space-20) 0; - text-align: center; - background: linear-gradient(135deg, #1a2f38 0%, #0d1f26 50%, #162329 100%); - position: relative; - overflow: hidden; -} - -.hero::before { - content: ''; - position: absolute; - top: -50%; - left: -25%; - width: 80%; - height: 150%; - background: radial-gradient(ellipse, rgba(61, 153, 112, 0.25) 0%, transparent 60%); - animation: float 15s ease-in-out infinite; -} - -.hero::after { - content: ''; - position: absolute; - bottom: -50%; - right: -25%; - width: 80%; - height: 150%; - background: radial-gradient(ellipse, rgba(46, 204, 113, 0.15) 0%, transparent 60%); - animation: float 18s ease-in-out infinite reverse; -} - -@keyframes float { - 0%, 100% { transform: translate(0, 0) rotate(0deg); } - 33% { transform: translate(30px, -30px) rotate(5deg); } - 66% { transform: translate(-20px, 20px) rotate(-5deg); } -} - -.hero .container { - position: relative; - z-index: 1; -} - -.hero-logo { - max-width: 280px; - height: auto; - margin: 0 auto var(--space-8); - border-radius: var(--radius-xl); - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); -} - -.hero h1 { - font-size: var(--text-5xl); - margin-bottom: var(--space-6); - color: #fff; - text-shadow: 0 4px 30px rgba(0, 0, 0, 0.3); -} - -.hero h1 span { - color: var(--color-primary-light); -} - -.hero-tagline { - font-size: var(--text-xl); - color: rgba(255, 255, 255, 0.85); - max-width: 42rem; - margin: 0 auto var(--space-8); - line-height: 1.8; -} - -.hero-actions { - display: flex; - gap: var(--space-4); - justify-content: center; - flex-wrap: wrap; -} - -.hero .btn-secondary { - background: rgba(255, 255, 255, 0.15); - color: white; - border: 2px solid rgba(255, 255, 255, 0.4); - backdrop-filter: blur(10px); -} - -.hero .btn-secondary:hover { - background: rgba(255, 255, 255, 0.25); - border-color: rgba(255, 255, 255, 0.6); - color: white; -} - -/* ============================================ - CONTAINER - ============================================ */ - -.container { - width: 100%; - max-width: var(--max-width); - margin: 0 auto; - padding: 0 var(--space-4); -} - -/* ============================================ - FEATURES - ============================================ */ - -.features { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: var(--space-6); - padding: var(--space-16) 0; -} - -.feature-card { - padding: var(--space-6); - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94); - position: relative; - overflow: hidden; -} - -.feature-card::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); - transform: scaleX(0); - transform-origin: left; - transition: transform var(--transition-base); -} - -.feature-card:hover { - transform: translateY(-8px); - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); - border-color: var(--color-primary); -} - -.feature-card:hover::before { - transform: scaleX(1); -} - -.feature-card h3 { - margin-bottom: var(--space-2); - color: var(--color-primary); -} - -.feature-card p { - margin: 0; - color: var(--text-secondary); -} - -.feature-card code { - background: var(--bg-tertiary); - color: var(--color-secondary); - padding: 0.15em 0.4em; - border-radius: var(--radius-sm); - font-size: 0.85em; -} - -/* Code blocks inside feature cards (e.g., Why Discriminated Unions section) */ -.feature-card pre { - background: var(--bg-tertiary); - border-radius: var(--radius-md); - padding: var(--space-4); - margin: var(--space-4) 0 0 0; - overflow-x: auto; - border: none; -} - -.feature-card pre code { - background: none; - padding: 0; - font-size: inherit; - color: var(--text-primary); - display: block; - white-space: pre; - word-break: normal; -} - -/* ============================================ - DOCS LAYOUT - ============================================ */ - -.docs-layout { - min-height: calc(100vh - var(--header-height)); -} - -.sidebar { - background: var(--bg-secondary); - border-right: 1px solid var(--border-color); - padding: var(--space-6); -} - -.docs-nav ul { - list-style: none; - padding: 0; - margin: 0; -} - -.docs-nav li { - margin: 0; -} - -.docs-nav a { - display: block; - padding: var(--space-2) var(--space-3); - color: var(--text-secondary); - border-radius: var(--radius-md); - transition: all var(--transition-fast); - border-left: 3px solid transparent; - margin-left: -3px; -} - -.docs-nav a:hover, -.docs-nav a.active { - background: var(--bg-tertiary); - color: var(--color-primary); - border-left-color: var(--color-primary); - padding-left: calc(var(--space-3) + 6px); -} - -.docs-content { - padding: var(--space-8); -} - -.docs-content h1 { - margin-bottom: var(--space-6); -} - -.docs-content h1:first-child { - padding-bottom: var(--space-4); - border-bottom: 2px solid var(--border-color); - position: relative; -} - -.docs-content h1:first-child::after { - content: ''; - position: absolute; - bottom: -2px; - left: 0; - width: 80px; - height: 2px; - background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); -} - -.docs-content h2 { - margin-top: var(--space-10); - margin-bottom: var(--space-4); - padding-bottom: var(--space-2); - border-bottom: 1px solid var(--border-color); - position: relative; -} - -.docs-content h2::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - width: 60px; - height: 1px; - background: var(--color-primary); -} - -.docs-content h3 { - margin-top: var(--space-8); - margin-bottom: var(--space-3); -} - -.docs-content ul, -.docs-content ol { - padding-left: var(--space-6); - margin-bottom: var(--space-4); -} - -.docs-content li { - margin-bottom: var(--space-2); -} - -.docs-content blockquote { - border-left: 4px solid var(--color-primary); - padding: var(--space-4) var(--space-6); - margin: var(--space-6) 0; - background: var(--bg-tertiary); - border-radius: 0 var(--radius-lg) var(--radius-lg) 0; -} - -.docs-content blockquote p { - margin: 0; - color: var(--text-primary); -} - -.docs-content table { - width: 100%; - border-collapse: collapse; - margin: var(--space-6) 0; - border-radius: var(--radius-lg); - overflow: hidden; -} - -.docs-content th, -.docs-content td { - padding: var(--space-3) var(--space-4); - border: 1px solid var(--border-color); - text-align: left; -} - -.docs-content th { - background: var(--bg-tertiary); - font-weight: 600; -} - -.docs-content img { - max-width: 100%; - height: auto; - border-radius: var(--radius-lg); - margin: var(--space-6) 0; - box-shadow: var(--shadow-md); -} - -/* Header anchors */ -.header-anchor { - color: var(--text-primary); - text-decoration: none; -} - -.header-anchor:hover { - color: var(--color-primary); -} - -/* Docs nav footer */ -.docs-nav-footer { - border-top: 1px solid var(--border-color); -} - -/* ============================================ - BLOG - ============================================ */ - -.blog-container { - max-width: var(--content-width); - margin: 0 auto; - padding: var(--space-12) var(--space-4); -} - -.blog-header { - text-align: center; - margin-bottom: var(--space-12); - padding-bottom: var(--space-8); - border-bottom: 1px solid var(--border-color); -} - -.blog-logo { - width: 100px; - height: auto; - border-radius: var(--radius-lg); - margin-bottom: var(--space-4); -} - -.blog-header h1 { - margin-bottom: var(--space-2); - font-size: var(--text-3xl); -} - -.blog-subtitle { - color: var(--text-tertiary); - margin: 0; -} - -.post-list { - list-style: none; - padding: 0; - margin: 0; -} - -.post-list .blog-post { - padding: var(--space-6) 0; - border-bottom: 1px solid var(--border-color); -} - -.post-list .blog-post:last-child { - border-bottom: none; -} - -.post-title { - font-size: var(--text-xl); - font-weight: 600; - color: var(--text-primary); - display: block; - margin-bottom: var(--space-1); - transition: color var(--transition-fast); -} - -.post-title:hover { - color: var(--color-primary); -} - -.post-meta { - color: var(--text-tertiary); - font-size: var(--text-sm); - margin-bottom: var(--space-2); -} - -.post-excerpt { - color: var(--text-secondary); - margin: 0; - line-height: 1.6; -} - -/* Single blog post page */ -article.blog-post { - padding: 0; -} - -article.blog-post .blog-container { - padding: var(--space-8) var(--space-4); -} - -.blog-post-header { - margin-bottom: var(--space-6); - padding-bottom: var(--space-4); - border-bottom: 1px solid var(--border-color); -} - -.blog-post-header h1 { - font-size: var(--text-3xl); - margin-bottom: var(--space-2); - line-height: 1.2; -} - -.blog-post-meta { - color: var(--text-tertiary); - font-size: var(--text-sm); - margin: 0; -} - -.blog-post-tags { - margin-top: var(--space-3); -} - -.blog-post-content { - max-width: var(--content-width); -} - -/* Remove duplicate h1 from markdown content */ -.blog-post-content h1:first-child { - display: none; -} - -.blog-post-content h2 { - font-size: var(--text-2xl); - margin-top: var(--space-8); - margin-bottom: var(--space-3); -} - -.blog-post-content h3 { - font-size: var(--text-xl); - margin-top: var(--space-6); - margin-bottom: var(--space-2); -} - -.blog-post-content p { - margin-bottom: var(--space-4); -} - -.blog-post-content pre { - margin: var(--space-4) 0; -} - -.blog-post-footer { - margin-top: var(--space-8); - padding-top: var(--space-4); - border-top: 1px solid var(--border-color); -} - -/* Tags */ -.tag { - display: inline-block; - padding: var(--space-1) var(--space-3); - font-size: var(--text-xs); - font-weight: 500; - background: var(--color-primary); - color: white; - border-radius: var(--radius-full); -} - -.tag-secondary { - background: var(--bg-tertiary); - color: var(--text-secondary); -} - -a.tag-secondary:hover { - background: var(--color-primary); - color: white; -} - -/* ============================================ - FOOTER - ============================================ */ - -.site-footer { - background: var(--bg-secondary); - border-top: 1px solid var(--border-color); - position: relative; -} - -.site-footer::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 2px; - background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); -} - -.footer-section h3 { - font-size: var(--text-sm); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: var(--space-4); - color: var(--text-tertiary); -} - -.footer-section ul { - list-style: none; - padding: 0; - margin: 0; -} - -.footer-section li { - margin-bottom: var(--space-2); -} - -.footer-section a { - color: var(--text-secondary); - font-size: var(--text-sm); -} - -.footer-section a:hover { - color: var(--color-primary); -} - -.footer-bottom { - border-top: 1px solid var(--border-color); -} - -.footer-bottom p { - font-size: var(--text-sm); - color: var(--text-tertiary); - margin: 0; -} - -/* ============================================ - SYNTAX HIGHLIGHTING (Prism) - ============================================ */ - -.token.comment, -.token.prolog, -.token.doctype, -.token.cdata { - color: var(--color-gray-500); -} - -.token.punctuation { - color: var(--text-secondary); -} - -.token.property, -.token.tag, -.token.boolean, -.token.number, -.token.constant, -.token.symbol, -.token.deleted { - color: var(--color-secondary); -} - -.token.selector, -.token.attr-name, -.token.string, -.token.char, -.token.builtin, -.token.inserted { - color: var(--color-success); -} - -.token.operator, -.token.entity, -.token.url, -.language-css .token.string, -.style .token.string { - color: var(--color-warning); -} - -.token.atrule, -.token.attr-value, -.token.keyword { - color: var(--color-accent); -} - -.token.function, -.token.class-name { - color: var(--color-primary); -} - -.token.regex, -.token.important, -.token.variable { - color: var(--color-secondary); -} - -/* ============================================ - UTILITIES - ============================================ */ - -.text-center { text-align: center; } -.text-left { text-align: left; } -.text-right { text-align: right; } - -.mt-0 { margin-top: 0; } -.mt-sm { margin-top: var(--space-2); } -.mt-md { margin-top: var(--space-4); } -.mt-lg { margin-top: var(--space-6); } -.mt-xl { margin-top: var(--space-8); } -.mt-2xl { margin-top: var(--space-12); } - -.mb-0 { margin-bottom: 0; } -.mb-sm { margin-bottom: var(--space-2); } -.mb-md { margin-bottom: var(--space-4); } -.mb-lg { margin-bottom: var(--space-6); } -.mb-xl { margin-bottom: var(--space-8); } -.mb-2xl { margin-bottom: var(--space-12); } - -.flex { display: flex; } -.flex-wrap { flex-wrap: wrap; } -.items-center { align-items: center; } -.justify-center { justify-content: center; } -.justify-between { justify-content: space-between; } -.gap-xs { gap: var(--space-1); } -.gap-sm { gap: var(--space-2); } -.gap-md { gap: var(--space-4); } -.gap-lg { gap: var(--space-6); } - -.hidden { display: none; } -.block { display: block; } -.grid { display: grid; } - -/* ============================================ - RESPONSIVE - ============================================ */ - -@media (max-width: 1024px) { - .sidebar { - position: fixed; - top: var(--header-height); - left: 0; - width: var(--sidebar-width); - height: calc(100vh - var(--header-height)); - z-index: 50; - transform: translateX(-100%); - } - - .sidebar.open { - transform: translateX(0); - } -} - -@media (max-width: 768px) { - :root { - --text-5xl: 2rem; - --text-4xl: 1.75rem; - --text-3xl: 1.375rem; - --text-2xl: 1.25rem; - } - - .container { - padding: 0 var(--space-3); - } - - .hero { - padding: var(--space-10) 0; - } - - .hero h1 { - font-size: var(--text-4xl); - } - - .hero h1 br { - display: none; - } - - .hero-tagline { - font-size: var(--text-base); - } - - .hero-actions { - flex-direction: column; - align-items: stretch; - } - - .hero-logo { - max-width: 200px; - } - - pre { - font-size: var(--text-xs); - padding: var(--space-3); - border-radius: var(--radius-md); - } - - .features { - grid-template-columns: 1fr; - gap: var(--space-4); - padding: var(--space-10) 0; - } - - .feature-card { - padding: var(--space-4); - } - - .blog-container, - .blog-post { - padding: var(--space-10) var(--space-4); - } - - .docs-content { - padding: var(--space-4); - } - - table { - font-size: var(--text-sm); - } - - th, td { - padding: var(--space-2); - } - - .nav-actions { - gap: var(--space-2); - } - - .theme-toggle { - width: 36px; - height: 36px; - } - - .language-btn { - padding: var(--space-1) var(--space-2); - font-size: var(--text-xs); - height: 36px; - } -} - -@media (max-width: 480px) { - :root { - --text-5xl: 1.75rem; - --text-4xl: 1.5rem; - --text-3xl: 1.25rem; - --text-2xl: 1.125rem; - --text-xl: 1rem; - } - - .container { - padding: 0 var(--space-4); - } - - .hero { - padding: var(--space-8) 0; - } - - pre { - font-size: 0.65rem; - padding: var(--space-2); - } - - .btn { - padding: var(--space-2) var(--space-4); - font-size: var(--text-sm); - } - - .btn-large { - padding: var(--space-3) var(--space-5); - font-size: var(--text-base); - } - - .language-btn span:not(.chevron) { - display: none; - } - - .language-dropdown { - right: 0; - left: auto; - min-width: 120px; - } -} - -/* ============================================ - ANIMATIONS - ============================================ */ - -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { - animation: none !important; - transition-duration: 0.01ms !important; - } -} - -@keyframes fadeIn { - from { opacity: 0; transform: translateY(10px); } - to { opacity: 1; transform: translateY(0); } -} - -.animate-fade-in { - animation: fadeIn 0.5s ease-out; -} - -/* ============================================ - API REFERENCE - ============================================ */ - -.api-reference { - max-width: var(--content-width); - margin: 0 auto; - padding: var(--space-8) var(--space-4); -} - -.api-reference h1 { - margin-bottom: var(--space-2); -} - -.api-subtitle { - color: var(--text-tertiary); - margin-bottom: var(--space-8); -} - -.api-package { - margin-bottom: var(--space-8); - padding-bottom: var(--space-6); - border-bottom: 1px solid var(--border-color); -} - -.api-package:last-of-type { - border-bottom: none; -} - -.api-package h2 { - font-size: var(--text-xl); - margin-bottom: var(--space-2); -} - -.api-package h2 a { - color: var(--text-primary); -} - -.api-package h2 a:hover { - color: var(--color-primary); -} - -.package-install { - margin-bottom: var(--space-4); -} - -.package-install code { - background: var(--bg-tertiary); - padding: var(--space-1) var(--space-2); - border-radius: var(--radius-sm); - font-size: var(--text-sm); -} - -.api-package h3 { - font-size: var(--text-base); - color: var(--text-secondary); - margin-bottom: var(--space-3); - font-weight: 500; -} - -.api-package h3 code { - color: var(--color-primary); - background: none; - font-weight: 600; -} - -.api-list { - margin: 0; - padding: 0; -} - -.api-list dt { - font-weight: 600; - margin-top: var(--space-3); -} - -.api-list dt a { - color: var(--color-primary); -} - -.api-list dd { - margin-left: 0; - color: var(--text-secondary); - font-size: var(--text-sm); -} - -.api-list dd ul { - margin: var(--space-1) 0 0 var(--space-4); - padding: 0; -} - -.api-list dd li { - margin-bottom: var(--space-1); -} - -.api-list dd li a { - color: var(--text-primary); - font-family: var(--font-mono); - font-size: var(--text-xs); -} - -.api-list dd li a:hover { - color: var(--color-primary); -} - -.api-external { - margin-top: var(--space-8); -} - -.api-external h2 { - font-size: var(--text-lg); - margin-bottom: var(--space-3); -} - -.api-external ul { - margin: 0; - padding: 0; - list-style: none; -} - -.api-external li { - margin-bottom: var(--space-2); - font-size: var(--text-sm); -} +*{box-sizing:border-box}html{background:#101313;color:#edf0e8;font:16px/1.65 system-ui,sans-serif}body{overflow-wrap:anywhere;margin:auto;max-width:1440px;padding:0 5%}a{color:inherit;text-decoration:none}a:hover,em,.active{color:#d8ff62}em{font-style:normal}button,.button,summary{font:inherit;cursor:pointer}button,.button{border:1px solid #50594e;border-radius:4px;padding:.65em 1em;background:transparent;color:inherit}.primary,[aria-pressed=true]{background:#d8ff62;color:#101313;border-color:#d8ff62}h1,h2,h3{line-height:1.1;letter-spacing:-.045em}h1{font-size:clamp(3rem,7vw,6rem)}h2{font-size:clamp(2rem,4vw,3.3rem)}h3{font-size:1.65rem}.hero h1{font-size:clamp(4rem,10vw,9rem);margin:.3em 0}.hero p{max-width:40rem}p,small{color:#adb6a7}.eyebrow{font:11px/1.8 monospace;letter-spacing:.13em;text-transform:uppercase}.bar,.actions,nav{display:flex;gap:1.2rem;align-items:center;flex-wrap:wrap}.bar{justify-content:space-between}.brand{display:flex;align-items:center;gap:.6rem;font-weight:800}header.bar{padding:1.6rem 0;border-bottom:1px solid #343c34;font-size:.85rem}section{padding:3.8rem 0}.split,.grid{display:grid;gap:2rem;grid-template-columns:repeat(auto-fit,minmax(min(100%,340px),1fr))}.grid{gap:1rem}.feature-card,.lab{border:1px solid #343c34;border-radius:12px;padding:1.6rem;background:#171c19}canvas{display:block;width:100%;height:auto}pre{background:#0a0e0c;border:1px solid #343c34;border-radius:8px;padding:1.4rem;overflow:auto;font-size:.82rem}.keyword{color:#a9dfff}.string{color:#d8ff62}.comment{color:#adb6a7}.reading{display:grid;grid-template-columns:200px minmax(0,1fr);gap:4rem;padding:4rem 0}.sidebar nav{display:grid;gap:.7rem;font-size:.85rem}.prose{max-width:76ch;min-width:0}.article{margin:4em auto}.prose a{text-decoration:underline;text-underline-offset:4px}.prose h2{font-size:2rem;margin-top:2em}.prose h3{font-size:1.4rem}.prose table{display:block;overflow:auto;border-collapse:collapse}td,th{padding:.6rem;border-bottom:1px solid #343c34;text-align:left}blockquote{border-left:2px solid #d8ff62;padding-left:1rem;margin-left:0}footer{padding:3rem 0;border-top:1px solid #343c34}.skip{position:absolute;top:-60px}.skip:focus{top:0}a:focus-visible,button:focus-visible,summary:focus-visible{outline:2px solid #d8ff62;outline-offset:4px}[hidden]{display:none!important}@media(max-width:700px){.reading{grid-template-columns:1fr;gap:2rem}.sidebar nav{display:flex}section{padding:2.5rem 0}}#outcome-code strong{color:#d8ff62}summary{padding:1em} diff --git a/Website/src/assets/images/mark.svg b/Website/src/assets/images/mark.svg new file mode 100644 index 00000000..fee805fd --- /dev/null +++ b/Website/src/assets/images/mark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Website/src/assets/images/social-card.png b/Website/src/assets/images/social-card.png new file mode 100644 index 0000000000000000000000000000000000000000..f4d0fd0c31a478ea0301353fc75aee898142b7d4 GIT binary patch literal 36202 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νh`x7fhJy$ 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�fhuig6>@BBB#ZMSDBRD^avO<@p`;$U=1iCfk z(D;c`+5&v4+@s#8?SQ`SO_yX(u3?pPCYLkDVO?JP_Z{}E>^A9v1^XuRDA{Hwwx?W+ssF92`HUUt~Iy@3IcXyWiK# zlThD*E$K(cXC4*O288--YlzUmn5gZrIqU1G%ele+qeaGzZ4Fb z8LCHC??}I(!fiuAc?H@1?DfRj*xEB`e~Vu?NBq9yqg&(_zd_q@o`2l@sR}YFq~H%#H8&&F|y}hqU7jW6|PdTbC0MUp;Sb2tW1s}kMSvziyOWU+n?lQaa+z%TUQ)94ZOfY zy?e3%eYZB_gJv)ld_Fzcl%;l>;4Nvp4@qNWMP@z9EVW2XlB0YA1XsiQ`Ts7FUGBX_ zHagRf+pS6=~ z{#LqQGkwt7ts1rXuD6w8_^eU{D{y!Tqq=n0jWq(}gvsYMOe{#VJ$j!F)@oH3wo!3D z=@K}c8SS73Vy$R{x`D5Z51U8wIwP@pJ$o1W13;3(Bgd_G#&9+S4fCmxFb~_2+GG6Pm1Rg*>Ay&3u zC0)b!i4EbQ3j&Rr?YNWlGq3B1BmT7O08#$H@<~})4lu>JTYt2ocZL*^=b?(u{6DeWN&UCbViO3ZGy5^AY0eRtt z_;0W`ZOxa(b+_c2?$#V*lbOIhWJCBg)M4BY|bV4%qx#^Nu zjc8;|XwNR_b2Y(li@ptBOfDG;Ev?KEtzNyJ%8Ch)VD@;$_N_UgQOo(*L?%pji+UGSZYEo3JW7Bs|kW zEkB$|H>i&#-CM$*!IQK{vdq)Pd%R{jtFh}8-9@k^Q*L#i%6|1?*Bfll;sx}MQ-{oVM+Uz0LP7|$sBym?uc3Ja8l4eT7B^TEx`Qp};BN;|?9Gw)f zF;fK>Gb%nKc-GlMm7LYxctGCdBF0gpsd4GXSU!RfJ%BL~)?Ccxf5OcWPYwzC`vWFX ziZT!n7o^hyy`an?_AL?6ArWw~!Q%vAcwBS}PIJ2awK+oLlM^z6?K209ZV7{DX_fW` zyTK=|Ck;srlV4QF=XoN-BUI$KjGBKmvv4`(%S`dU+v%H@9&O-$5fXxpA41cU&hY6e zCtLgEnPB>bg!2i)>hu@|{%E?4-_ca_JPH~s;(8tMpzxTCgv=bNvBBnPqAxG2vVv_K zBYJIg{z}3WAoBC_Xg3)yKQeZTPwR8A4;sYtU}0dgFq0fz_RspxP2D{^0sF^Hw9-Ku zC{eDN5IoH$yH|y?vXN(Qww8$UX*V@7+21eHnhlWX$pOWYBG&ygLF(UGkF_0%y6NTR zh_x&ao>P*LxcG`0Eh6Z<*48CEL9x42l*7Tk2QVu?ymg%^Pmx$rtCJ@iAJm(N@bGM{ z{?sfDZ5CKQ=4|0>BmF#7BSUOhA-KIYy4IOEhU;l83G)t&F z;bLg-YIKw~l;k)NC!1bDZqcf$jT`6@y-sQMUnY_Ei z2@Qu#UakfyqASh5geE~#h=c@NG^S$%?w*f^MU`)kjtEgCm7gJz_5)o_@O8nIq$Fq> zFJDt97xW!O9*NhsN6gh=R48{I;b9tz;&PHQnHE!e{osMF#A|03F(LE4_ybUr?!Z$6 zJT6(vg41_s*f3U9LQMQm7l$YbP@g;GI$$p$%T^&Iij$EmPwLWR`*DYOcwY#NGLHH+ zr?tC{P1NynQJ9C4tanK}|H|5XNL#N%yVXd(jxS(iMY}ca@NT;Nvm%pgD|?wLZ++n1 zq=c-v7iNj2{^otd(;gU_&udl(Bf@Zwte98cJbeTP7nX`kV6B2zGSv9KtzAswlsxK+yF|B>uTp zVp19xWYVVP#Gr#Vhz`_fiUqN0^&CqtH>9A_$?}hbrU_eRRrM(bJbue)4J}2SQwJc?3NJXWWiuTkg&prL|b>0j6QY0QF<&V^sIWq@$ z=0Mo3&*a<}28e~K=b=#MJ3h#zzeA9zY0s;;Ir6aK01*<~Y^+jx`57S%CBACWh%>!A zU5PFC<($~%HqG33^mNq2Z379ol3n~PH0NP25XCy)i^*5XVq|BZ=K`?ss<=)!y6Z4g z=|p{9C~&@2C5v6X#j6=j9Mhf25vy>C0_IV2?0j zq-t~Ge0ha9%YvVpR~{Oj_`|I9A{3KaZUCTaBo_w_&J3v8{_fI!7IkvQ%JD(^sZig4* z!Y-r*obc6L8-Mzdi+LlJ3R~tSw0u?@qfV0IIO z0Lzm&!EIxPns55$yPf`a2n}71E$01u+2JS(dC!PpGUHOKhy6ry~Xw%T{6%wu(Id+({ zwr~V&t31n6-}BPunsnMXTqhR{g-E21pM>QPhWt%aUpFY; z!!PuwVC3RrYrO54+rfe*$bb%996r@8eD55875sTL34EfMM|TI92zL zGh;eQ#K{+WIaAQa_%f!cPq8Q6&iYaNEWp~`N5Xw?#!4}50B$vC#UE0PAf;qZ57l}e z?6Op0M#kzn(Od!gYK&YC}-aGRv6!05jJAbI;+sX|jqAl9J zdlGnxQeaFLvv@&nM|Kn+muhH{vk)-+rwc?0(;#&Y9UhpR7~O(EZ1q^ls8Va!+C%LP z$rBt8S7&h=T*tOXalG}IYA7g}g91 z3{E77yAhY$t2(b2xK)#?J*{SY>&$SoTb~oU9b=*9Q6AH$yZ4Sk5XTRt@Y)S2$nI6y zX(FmWf#^q~L}AhyNYbW*glWta4ZVFG6r{KIRdvGr5dqS3O38Xjkj6{_|c za))t5B50_S?qAqL615ed>L)H-UrNz9##B2pyKDeaJ^-E^)^V)<33s%|IYVKS6WXSx z#27FLlC@`22$z~xuID9S5fb5|4{RZgHhE`oAjmgSXolLUoM&cEiCJB*!s$PTw= zF9XBUGgV9bf!y#LOof>NU^lIWY1#}45SKFPgm&!|IF;vpam>`zK&1?O^e&VxUk*Jb zk}Av{EWh>ON*QQNT@!baS=W>&!|%b8c}_`0DayoMFXxoD41Q5>aQ&&dp8aN}zCH@B zPeP<^$&oZP*_^|5t_v?t7Ig00Hn~t52&fj36%$Q>8+n96MH^BJ_wcut!ZqhF91P&qJTC)U8xzGaiwQtp__~#0$SDC<%Kc2u4(@BMn-)Vp( zR=jV@-AjY9;}lC#&f_i^&v$>Owy1EN)+!8hnCI9s@ZrTGDjUD#wKzFZcj{CvkmIH& z?&fIcR1=gR8VZ=nenosOB+?VW< zv=p0dyvNduFf;C&lLjQivWy6LVp^VE2y9g^kAb?#1cUssaj+-|cJ`50X=hix^kI)h z$+h1(A@ZBIO9@&w$7h;UU%HK7 z7`m2Hbw;jwoy_csl+5n)GPCi#WW&Xn z8F5Dm_^S@i6f(k4+=78G&bZt!04Heu1LBfxt!9$!qamLs+i< zv)m*XPp-8^+r&UKC)CKi`l5pMJosuWSDnIvSt6=8Kb2l7|BRKC)=PWWlZ^;I8EX~O z9ksbwsC52Zh74P$yj0Fo`|Aglp`T4W^XY`)el*|p1bmiJw=4O+ZJ({>N4qyC+go7O z14gP6`=(H1?a!)BpF^1f;-4R%p9EHMGq!U)c<`c4=F}gk0uq zb?mlqVR!l9(Zd1*oVEiTV)k1W-jeFf*8ofQ>hBd=sLb3K{mLC_PnlG<%cO%wmq!&= zGb#9?!zATCLdwAL=gp%`awLtH&Sb86IN(>8xQHt~BQx95o0%G9=xeO2E7kL4m}(vV zl|8o|MP2THq|1&^ zL>U`1*CfPS$}8nV|B!{B5|7l-PqFBIt7MZhL)LkcHpS|sjMMY4S9dM48wf*8DxPQN z?|=bz+m<2c?)Rn>0gsTpYh6(VJM)uusJe}!xwM(L#7On{xd<7Dn7kRhwD!%hCA@** z8mq8V9VL>$&dz`fjnp3~5n5B|4(NmoPk?$3_9(x!(eft3B`Kbhuk;M7VmCH3(Xq(2 zhrp=}5<;3*?wb@0GN_$CTdoG2d5wFPV=G43&{Nbym6*M=a`(5H3-)I6gMTA^KHx~s zQ*DBWhqcq=VN8e;xQ-*2sw#U4d!w+(aRoyN<%oXz(TG4e@zV#tLf-@9N7@QDWo!G> zDOX1pTqEl2U_>;MaL ze5HUSQi{Tc-Ss!qi~NQ$W!S&h{wpE#af6J2sHD^Yz4%+1hO~T*3q!Ki#Ok?(K)krx zWzS(>1MFKid*D@q+P5uIUZ(Tq1PvqZG3}XzN+vxY^ej4h5xN20Mvf-|LC*E(xj5+0 zpKn`ole%DAD|G!PPP2dTnZrDHdSPwh+X~GixqY?_uE_L8i}_>^s`#5YK{_gxFF|4Z zaX~8T{v|41RdnV$kulg!K^!~t`*q;O+V$TRRT6K~fK*fKwjVwUjWb;ek^mwH^8N-ASbaVr72{LVta^B1&GCDVXj0#@e7m z0wXL>eiq4s(&RqJscR#YQ#}h=<($bJxkw7Z;MzgdoQna~Gl3iNj)4Z}K`jC{lQuWD zRHe-L`9un#lUor!rNToJJrtp{x(PnDtLv+Y1(=T>eBpSM$uim}r&xE2t@jL#yKY!a zMxpRaC1w~kx6kGHA$50FYX(3IF2nI*^68DE7M;2NW=>)i9{osV{Oj;+(ZqyT_+y!t z?=$YBW~*Bm*|Q`s&<4`yvS|7MYrG~@Lho%4 z9(J1^SD2(=bR8nw*V2eu`27lx0)N{E0iwyYEcY0=J?VoPFq{}4Ys2S?|`2$zEzZaMtW7$$9(c);kHdJtJKb3bHzHfM zu>`-{Sl6toQeDRFY87B?-qduygi%K?r-@DL?hPBnCoVfXb&0FF#xp$ixWrE97U6hX z;^aUvc)HptF-`PnSRSE$LJvSP95ziZ$3MZ)01`UYh^|KJs~1 z_RWrJpB8xIy&UVo?3;>O-i4Wp1&tX&m4%;-UUK_XE&#@wH0~v_!;+Go{SHa=JJV2? z@k-0Pu9~nqa%VAQpcm|KDNbk_sTP4{DZBaHIOACzGg%Da!WEiTvcqXbv4oyng?KF+ zAD|qXzZP`Wcpi1uO(EFEfhfBDWQmcm2pdgJKD0cl{4V1lkq-oE^YQ5@h=g(1({V5f zUUd9yYt+1zgnX-2)brL7c`>OVDnFC729_;6jQ}pD&v#^T9({VF*CJ#qFNCxT<2NwA zpmCd7Gz7Ao>1eA}E7yMIHWAD1xgT$_vLkl@@EsYT&or==cq@ zlNY;w3@UY1dQOE~H)=voi7(V#>jFJITT#260d>$AZuW&e!Vly-YZK#joTxvKxND|m zndyshyOfIX^q4$IV9kR7vB0UR*3P@VRmP&2uU0PC{;%d^&ntaeKbquWg)m+S9ba}P zhA&p8T7BORxc=qKK^>@qymUf6A%Hz?TCT+Ub5KD1 zDCJ&?EidRok?X_@ctK+ek#epO7jn71?U0oYuI%)1%;(mnYROkPKFZgzc5zLd9Inf& z6Le73!zogc56~Y6((Ee-3HjJSxZ5hlWr%(7A!~0sJ~1C(e!rXsjeDCI>$vW(6YfY) z(}e=QP+!x&WR4*MJjN=wJ~2ucs5DcO6PGI`<}tYJRga>P{mprQn#W?WcF$FCGG|^S zRf`y>wYYomsNSgC3rbp_e(wHcTw?r$qmkkgF6Vcb+wxXkvav%b$ve%3Wqax%l`P;) zAQ_QTHIRI+HeaJs{)KF&=a9uBxBY>ttS=%AMTo4S<- z8Y{4B9`ONO@DU-DCKY+Ho#ze2``+4#F`RUwL%PaHQVx!zLJKlS$(@>HU4^q-n8E&eVt#4@q(|q<~yWe5fH0g3~J{ACBmTPJ$m& zL}hY=6sdd>yGn0#?QNhsxB^3JX0tmQ07o{JI|43~#J01X5qlOKuS5>Hf;e!s4fHO6 zU%yj%~J8muMt&x0|lnNphM z^a@7=1%+_>H=Ec4f)_g zR7klu%AiC%CMU;?0tsd$O7QOU!=>^(Gpf4{79?ntir{ajU>|Toqa{exJo#wTt^SC*><#^xVxCknFrp1X03>jEwwHp74E2S;i^tOTio) z!sK^Xet(vSs#*2$OjqE=?v?KMZPmZgHXo-B>$08o?e&>W74b5uuO_6pZ!lx>bKHL< zNCbx8OY`3%AL{{k=)c^c2c-8D$iUzKCtm;WGI85?fZE;N4LUIa`IHaP?vFB$A8bbD V3tEPjCf-k_jD+H+GV#yf{|5~n&^`bF literal 0 HcmV?d00001 diff --git a/Website/src/assets/images/social-card.svg b/Website/src/assets/images/social-card.svg new file mode 100644 index 00000000..1aecfdbc --- /dev/null +++ b/Website/src/assets/images/social-card.svg @@ -0,0 +1 @@ +RESTCLIENT.NETEvery outcome.In view.Typed HTTP. Explicit errors. Confident C#. \ No newline at end of file diff --git a/Website/src/assets/js/site.js b/Website/src/assets/js/site.js new file mode 100644 index 00000000..10d29a7f --- /dev/null +++ b/Website/src/assets/js/site.js @@ -0,0 +1,124 @@ +const isChinese = document.documentElement.lang === 'zh'; +const menu = document.querySelector('#mobile-nav'); +const narrow = matchMedia('(max-width:700px)'); +function syncMenu() { if (menu) { menu.open = !narrow.matches; menu.querySelector('summary').hidden = !narrow.matches; } } +syncMenu(); +narrow.addEventListener('change', syncMenu); + +const canvas = document.querySelector('#flow-canvas'); +if (canvas && canvas.getContext('2d')) { + const ctx = canvas.getContext('2d'); + canvas.hidden = false; + const reduced = matchMedia('(prefers-reduced-motion:reduce)'); + const controls = [...document.querySelectorAll('[data-outcome]')]; + const toggle = document.querySelector('#motion-toggle'); + const status = document.querySelector('#outcome-status'); + const code = document.querySelector('#outcome-code code'); + const descriptions = isChinese ? [ + '200 OK → 成功结果中包含已反序列化的响应。', + '404 Not Found → ResponseError 中保留响应数据、状态码与标头。', + '连接失败 → ExceptionError 中保留异常,交由调用方明确处理。' + ] : [ + '200 OK → Your deserialized response, wrapped in a typed success.', + '404 Not Found → ResponseError preserves the response body, status code, and headers.', + 'Connection failed → ExceptionError carries the exception as an explicit result.' + ]; + const branches = [ + 'OkPost(var post) => post.Title,', + 'ErrorPost(ResponseErrorPost(var error, var status, _)) => $"HTTP {status}",', + 'ErrorPost(ExceptionErrorPost(var exception)) => exception.Message' + ]; + let selected = 0, paused = reduced.matches, visible = true, frame = 0, time = 0, lastTime = 0, stopped = false; + const colors = ['#d8ff62', '#ffbc82', '#9cb7ff']; + const names = ['Ok', 'ResponseError', 'ExceptionError']; + const ys = [95, 230, 365]; + function motionText() { + toggle.textContent = isChinese ? (paused ? '播放动画' : '暂停动画') : (paused ? 'Play motion' : 'Pause motion'); + toggle.setAttribute('aria-pressed', String(paused)); + canvas.dataset.motion = paused ? 'paused' : 'running'; + } + const round = (x,y,w,h,r=12) => {ctx.beginPath();ctx.roundRect(x,y,w,h,r);}; + const text = (value,x,y,size=13,color='#adb6a7',weight='400') => { + ctx.fillStyle=color;ctx.font=`${weight} ${size}px ui-monospace, monospace`;ctx.fillText(value,x,y); + }; + function route(i) {ctx.beginPath();ctx.moveTo(596,230);ctx.bezierCurveTo(750,230,715,ys[i],840,ys[i]);} + function drawMobile() { + ctx.clearRect(0,0,600,650); + for(let x=20;x<600;x+=28) for(let y=20;y<650;y+=28) {ctx.fillStyle='#2a342a';ctx.fillRect(x,y,1,1);} + ctx.strokeStyle=colors[selected];ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(300,145);ctx.lineTo(300,500);ctx.stroke(); + for(let r=86;r<=120;r+=34) {ctx.beginPath();ctx.arc(300,318,r,0,Math.PI*2);ctx.strokeStyle='#344a2c';ctx.stroke();} + ctx.save();ctx.translate(300,318);ctx.rotate(time*.14);ctx.setLineDash([8,15]);ctx.strokeStyle=colors[selected];ctx.beginPath();ctx.arc(0,0,120,0,Math.PI*2);ctx.stroke();ctx.restore(); + round(110,65,380,90);ctx.fillStyle='#121a15';ctx.fill();ctx.strokeStyle='#506448';ctx.stroke();text('HTTP REQUEST',135,98,17);text('GET /posts/1',135,132,26,'#edf0e8'); + ctx.shadowColor=colors[selected];ctx.shadowBlur=26;round(212,269,176,98,16);ctx.fillStyle='#1f2b1d';ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle=colors[selected];ctx.stroke();text('Result',241,313,30,'#edf0e8','600');text('',258,343,19,colors[selected]); + round(60,492,480,98);ctx.fillStyle='#24301f';ctx.fill();ctx.strokeStyle=colors[selected];ctx.stroke();text(names[selected],87,534,25,colors[selected]);text(['200 · RESPONSE DATA','404 · BODY + STATUS','NETWORK · EXCEPTION'][selected],87,566,16); + if(!paused) for(let n=0;n<3;n++){const y=155+(time*.19+n/3)%1*337;ctx.shadowColor=colors[selected];ctx.shadowBlur=16;ctx.beginPath();ctx.arc(300,y,5,0,Math.PI*2);ctx.fillStyle=colors[selected];ctx.fill();ctx.shadowBlur=0;} + text('ONE CALL. EXPLICIT OUTCOMES.',90,630,15); + } + function draw() { + const mobile = narrow.matches; + const width = mobile ? 600 : 1200, height = mobile ? 650 : 470; + if(canvas.width!==width || canvas.height!==height){canvas.width=width;canvas.height=height;} + if(mobile){drawMobile();return;} + ctx.clearRect(0,0,1200,470); + for(let x=25;x<1200;x+=28) for(let y=20;y<470;y+=28) {ctx.fillStyle='#2a342a';ctx.fillRect(x,y,1,1);} + // These are drawing commands for the illustrated request model, not DOM styling. + ctx.strokeStyle='#334033';ctx.lineWidth=1; + ctx.beginPath();ctx.moveTo(225,230);ctx.lineTo(466,230);ctx.stroke(); + for(let i=0;i<3;i++) {route(i);ctx.strokeStyle=i===selected?colors[i]:'#344137';ctx.lineWidth=i===selected?2:1;ctx.stroke();} + for(let r=74;r<=130;r+=28) {ctx.beginPath();ctx.arc(530,230,r,0,Math.PI*2);ctx.strokeStyle=r===74?'#596944':'#28382a';ctx.lineWidth=1;ctx.stroke();} + ctx.save();ctx.translate(530,230);ctx.rotate(time*.14);ctx.setLineDash([7,14]);ctx.strokeStyle='#5b713d';ctx.beginPath();ctx.arc(0,0,130,0,Math.PI*2);ctx.stroke();ctx.restore(); + ctx.shadowColor=colors[selected];ctx.shadowBlur=26;round(466,187,128,86,16);ctx.fillStyle='#1f2b1d';ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle=colors[selected];ctx.stroke(); + text('Result',487,229,24,'#edf0e8','600');text('',502,252,14,colors[selected]); + round(32,189,192,82);ctx.fillStyle='#121a15';ctx.fill();ctx.strokeStyle='#506448';ctx.stroke(); + text('HTTP REQUEST',50,217,12);text('GET /posts/1',50,246,20,'#edf0e8'); + for(let i=0;i<3;i++) { + round(842,ys[i]-37,314,74);ctx.fillStyle=i===selected?'#24301f':'#121a15';ctx.fill();ctx.strokeStyle=i===selected?colors[i]:'#344137';ctx.stroke(); + ctx.beginPath();ctx.arc(864,ys[i],4,0,Math.PI*2);ctx.fillStyle=colors[i];ctx.fill(); + text(names[i],881,ys[i]-2,19,i===selected?colors[i]:'#889882','500'); + text(['200 · RESPONSE DATA','404 · BODY + STATUS','NETWORK · EXCEPTION'][i],881,ys[i]+20,11); + } + if(!paused) for(let n=0;n<4;n++) { + const p=(time*.19+n*.25)%1; + let x,y; + if(p<.5){x=224+p*2*242;y=230;} else { + const t=(p-.5)*2,q=1-t; + x=q*q*q*596+3*q*q*t*750+3*q*t*t*715+t*t*t*840; + y=q*q*q*230+3*q*q*t*230+3*q*t*t*ys[selected]+t*t*t*ys[selected]; + } + ctx.shadowColor=colors[selected];ctx.shadowBlur=16;ctx.beginPath();ctx.arc(x,y,4,0,Math.PI*2);ctx.fillStyle=colors[selected];ctx.fill();ctx.shadowBlur=0; + } + text('REQUEST',35,165,10);text('EXPLICIT OUTCOMES',843,35,10);text('ONE CALL. EVERY POSSIBILITY.',35,435,11);text('RESTCLIENT.NET / LIVE MODEL',900,435,10); + } + function tick(timestamp) { + frame=0; + if(paused || !visible || document.hidden || stopped) {lastTime=0;return;} + time+=lastTime?Math.min((timestamp-lastTime)/1000,.05):0; + lastTime=timestamp;draw();frame=requestAnimationFrame(tick); + } + function resume() { + draw();motionText(); + if(!paused && visible && !document.hidden && !frame && !stopped) frame=requestAnimationFrame(tick); + if(paused && frame) {cancelAnimationFrame(frame);frame=0;lastTime=0;} + } + controls.forEach((button,index)=>{ + button.disabled=false; + button.addEventListener('click',()=>{ + selected=index; + controls.forEach((item,i)=>item.setAttribute('aria-pressed',String(i===index))); + status.textContent=descriptions[index];canvas.dataset.outcome=button.dataset.outcome; + code.replaceChildren(); + code.append(document.createTextNode('// Selected branch: '+names[index]+'\nvar message = result switch\n{\n')); + branches.forEach((branch,i)=>{const line=document.createElement(i===index?'strong':'span');line.textContent=' '+branch+'\n';code.append(line);}); + code.append(document.createTextNode('};'));resume(); + }); + }); + toggle.hidden=false; + toggle.addEventListener('click',()=>{paused=!paused;resume();}); + reduced.addEventListener('change',()=>{paused=reduced.matches;resume();}); + narrow.addEventListener('change',resume); + document.addEventListener('visibilitychange',resume); + new IntersectionObserver(entries=>{visible=entries[0].isIntersecting;resume();}).observe(canvas); + window.addEventListener('pagehide',()=>{stopped=true;if(frame)cancelAnimationFrame(frame);frame=0;lastTime=0;}); + window.addEventListener('pageshow',()=>{if(!stopped)return;stopped=false;frame=0;lastTime=0;resume();}); + canvas.dataset.outcome='success';resume(); +} diff --git a/Website/src/blog/index.njk b/Website/src/blog/index.njk new file mode 100644 index 00000000..af8b1d5a --- /dev/null +++ b/Website/src/blog/index.njk @@ -0,0 +1,7 @@ +--- +layout: layouts/base.njk +title: Journal +lang: en +permalink: /blog/ +--- +

RESTCLIENT.NET / JOURNAL

Ideas worth building on.

Types, tools, and the craft of building reliable software.

{% for post in collections.posts %}{% endfor %}
diff --git a/Website/src/docs/api/mcp-generator.md b/Website/src/docs/api/mcp-generator.md index 62d90448..b0e852c4 100644 --- a/Website/src/docs/api/mcp-generator.md +++ b/Website/src/docs/api/mcp-generator.md @@ -281,5 +281,5 @@ The pet with ID 123 is a golden retriever named "Buddy" who is available for ado ## See Also -- [OpenAPI Generator](./openapi-generator) - Generate RestClient.Net extensions -- [Getting Started with MCP](/docs/mcp) - Tutorial and examples +- [OpenAPI Generator](/docs/api/openapi-generator/) - Generate RestClient.Net extensions +- [Getting Started with MCP](/docs/mcp/) - Tutorial and examples diff --git a/Website/src/docs/api/openapi-generator.md b/Website/src/docs/api/openapi-generator.md index 8094c857..4da2dc91 100644 --- a/Website/src/docs/api/openapi-generator.md +++ b/Website/src/docs/api/openapi-generator.md @@ -393,5 +393,5 @@ restclient-openapi generate \ ## See Also -- [MCP Generator](./mcp-generator) - Generate MCP servers from OpenAPI specs -- [Getting Started with OpenAPI](/docs/openapi) - Tutorial and examples +- [MCP Generator](/docs/api/mcp-generator/) - Generate MCP servers from OpenAPI specs +- [Getting Started with OpenAPI](/docs/openapi/) - Tutorial and examples diff --git a/Website/src/docs/exhaustion.md b/Website/src/docs/exhaustion.md index 131af419..782c42d3 100644 --- a/Website/src/docs/exhaustion.md +++ b/Website/src/docs/exhaustion.md @@ -263,3 +263,8 @@ If Exhaustion reports an error incorrectly, please [report an issue](https://git - [Error Handling](/docs/error-handling/) - Result type patterns - [Basic Usage](/docs/basic-usage/) - Getting started guide - [API Reference](/api/result-types/) - Result type documentation + + +## Bounded analysis + +Exhaustion limits how much work it performs when exploring large or recursive type hierarchies. When that budget is exceeded, `EXHAUSTION002` reports that analysis could not complete. Simplify the switch or the modeled hierarchy before treating it as exhaustively checked. The analyzer does not claim completeness when it reaches this limit. diff --git a/Website/src/examples/index.njk b/Website/src/examples/index.njk index 7c8d25dc..cdc4149e 100644 --- a/Website/src/examples/index.njk +++ b/Website/src/examples/index.njk @@ -1,280 +1,26 @@ --- layout: layouts/base.njk -title: Code Examples +title: Code examples +lang: en permalink: /examples/ --- -
-

Code Examples

-

Complete, working examples for common scenarios

- -
- -
-

Basic GET Request

-

Fetch data from a REST API with type-safe error handling.

- {% highlight "csharp" %}using System.Net.Http.Json; -using RestClient.Net; -using Urls; - -// Define models -record Post(int UserId, int Id, string Title, string Body); -record ApiError(string Message); - -// Type aliases for clean pattern matching -using OkPost = Outcome.Result> - .Ok>; -using ErrorPost = Outcome.Result> - .Error>; -using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; -using ExceptionErrorPost = Outcome.HttpError.ExceptionError; - -// Make the request -using var httpClient = new HttpClient(); -var result = await httpClient.GetAsync( - url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), - deserializeSuccess: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), - deserializeError: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") -); - -// Handle all cases -var message = result switch -{ - OkPost(var post) => $"Title: {post.Title}", - ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Error {status}: {err.Message}", - ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", -}; - -Console.WriteLine(message);{% endhighlight %} -
- -
-

POST Request with Body

-

Create a new resource with a JSON request body.

- {% highlight "csharp" %}record CreatePostRequest(string Title, string Body, int UserId); - -var newPost = new CreatePostRequest("My Title", "My content", 1); - -var result = await httpClient.PostAsync( - url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(), - body: newPost, - serializeRequest: body => JsonContent.Create(body), - deserializeSuccess: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), - deserializeError: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") -); - -var message = result switch -{ - OkPost(var post) => $"Created post with ID: {post.Id}", - ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Failed: {status}", - ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", -};{% endhighlight %} -
- -
-

Using IHttpClientFactory

-

Proper HttpClient usage in ASP.NET Core applications.

- {% highlight "csharp" %}// Program.cs - Register the client -builder.Services.AddHttpClient("api", client => -{ - client.BaseAddress = new Uri("https://api.example.com"); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - client.Timeout = TimeSpan.FromSeconds(30); -}); - -// UserService.cs - Use the client -public class UserService(IHttpClientFactory httpClientFactory) -{ - public async Task>> GetUserAsync( - string userId, - CancellationToken ct = default) - { - var client = httpClientFactory.CreateClient("api"); - - return await client.GetAsync( - url: $"/users/{userId}".ToAbsoluteUrl(), - deserializeSuccess: Deserializers.Json, - deserializeError: Deserializers.Error, - cancellationToken: ct - ); - } -}{% endhighlight %} -
- -
-

Retry Policy with Polly

-

Add automatic retries for transient failures.

- {% highlight "csharp" %}// Program.cs -builder.Services.AddHttpClient("api") - .AddStandardResilienceHandler(options => - { - options.Retry.MaxRetryAttempts = 3; - options.Retry.Delay = TimeSpan.FromMilliseconds(500); - options.Retry.UseJitter = true; - options.Retry.ShouldHandle = args => ValueTask.FromResult( - args.Outcome.Exception is not null || - args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError - ); - });{% endhighlight %} -
- -
-

Authentication Handler

-

Automatically add authentication tokens to requests.

- {% highlight "csharp" %}public class AuthenticationHandler(ITokenService tokenService) : DelegatingHandler -{ - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - var token = await tokenService.GetAccessTokenAsync(cancellationToken); - - request.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", token); - - var response = await base.SendAsync(request, cancellationToken); - - // Refresh token if expired - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - token = await tokenService.RefreshTokenAsync(cancellationToken); - request.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", token); - response = await base.SendAsync(request, cancellationToken); - } - - return response; - } -} - -// Program.cs -builder.Services.AddTransient(); -builder.Services.AddHttpClient("api") - .AddHttpMessageHandler();{% endhighlight %} -
- -
-

Status Code Specific Handling

-

Handle different HTTP status codes differently.

- {% highlight "csharp" %}var result = await httpClient.GetUserAsync(userId); - -var message = result switch -{ - OkUser(var user) => $"Found: {user.Name}", - - // Not Found - user doesn't exist - ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => - "User not found. Please check the ID.", - - // Unauthorized - need to log in - ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => - "Please log in to view this user.", - - // Forbidden - not allowed - ErrorUser(ResponseErrorUser(_, HttpStatusCode.Forbidden, _)) => - "You don't have permission to view this user.", - - // Rate limited - ErrorUser(ResponseErrorUser(_, HttpStatusCode.TooManyRequests, var response)) => - { - var retryAfter = response.Headers.RetryAfter?.Delta; - return $"Too many requests. Try again in {retryAfter?.TotalSeconds ?? 60} seconds."; - }, - - // Server error - ErrorUser(ResponseErrorUser(var err, var status, _)) when (int)status >= 500 => - "The server is experiencing issues. Please try again later.", - - // Other API errors - ErrorUser(ResponseErrorUser(var err, var status, _)) => - $"API Error {(int)status}: {err.Message}", - - // Network/timeout errors - ErrorUser(ExceptionErrorUser(TaskCanceledException ex)) - when ex.CancellationToken.IsCancellationRequested => - "Request was cancelled.", - - ErrorUser(ExceptionErrorUser(TaskCanceledException)) => - "Request timed out. Please try again.", - - ErrorUser(ExceptionErrorUser(HttpRequestException)) => - "Network error. Please check your connection.", - - ErrorUser(ExceptionErrorUser(var ex)) => - $"Unexpected error: {ex.Message}", -};{% endhighlight %} -
- -
-

Chaining Multiple Requests

-

Chain dependent API calls with proper error propagation.

- {% highlight "csharp" %}// Get user, then get their orders, then get order details -public async Task>> GetUserOrderDetailsAsync( - string userId, - CancellationToken ct) -{ - // First, get the user - var userResult = await httpClient.GetUserAsync(userId, ct); - - return await userResult switch - { - OkUser(var user) => await GetOrdersForUserAsync(user, ct), - ErrorUser(var error) => new Result> - .Error(error), - }; -} - -private async Task>> GetOrdersForUserAsync( - User user, - CancellationToken ct) -{ - var ordersResult = await httpClient.GetOrdersAsync(user.Id, ct); - - return ordersResult switch - { - OkOrders(var orders) => new Result> - .Ok(new OrderDetails(user, orders)), - ErrorOrders(var error) => new Result> - .Error(error), - }; -}{% endhighlight %} -
- -
-

Parallel Requests

-

Make multiple independent requests in parallel.

- {% highlight "csharp" %}public async Task GetDashboardAsync(string userId, CancellationToken ct) -{ - // Start all requests in parallel - var userTask = httpClient.GetUserAsync(userId, ct); - var ordersTask = httpClient.GetOrdersAsync(userId, ct); - var notificationsTask = httpClient.GetNotificationsAsync(userId, ct); - - // Wait for all to complete - await Task.WhenAll(userTask, ordersTask, notificationsTask); - - var userResult = await userTask; - var ordersResult = await ordersTask; - var notificationsResult = await notificationsTask; - - // Combine results - return (userResult, ordersResult, notificationsResult) switch - { - (OkUser(var user), OkOrders(var orders), OkNotifications(var notifications)) => - new Dashboard(user, orders, notifications), - - (ErrorUser(var e), _, _) => - throw new Exception($"Failed to load user: {e}"), - (_, ErrorOrders(var e), _) => - throw new Exception($"Failed to load orders: {e}"), - (_, _, ErrorNotifications(var e)) => - throw new Exception($"Failed to load notifications: {e}"), - }; -}{% endhighlight %} -
- -
-
+
+

RESTCLIENT.NET / FROM SOURCE TO REQUEST

+

Code you can run.

+

One example, compiled against RestClient.Net 7.3.1. The code below is the same source our checks exercise with local HTTP responses: success, HTTP errors, connection failures, JSON requests, and cancellation.

+

Start with a GET

+

The entry point fetches a post from JSONPlaceholder and prints an explicit result. Clone the repository, install the .NET 9 SDK, then run:

+{% highlight "bash" %}dotnet run --project Website/examples/Examples.csproj{% endhighlight %} +

This command makes a real request to JSONPlaceholder. The automated checks use a local message handler and make no HTTP requests.

+

Send JSON. Keep the result.

+

CreatePostAsync passes JsonContent.Create(request) as requestBody. Deserializers receive an HttpResponseMessage, so they read response.Content. The successful response becomes a Post; an HTTP failure preserves the response body as text.

+

Use your existing client factory

+

GetUsingFactoryAsync uses the named client posts. Register it with services.AddHttpClient("posts") in your application. Each call passes an absolute URL, and your registered handlers, authentication, and resilience configuration still apply.

+

Handle every outcome

+

Result.Match separates success from failure. HttpError.Match then handles HTTP responses and exceptions explicitly. The cancellation token is passed through every request and deserialization operation; a cancelled request is returned as an exception error.

+

Complete source

+

Both language versions render this single source file. The included Main runs the GET example; the POST and factory methods are ready to call from your application.

+{% highlight "csharp" %}{{ examples.source | safe }}{% endhighlight %} +

Keep exploring

+

HTTP methods and signatures · Client factory reference · Error-handling guide · Generate clients from OpenAPI

+
diff --git a/Website/src/feed.njk b/Website/src/feed.njk new file mode 100644 index 00000000..dd27295a --- /dev/null +++ b/Website/src/feed.njk @@ -0,0 +1,22 @@ +--- +permalink: /feed.xml +layout: false +eleventyExcludeFromCollections: true +--- + + +RestClient.Net Blog +C# HTTP clients, explicit results, and exhaustive error handling. +{{ '/blog/' | absoluteUrl | xmlEscape }} + + +{% for post in collections.posts %}{% if loop.first %}{{ post.date | isoDate }}{% endif %}{% endfor %} +{{ site.author | xmlEscape }} +{% for post in collections.posts %} +{{ post.data.title | xmlEscape }} +{{ post.url | absoluteUrl | xmlEscape }} + +{{ post.date | isoDate }} +{{ (post.data.excerpt or post.data.description or post.data.title) | xmlEscape }} +{% endfor %} + diff --git a/Website/src/index.njk b/Website/src/index.njk index 4b3c0e3a..c5fdb94a 100644 --- a/Website/src/index.njk +++ b/Website/src/index.njk @@ -1,117 +1,7 @@ --- layout: layouts/base.njk -title: RestClient.Net - Type-Safe REST Client for C# +title: Every outcome, in view lang: en permalink: / --- - -
-
- -

RestClient.Net

-

The safest way to make REST calls in C#. Built with functional programming, compile-time exhaustiveness, and modern .NET patterns.

- -
-
- -
-
-
-

Result Types

-

Returns Result<TSuccess, HttpError<TError>> with closed hierarchy types for compile-time safety. No more exception handling guesswork.

-
- -
-

Zero Exceptions

-

No exception throwing for predictable error handling. Every possible outcome is represented in the type system.

-
- -
-

Exhaustiveness Checking

-

Uses the Exhaustion analyzer for compile-time completeness guarantees. If you don't handle all cases, it won't compile.

-
- -
-

HttpClient Extensions

-

Works with IHttpClientFactory.CreateClient() for proper pooled connections and DNS behavior handling.

-
- -
-

OpenAPI Generator

-

Generate type-safe C# clients from OpenAPI 3.x specs. Automatic model generation and result type aliases.

-
- -
-

MCP Server Generator

-

Generate Model Context Protocol servers for Claude Code from OpenAPI specs. AI-ready API integration.

-
-
-
- -
-

Quick Install

- {% highlight "bash" %}dotnet add package RestClient.Net{% endhighlight %} -
- -
-

Basic Usage

- {% highlight "csharp" %}using RestClient.Net; - -// Make a GET request -var result = await httpClient - .GetAsync( - url: "https://api.example.com/posts/1".ToAbsoluteUrl(), - deserializeSuccess: DeserializePost, - deserializeError: DeserializeError - ); - -// Pattern match on the result - MUST handle all cases -var output = result switch -{ - OkPost(var post) => $"Success: {post.Title}", - ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Error {status}", - ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", -};{% endhighlight %} -
- -
-
-

Why Discriminated Unions?

-

- C# doesn't officially support discriminated unions yet, but RestClient.Net brings this powerful pattern to your code today. With the Exhaustion analyzer, missing a case means your code won't compile. -

- -
-
-

Without Exhaustion

- {% highlight "csharp" %}// DANGEROUS - compiles but may throw -var output = result switch -{ - OkPost(var post) => "Success", - ErrorPost(ResponseErrorPost(...)) => "Error", - // Missing ExceptionErrorPost! - // Runtime crash waiting to happen -};{% endhighlight %} -
-
-

With Exhaustion

- {% highlight "csharp" %}// SAFE - compiler error! -// error EXHAUSTION001: Switch not exhaustive -// Missing: ExceptionErrorPost -// Build fails until you handle all cases{% endhighlight %} -
-
-
-
- -
-

Ready to Get Started?

- -
+{% include "home.njk" %} diff --git a/Website/src/journal-archives.njk b/Website/src/journal-archives.njk new file mode 100644 index 00000000..8302ed66 --- /dev/null +++ b/Website/src/journal-archives.njk @@ -0,0 +1,13 @@ +--- +layout: layouts/base.njk +pagination: + data: collections.journalArchives + size: 1 + alias: archive + addAllPagesToCollections: true +permalink: "{{ archive.url }}" +eleventyComputed: + title: "{{ archive.title }}" + lang: "{{ archive.lang }}" +--- +

RESTCLIENT.NET / JOURNAL

{{ archive.title }}

{% for post in archive.posts %}{% endfor %}

{{ '← 返回博客' if lang == 'zh' else '← Back to Blog' }}

diff --git a/Website/src/llms.txt.njk b/Website/src/llms.txt.njk new file mode 100644 index 00000000..62c0783e --- /dev/null +++ b/Website/src/llms.txt.njk @@ -0,0 +1,22 @@ +--- +permalink: /llms.txt +layout: false +eleventyExcludeFromCollections: true +--- +# RestClient.Net + +> RestClient.Net is a C# HTTP client library with explicit success, HTTP response error, and exception results. The Exhaustion analyzer checks closed type hierarchies during compilation. + +## Documentation and API reference + +{% for entry in collections.publicPages %}{% if '/docs/' in entry.url or '/api/' in entry.url %}- [{{ entry.data.title }}]({{ entry.url | absoluteUrl }}) +{% endif %}{% endfor %} +## Articles + +{% for post in collections.posts %}- [{{ post.data.title }}]({{ post.url | absoluteUrl }}): {{ post.data.excerpt or post.data.description or post.data.title }} +{% endfor %} +## Source and packages + +- [Source repository](https://github.com/MelbourneDeveloper/RestClient.Net) +- [RestClient.Net on NuGet](https://www.nuget.org/packages/RestClient.Net) +- [Exhaustion on NuGet](https://www.nuget.org/packages/Exhaustion) diff --git a/Website/src/robots.txt.njk b/Website/src/robots.txt.njk new file mode 100644 index 00000000..616e8ff4 --- /dev/null +++ b/Website/src/robots.txt.njk @@ -0,0 +1,9 @@ +--- +permalink: /robots.txt +layout: false +eleventyExcludeFromCollections: true +--- +User-agent: * +Allow: / + +Sitemap: {{ '/sitemap.xml' | absoluteUrl }} diff --git a/Website/src/sitemap.njk b/Website/src/sitemap.njk new file mode 100644 index 00000000..6925a063 --- /dev/null +++ b/Website/src/sitemap.njk @@ -0,0 +1,9 @@ +--- +permalink: /sitemap.xml +layout: false +eleventyExcludeFromCollections: true +--- + + +{% for entry in collections.publicPages %}{{ entry.url | absoluteUrl | xmlEscape }} +{% endfor %} diff --git a/Website/src/zh/api/index.njk b/Website/src/zh/api/index.njk index 8090b7de..289fbf11 100644 --- a/Website/src/zh/api/index.njk +++ b/Website/src/zh/api/index.njk @@ -4,79 +4,4 @@ title: API 参考 lang: zh permalink: /zh/api/ --- -
-

API 参考

-

RestClient.Net 完整 API 文档

- -

核心 API

- - -

代码生成器

- - -

指南

- - -

NuGet 包

- - - - - - - - - - - - - - - - - - - - - - - - - -
包名描述
RestClient.Net包含 HttpClient 扩展的核心库
RestClient.Net.OpenApiGeneratorOpenAPI 3.x 客户端生成器
RestClient.Net.McpGeneratorMCP 服务器生成器
Exhaustion用于 switch 穷尽性检查的 Roslyn 分析器
-
+

RESTCLIENT.NET / REFERENCE

从源码到理解。

查阅真实 C# 声明、XML 文档和实用指南。

RestClient.Net on NuGet ↗ · Exhaustion on NuGet ↗

diff --git a/Website/src/zh/blog/index.njk b/Website/src/zh/blog/index.njk new file mode 100644 index 00000000..ba25c734 --- /dev/null +++ b/Website/src/zh/blog/index.njk @@ -0,0 +1,7 @@ +--- +layout: layouts/base.njk +title: 博客 +lang: zh +permalink: /zh/blog/ +--- +

RESTCLIENT.NET / JOURNAL

博客

类型、工具与更可靠的软件。

{% for post in collections.zhposts %}{% endfor %}
diff --git a/Website/src/zh/docs/api/index.md b/Website/src/zh/docs/api/index.md index f66eee4d..4287697b 100644 --- a/Website/src/zh/docs/api/index.md +++ b/Website/src/zh/docs/api/index.md @@ -21,24 +21,24 @@ RestClient.Net 和 Outcome 库的完整参考文档。 - [HttpClient 扩展](/zh/docs/api/httpclient-extensions/) - `HttpClient` 的扩展方法 - [IHttpClientFactory 扩展](/zh/docs/api/httpclientfactory-extensions/) - `IHttpClientFactory` 的扩展方法 -- [委托](/zh/docs/api/delegates/) - 用于序列化/反序列化的函数委托 -- [工具类](/zh/docs/api/utilities/) - 辅助类如 `ProgressReportingHttpContent` +- [委托](/docs/api/delegates/) - 用于序列化/反序列化的函数委托 +- [工具类](/docs/api/utilities/) - 辅助类如 `ProgressReportingHttpContent` ### Outcome 提供用于错误处理的可辨识联合的函数式编程库。 -- [Result 类型](/zh/docs/api/result/) - 核心 `Result` 类型 -- [HttpError 类型](/zh/docs/api/httperror/) - HTTP 特定的错误表示 -- [Result 扩展](/zh/docs/api/result-extensions/) - `Result` 的扩展方法 -- [Unit 类型](/zh/docs/api/unit/) - 函数式编程中的 void 等价物 +- [Result 类型](/docs/api/result/) - 核心 `Result` 类型 +- [HttpError 类型](/docs/api/httperror/) - HTTP 特定的错误表示 +- [Result 扩展](/docs/api/result-extensions/) - `Result` 的扩展方法 +- [Unit 类型](/docs/api/unit/) - 函数式编程中的 void 等价物 ### 代码生成器 从 API 规范生成类型安全客户端的工具。 -- [OpenAPI 生成器](/zh/docs/api/openapi-generator/) - 从 OpenAPI/Swagger 规范生成 C# 客户端 -- [MCP 生成器](/zh/docs/api/mcp-generator/) - 从 OpenAPI 规范生成模型上下文协议服务器 +- [OpenAPI 生成器](/docs/api/openapi-generator/) - 从 OpenAPI/Swagger 规范生成 C# 客户端 +- [MCP 生成器](/docs/api/mcp-generator/) - 从 OpenAPI 规范生成模型上下文协议服务器 ## 快速入门 diff --git a/Website/src/zh/docs/exhaustion.md b/Website/src/zh/docs/exhaustion.md index 7b754d8b..985929cd 100644 --- a/Website/src/zh/docs/exhaustion.md +++ b/Website/src/zh/docs/exhaustion.md @@ -41,3 +41,8 @@ Exhaustion 会随 RestClient.Net 自动安装,或单独安装: ```bash dotnet add package Exhaustion ``` + + +## 有界分析 + +Exhaustion 在分析大型或递归类型层次时限制工作量。达到分析上限后,`EXHAUSTION002` 会指出分析未能完成。请简化 switch 表达式或类型层次,然后重新检查;达到上限并不代表已证明匹配完整。 diff --git a/Website/src/zh/examples/index.njk b/Website/src/zh/examples/index.njk index 2d82bded..5b13c05c 100644 --- a/Website/src/zh/examples/index.njk +++ b/Website/src/zh/examples/index.njk @@ -4,278 +4,23 @@ title: 代码示例 lang: zh permalink: /zh/examples/ --- -
-

代码示例

-

常见场景的完整可用示例

- -
- -
-

基本 GET 请求

-

使用类型安全的错误处理从 REST API 获取数据。

- {% highlight "csharp" %}using System.Net.Http.Json; -using RestClient.Net; -using Urls; - -// 定义模型 -record Post(int UserId, int Id, string Title, string Body); -record ApiError(string Message); - -// 类型别名,用于简洁的模式匹配 -using OkPost = Outcome.Result> - .Ok>; -using ErrorPost = Outcome.Result> - .Error>; -using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; -using ExceptionErrorPost = Outcome.HttpError.ExceptionError; - -// 发起请求 -using var httpClient = new HttpClient(); -var result = await httpClient.GetAsync( - url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), - deserializeSuccess: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), - deserializeError: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") -); - -// 处理所有情况 -var message = result switch -{ - OkPost(var post) => $"标题: {post.Title}", - ErrorPost(ResponseErrorPost(var err, var status, _)) => $"错误 {status}: {err.Message}", - ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", -}; - -Console.WriteLine(message);{% endhighlight %} -
- -
-

带请求体的 POST 请求

-

使用 JSON 请求体创建新资源。

- {% highlight "csharp" %}record CreatePostRequest(string Title, string Body, int UserId); - -var newPost = new CreatePostRequest("我的标题", "我的内容", 1); - -var result = await httpClient.PostAsync( - url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(), - body: newPost, - serializeRequest: body => JsonContent.Create(body), - deserializeSuccess: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), - deserializeError: async (content, ct) => - await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") -); - -var message = result switch -{ - OkPost(var post) => $"创建的文章 ID: {post.Id}", - ErrorPost(ResponseErrorPost(var err, var status, _)) => $"失败: {status}", - ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", -};{% endhighlight %} -
- -
-

使用 IHttpClientFactory

-

在 ASP.NET Core 应用程序中正确使用 HttpClient。

- {% highlight "csharp" %}// Program.cs - 注册客户端 -builder.Services.AddHttpClient("api", client => -{ - client.BaseAddress = new Uri("https://api.example.com"); - client.DefaultRequestHeaders.Add("Accept", "application/json"); - client.Timeout = TimeSpan.FromSeconds(30); -}); - -// UserService.cs - 使用客户端 -public class UserService(IHttpClientFactory httpClientFactory) -{ - public async Task>> GetUserAsync( - string userId, - CancellationToken ct = default) - { - var client = httpClientFactory.CreateClient("api"); - - return await client.GetAsync( - url: $"/users/{userId}".ToAbsoluteUrl(), - deserializeSuccess: Deserializers.Json, - deserializeError: Deserializers.Error, - cancellationToken: ct - ); - } -}{% endhighlight %} -
- -
-

使用 Polly 的重试策略

-

为瞬态故障添加自动重试。

- {% highlight "csharp" %}// Program.cs -builder.Services.AddHttpClient("api") - .AddStandardResilienceHandler(options => - { - options.Retry.MaxRetryAttempts = 3; - options.Retry.Delay = TimeSpan.FromMilliseconds(500); - options.Retry.UseJitter = true; - options.Retry.ShouldHandle = args => ValueTask.FromResult( - args.Outcome.Exception is not null || - args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError - ); - });{% endhighlight %} -
- -
-

认证处理器

-

自动为请求添加认证令牌。

- {% highlight "csharp" %}public class AuthenticationHandler(ITokenService tokenService) : DelegatingHandler -{ - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - var token = await tokenService.GetAccessTokenAsync(cancellationToken); - - request.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", token); - - var response = await base.SendAsync(request, cancellationToken); - - // 如果令牌过期则刷新 - if (response.StatusCode == HttpStatusCode.Unauthorized) - { - token = await tokenService.RefreshTokenAsync(cancellationToken); - request.Headers.Authorization = - new AuthenticationHeaderValue("Bearer", token); - response = await base.SendAsync(request, cancellationToken); - } - - return response; - } -} - -// Program.cs -builder.Services.AddTransient(); -builder.Services.AddHttpClient("api") - .AddHttpMessageHandler();{% endhighlight %} -
- -
-

状态码特定处理

-

针对不同的 HTTP 状态码进行不同处理。

- {% highlight "csharp" %}var result = await httpClient.GetUserAsync(userId); - -var message = result switch -{ - OkUser(var user) => $"找到: {user.Name}", - - // 未找到 - 用户不存在 - ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => - "用户未找到。请检查 ID。", - - // 未授权 - 需要登录 - ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => - "请登录以查看此用户。", - - // 禁止访问 - 无权限 - ErrorUser(ResponseErrorUser(_, HttpStatusCode.Forbidden, _)) => - "您没有权限查看此用户。", - - // 请求过多 - ErrorUser(ResponseErrorUser(_, HttpStatusCode.TooManyRequests, var response)) => - { - var retryAfter = response.Headers.RetryAfter?.Delta; - return $"请求过多。请在 {retryAfter?.TotalSeconds ?? 60} 秒后重试。"; - }, - - // 服务器错误 - ErrorUser(ResponseErrorUser(var err, var status, _)) when (int)status >= 500 => - "服务器出现问题。请稍后重试。", - - // 其他 API 错误 - ErrorUser(ResponseErrorUser(var err, var status, _)) => - $"API 错误 {(int)status}: {err.Message}", - - // 网络/超时错误 - ErrorUser(ExceptionErrorUser(TaskCanceledException ex)) - when ex.CancellationToken.IsCancellationRequested => - "请求已取消。", - - ErrorUser(ExceptionErrorUser(TaskCanceledException)) => - "请求超时。请重试。", - - ErrorUser(ExceptionErrorUser(HttpRequestException)) => - "网络错误。请检查您的连接。", - - ErrorUser(ExceptionErrorUser(var ex)) => - $"意外错误: {ex.Message}", -};{% endhighlight %} -
- -
-

链式多个请求

-

链接依赖的 API 调用并正确传播错误。

- {% highlight "csharp" %}// 获取用户,然后获取他们的订单,再获取订单详情 -public async Task>> GetUserOrderDetailsAsync( - string userId, - CancellationToken ct) -{ - // 首先,获取用户 - var userResult = await httpClient.GetUserAsync(userId, ct); - - return await userResult switch - { - OkUser(var user) => await GetOrdersForUserAsync(user, ct), - ErrorUser(var error) => new Result> - .Error(error), - }; -} - -private async Task>> GetOrdersForUserAsync( - User user, - CancellationToken ct) -{ - var ordersResult = await httpClient.GetOrdersAsync(user.Id, ct); - - return ordersResult switch - { - OkOrders(var orders) => new Result> - .Ok(new OrderDetails(user, orders)), - ErrorOrders(var error) => new Result> - .Error(error), - }; -}{% endhighlight %} -
- -
-

并行请求

-

并行发起多个独立请求。

- {% highlight "csharp" %}public async Task GetDashboardAsync(string userId, CancellationToken ct) -{ - // 并行启动所有请求 - var userTask = httpClient.GetUserAsync(userId, ct); - var ordersTask = httpClient.GetOrdersAsync(userId, ct); - var notificationsTask = httpClient.GetNotificationsAsync(userId, ct); - - // 等待所有请求完成 - await Task.WhenAll(userTask, ordersTask, notificationsTask); - - var userResult = await userTask; - var ordersResult = await ordersTask; - var notificationsResult = await notificationsTask; - - // 合并结果 - return (userResult, ordersResult, notificationsResult) switch - { - (OkUser(var user), OkOrders(var orders), OkNotifications(var notifications)) => - new Dashboard(user, orders, notifications), - - (ErrorUser(var e), _, _) => - throw new Exception($"加载用户失败: {e}"), - (_, ErrorOrders(var e), _) => - throw new Exception($"加载订单失败: {e}"), - (_, _, ErrorNotifications(var e)) => - throw new Exception($"加载通知失败: {e}"), - }; -}{% endhighlight %} -
- -
-
+
+

RESTCLIENT.NET / 从源码到请求

+

可以运行的代码。

+

下面的示例使用 RestClient.Net 7.3.1 编译。页面与自动验证共用同一份源码,验证涵盖成功响应、HTTP 错误、连接失败、JSON 请求和取消操作。

+

从 GET 请求开始

+

入口方法从 JSONPlaceholder 获取文章,并输出明确的结果。克隆仓库、安装 .NET 9 SDK 后运行:

+{% highlight "bash" %}dotnet run --project Website/examples/Examples.csproj{% endhighlight %} +

此命令会向 JSONPlaceholder 发出真实请求。自动验证使用本地消息处理器,不会发出 HTTP 请求。

+

发送 JSON,保留完整结果

+

CreatePostAsync 将 JsonContent.Create(request) 作为 requestBody 传入。反序列化委托接收 HttpResponseMessage,因此需要读取 response.Content。成功响应转换为 Post,HTTP 错误保留文本格式的响应内容。

+

使用现有客户端工厂

+

GetUsingFactoryAsync 使用名为 posts 的客户端。在应用中通过 services.AddHttpClient("posts") 注册即可。请求使用绝对 URL,已有的处理器、认证和弹性配置仍然适用。

+

明确处理每一种结果

+

Result.Match 分别处理成功和失败,随后 HttpError.Match 明确区分 HTTP 响应错误与异常。取消令牌会传递到请求与反序列化操作;取消请求的结果是一个异常错误。

+

完整源码

+

中英文页面均展示同一个源码文件。其中的 Main 运行 GET 示例;POST 与客户端工厂方法可直接从应用调用。

+{% highlight "csharp" %}{{ examples.source | safe }}{% endhighlight %} +

继续探索

+

HTTP 方法与签名 · 客户端工厂参考 · 错误处理指南 · 从 OpenAPI 生成客户端

+
diff --git a/Website/src/zh/index.njk b/Website/src/zh/index.njk index 1690be09..a8193613 100644 --- a/Website/src/zh/index.njk +++ b/Website/src/zh/index.njk @@ -1,112 +1,7 @@ --- layout: layouts/base.njk -title: RestClient.Net - C# 类型安全 REST 客户端 +title: 每种结果,尽在掌握 lang: zh permalink: /zh/ --- - -
-
-

RestClient.Net

-

C# 中最安全的 REST 调用方式。基于函数式编程、类型安全和现代 .NET 模式从头构建。

- -
-
- -
-
-
-

Result 类型

-

返回 Result<TSuccess, HttpError<TError>>,使用封闭层次类型实现编译时安全。不再猜测异常处理。

-
- -
-

零异常

-

不抛出异常,实现可预测的错误处理。每种可能的结果都在类型系统中表示。

-
- -
-

穷尽性检查

-

使用 Exhaustion 分析器保证编译时完整性。如果你没有处理所有情况,代码将无法编译。

-
- -
-

HttpClient 扩展

-

与 IHttpClientFactory.CreateClient() 配合使用,实现正确的连接池和 DNS 行为处理。

-
- -
-

OpenAPI 生成器

-

从 OpenAPI 3.x 规范生成类型安全的 C# 客户端。自动生成模型和 Result 类型别名。

-
- -
-

MCP 服务器生成器

-

从 OpenAPI 规范为 Claude Code 生成模型上下文协议服务器。AI 就绪的 API 集成。

-
-
-
- -
-

快速安装

- {% highlight "bash" %}dotnet add package RestClient.Net{% endhighlight %} - -

基本用法

- {% highlight "csharp" %}using RestClient.Net; - -// 发起 GET 请求 -var result = await httpClient - .GetAsync( - url: "https://api.example.com/posts/1".ToAbsoluteUrl(), - deserializeSuccess: DeserializePost, - deserializeError: DeserializeError - ); - -// 模式匹配结果 - 必须处理所有情况 -var output = result switch -{ - OkPost(var post) => $"成功: {post.Title}", - ErrorPost(ResponseErrorPost(var err, var status, _)) => $"错误 {status}", - ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", -};{% endhighlight %} -
- -
-

为什么使用可辨识联合?

-

- C# 尚未正式支持可辨识联合,但 RestClient.Net 今天就将这种强大的模式带入你的代码。配合 Exhaustion 分析器,遗漏任何情况都会导致编译失败。 -

- -
-
-

不使用 Exhaustion

- {% highlight "csharp" %}// 危险 - 编译通过但可能抛出异常 -var output = result switch -{ - OkPost(var post) => "成功", - ErrorPost(ResponseErrorPost(...)) => "错误", - // 遗漏了 ExceptionErrorPost! - // 运行时崩溃等着发生 -};{% endhighlight %} -
-
-

使用 Exhaustion

- {% highlight "csharp" %}// 安全 - 编译器报错! -// error EXHAUSTION001: Switch 不完整 -// 遗漏: ExceptionErrorPost -// 构建失败直到处理所有情况{% endhighlight %} -
-
-
- -
-

准备好开始了吗?

- -
+{% include "home.njk" %} diff --git a/Website/tests-node/api-export.test.js b/Website/tests-node/api-export.test.js new file mode 100644 index 00000000..84507d72 --- /dev/null +++ b/Website/tests-node/api-export.test.js @@ -0,0 +1,179 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { generateApi } from '../scripts/generate-api-docs.js'; + +const fixture = `namespace Fixture; +/// A callable converter. +/// The value to convert. +/// The converted value. +public delegate TResult Converter(T input) where T : class; + +/// A documented client. +/// The item type. +public partial class Client where T : class, new() +{ + /// Fetch the original item. See . + /// The numeric identifier. + /// An optional label. + /// The item text. + /// Templates such as {{ value }} remain literal. + public string Fetch(int id = 42, string? label = "two words") => id.ToString(); + /// Fetch an item by its string identifier. + public string Fetch(string id) => id; + /// A generic factory. + public TResult Create() where TResult : T, new() => new TResult(); + /// Readable count. + public int Count { get; private set; } + /// Constants retain their literal whitespace. + public const string First = "one two", Second = "second"; + private void Secret() { } + internal void InternalOperation() { } + public sealed class Nested { public int Value { get; init; } } +} +/// A payload with positional properties. +/// The payload name. +/// The count. +public record Payload(string Name, int Count = 2); +internal class Hidden { public class Leaked { public void NeverPublish() { } } } +`; + +function snapshot(directory) { + return Object.fromEntries(readdirSync(directory).sort().map(name => [name, readFileSync(path.join(directory, name), 'utf8')])); +} + +test('source API export follows declarations and documentation through edits', { timeout: 180_000 }, async t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'restclient-api-export-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const sources = path.join(root, 'Fixture'); + const output = path.join(root, 'reference'); + mkdirSync(sources); + writeFileSync(path.join(sources, 'Api.cs'), fixture); + writeFileSync(path.join(sources, 'More.cs'), 'namespace Fixture; public partial class Client { public bool Delete(int id) => true; }'); + const options = { root, output, projects: ['Fixture'], sourceRef: 'test-revision' }; + generateApi(options); + const initial = snapshot(output); + const index = JSON.parse(initial['api.json']); + const client = index.types.find(type => type.name === 'Client'); + const fetches = client.members.filter(member => member.name === 'Fetch'); + + await t.test('public surface, overload identities, signatures and source links are accurate', () => { + assert.equal(index.schemaVersion, 1); + assert.equal(index.sourceRef, 'test-revision'); + assert.equal(index.types.filter(type => type.name === 'Client').length, 1, 'partial declarations merge'); + assert.deepEqual(index.types.map(type => type.name).sort(), ['Client', 'Converter', 'Nested', 'Payload']); + assert.equal(fetches.length, 2); + assert.equal(new Set(fetches.map(member => member.id)).size, 2); + assert.equal(new Set(fetches.map(member => member.anchor)).size, 2); + assert.ok(client.members.some(member => member.name === 'Delete'), 'the second partial contributes members'); + assert.ok(!client.members.some(member => ['Secret', 'InternalOperation'].includes(member.name))); + assert.match(client.signature, /where T : class, new\(\)/); + assert.match(client.members.find(member => member.name === 'Create').signature, /where TResult : T, new\(\)/); + assert.match(client.members.find(member => member.name === 'Count').signature, /private set;/); + assert.match(client.members.find(member => member.name === 'First').signature, /First = "one two"/); + assert.match(client.members.find(member => member.name === 'Second').signature, /Second = "second"/); + assert.doesNotMatch(client.members.find(member => member.name === 'Second').signature, /First =/); + const numeric = fetches.find(member => member.parameters[0].type === 'int'); + assert.equal(numeric.parameters[0].default, '42'); + assert.equal(numeric.parameters[1].type, 'string?'); + assert.equal(numeric.parameters[1].default, '"two words"'); + assert.equal(numeric.source.file, 'Fixture/Api.cs'); + assert.equal(numeric.source.line, fixture.split('\n').findIndex(line => line.includes('public string Fetch(int')) + 1); + assert.match(numeric.source.url, /\/blob\/test-revision\/Fixture\/Api.cs#L\d+$/); + const delegate = index.types.find(type => type.kind === 'delegate'); + assert.match(delegate.signature, /delegate TResult Converter\(T input\)/); + assert.match(delegate.signature, /where T : class/); + assert.equal(delegate.parameters[0].type, 'T'); + const record = index.types.find(type => type.name === 'Payload'); + assert.deepEqual(record.parameters.map(parameter => parameter.name), ['Name', 'Count']); + assert.equal(record.parameters[1].default, '2'); + assert.deepEqual(record.members.filter(member => ['Name', 'Count'].includes(member.name)).map(member => member.name).sort(), ['Count', 'Name']); + }); + + await t.test('XML prose and Markdown use the shared layout without template evaluation', () => { + const numeric = fetches.find(member => member.parameters[0].type === 'int'); + assert.match(numeric.docs.summary, /Fetch the original item/); + assert.match(numeric.docs.summary, /`System.String`/); + assert.equal(numeric.docs.parameters[0].description, 'The numeric identifier.'); + assert.equal(numeric.docs.returns, 'The item text.'); + assert.match(numeric.docs.remarks, /`{{ value }}`/); + const markdown = initial['fixture-client-1.md']; + assert.match(markdown, /layout: layouts\/api.njk/); + assert.match(markdown, /templateEngineOverride: md/); + assert.doesNotMatch(markdown, /^# /m); + for (const overload of fetches) assert.ok(markdown.includes(`id="${overload.anchor}"`)); + assert.match(markdown, /\*\*Returns:\*\* The item text/); + assert.match(markdown, /\*\*Remarks:\*\* Templates/); + assert.match(markdown, /\| `id` \| `int` \| `42` \| The numeric identifier/); + assert.ok(initial['schema.json']); + assert.doesNotMatch(JSON.stringify(index), new RegExp(root.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + }); + + await t.test('identical input exports byte-identical JSON and Markdown', () => { + generateApi(options); + assert.deepEqual(snapshot(output), initial); + }); + + await t.test('editing real source changes API signatures and prose while stable overloads retain anchors', () => { + writeFileSync(path.join(sources, 'Api.cs'), fixture.replace('Fetch the original item.', 'Fetch the revised item.') + .replace('public string Fetch(int id = 42', 'public string Fetch(long id = 42')); + generateApi(options); + const edited = snapshot(output); + const changed = JSON.parse(edited['api.json']).types.find(type => type.name === 'Client'); + const numeric = changed.members.find(member => member.name === 'Fetch' && member.parameters[0].type === 'long'); + assert.ok(numeric); + assert.match(numeric.signature, /Fetch\(long id = 42/); + assert.match(numeric.docs.summary, /Fetch the revised item/); + assert.notEqual(numeric.anchor, fetches.find(member => member.parameters[0].type === 'int').anchor); + assert.equal(changed.members.find(member => member.name === 'Fetch' && member.parameters[0].type === 'string').anchor, + fetches.find(member => member.parameters[0].type === 'string').anchor); + assert.match(edited['fixture-client-1.md'], /Fetch the revised item/); + assert.doesNotMatch(edited['fixture-client-1.md'], /Fetch the original item/); + assert.notEqual(edited['api.json'], initial['api.json']); + }); +}); + +test('symbol IDs resolve SDK implicit usings and distinguish nullable references from values', { timeout: 180_000 }, t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'restclient-api-symbols-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, 'Fixture')); + writeFileSync(path.join(root, 'Fixture', 'Transport.cs'), `namespace Fixture; +public static class Transport +{ + public static void Send(HttpClient client, HttpContent? content, + Func callback, Action? progress, + IReadOnlyDictionary? headers, CancellationToken token, int? retries) { } +}`); + const output = path.join(root, 'reference'); + generateApi({ root, output, projects: ['Fixture'] }); + const index = JSON.parse(readFileSync(path.join(output, 'api.json'), 'utf8')); + const send = index.types.find(type => type.name === 'Transport').members.find(member => member.name === 'Send'); + assert.equal(send.id, 'M:Fixture.Transport.Send(System.Net.Http.HttpClient,System.Net.Http.HttpContent,System.Func{System.Net.Http.HttpResponseMessage,System.String},System.Action{System.Int64,System.Int64},System.Collections.Generic.IReadOnlyDictionary{System.String,System.String},System.Threading.CancellationToken,System.Nullable{System.Int32})'); + assert.deepEqual(send.parameters.map(parameter => parameter.type), [ + 'HttpClient', 'HttpContent?', 'Func', 'Action?', + 'IReadOnlyDictionary?', 'CancellationToken', 'int?', + ], 'source spelling and nullable annotations remain intact'); + assert.doesNotMatch(send.id, /Nullable\{(?:HttpContent|Action|IReadOnlyDictionary)/); +}); + +test('symbol IDs resolve third-party types used in repository public signatures', { timeout: 180_000 }, t => { + const root = mkdtempSync(path.join(os.tmpdir(), 'restclient-api-references-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(path.join(root, 'Fixture')); + writeFileSync(path.join(root, 'Fixture', 'External.cs'), `using Microsoft.OpenApi; +using Urls; +namespace Fixture; +public static class External +{ + public static void Inspect(AbsoluteUrl url, IHttpClientFactory clients, IOpenApiSchema? schema) { } +}`); + const output = path.join(root, 'reference'); + generateApi({ root, output, projects: ['Fixture'] }); + const index = JSON.parse(readFileSync(path.join(output, 'api.json'), 'utf8')); + const inspect = index.types.find(type => type.name === 'External').members.find(member => member.name === 'Inspect'); + assert.equal(inspect.id, 'M:Fixture.External.Inspect(Urls.AbsoluteUrl,System.Net.Http.IHttpClientFactory,Microsoft.OpenApi.IOpenApiSchema)'); + assert.equal(inspect.parameters[2].type, 'IOpenApiSchema?'); + assert.doesNotMatch(inspect.id, /Nullable\{IOpenApiSchema/); +}); diff --git a/Website/tests-node/examples.test.js b/Website/tests-node/examples.test.js new file mode 100644 index 00000000..889e5504 --- /dev/null +++ b/Website/tests-node/examples.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import examples from '../src/_data/examples.js'; + +const website = fileURLToPath(new URL('../', import.meta.url)); +test('both translated pages render the tested C# source without independent copies', () => { + assert.equal(examples.source, readFileSync(path.join(website, 'examples/Program.cs'), 'utf8')); + for (const page of ['src/examples/index.njk', 'src/zh/examples/index.njk']) { + const source = readFileSync(path.join(website, page), 'utf8'); + assert.match(source, /examples\.source/); + assert.match(source, /class="prose/); + assert.doesNotMatch(source, /serializeRequest:|content\.ReadFromJsonAsync|=>\s*\{/); + } +}); + +test('documented example compiles against the published package and handles real request interactions', { timeout: 180_000 }, () => { + const result = execFileSync('dotnet', ['run', '--project', path.join(website, 'examples/Tests/Examples.Tests.csproj'), '--configuration', 'Release', '--no-launch-profile'], { + cwd: website, encoding: 'utf8', timeout: 150_000, maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, DOTNET_PROCESSOR_COUNT: '2', DOTNET_GCHeapHardLimit: '0x40000000', + MSBUILDDISABLENODEREUSE: '1', UseSharedCompilation: 'false', DOTNET_CLI_TELEMETRY_OPTOUT: '1' }, + }); + assert.match(result, /PASS: 28 example assertions; no network requests\./); + assert.doesNotMatch(result, /warning |error /i); +}); diff --git a/Website/tests-node/site-guards.test.js b/Website/tests-node/site-guards.test.js new file mode 100644 index 00000000..2db4c007 --- /dev/null +++ b/Website/tests-node/site-guards.test.js @@ -0,0 +1,160 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { auditSite, cssInjectionErrors, CSS_BUDGET } from '../scripts/check-site.js'; + +async function fixture(t, { prefix = '/', stylesheet = 'body{color:#123}' } = {}) { + const directory = await mkdtemp(path.join(tmpdir(), 'restclient-site-guard-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const siteUrl = 'https://example.test'; + const absolute = route => `${siteUrl}${prefix}${route.replace(/^\//, '')}`; + const write = async (filename, value) => { + await mkdir(path.dirname(path.join(directory, filename)), { recursive: true }); + await writeFile(path.join(directory, filename), value); + }; + const html = (body = '

RestClient.Net

', extra = '') => ` +RestClient.Net documentation + + +${['og:title', 'og:description', 'og:image', 'og:type', 'twitter:card', 'twitter:title', 'twitter:description', 'twitter:image'].map(name => ``).join('')} + +${extra}${body}`; + await write('index.html', html()); + await write('assets/site.css', stylesheet); + await write('sitemap.xml', `${absolute('/')}`); + await write('robots.txt', `User-agent: *\nAllow: /\nSitemap: ${absolute('/sitemap.xml')}\n`); + await write('feed.xml', ``); + await write('llms.txt', `# RestClient.Net\n\n[Documentation](${absolute('/')})`); + return { directory, write, html, run: () => auditSite(directory, { siteUrl, pathPrefix: prefix }) }; +} + +test('raw CSS budget accepts exactly 2500 bytes and rejects one extra byte', async t => { + const site = await fixture(t, { stylesheet: ' '.repeat(CSS_BUDGET) }); + const exact = await site.run(); + assert.equal(exact.cssBytes, 2500); + assert.deepEqual(exact.errors, []); + await site.write('assets/site.css', ' '.repeat(CSS_BUDGET + 1)); + const overflow = await site.run(); + assert.equal(overflow.cssBytes, 2501); + assert.match(overflow.errors.join('\n'), /CSS budget exceeded/); +}); + +test('CSS budget totals all files and measures UTF-8 bytes rather than characters', async t => { + const site = await fixture(t, { stylesheet: ' '.repeat(2499) }); + await site.write('assets/hidden/extra.css', 'é'); + const report = await site.run(); + assert.equal(report.cssBytes, 2501); + assert.match(report.errors.join('\n'), /CSS budget exceeded/); + await site.write('assets/hidden/extra.css', 'a'); + assert.deepEqual((await site.run()).errors, []); +}); + +test('real inline styles fail while escaped code examples remain valid', async t => { + const site = await fixture(t); + await site.write('index.html', site.html('

Docs

<p style="color:red">')); + assert.deepEqual((await site.run()).errors, []); + for (const body of ['

Docs

', '

Docs

', '

Docs

']) { + await site.write('index.html', site.html(body)); + assert.match((await site.run()).errors.join('\n'), /inline CSS bypasses/); + } +}); + +test('external styles, data CSS, imports, and stylesheet preloads cannot bypass budget', async t => { + const site = await fixture(t); + for (const link of ['', '', '']) { + await site.write('index.html', site.html('

Docs

', link)); + assert.match((await site.run()).errors.join('\n'), /external or embedded stylesheet/); + } + await site.write('index.html', site.html()); + await site.write('assets/site.css', '@import "https://cdn.test/theme.css";'); + assert.match((await site.run()).errors.join('\n'), /unbudgeted CSS/); +}); + +test('JavaScript CSS injection is rejected across DOM, CSSOM, and generated markup', () => { + for (const source of [ + 'document.body.style.color="red";', + 'document.body["style"].cssText="color:red";', + 'document.body["st"+"yle"].color="red";', + 'document.createElement("style");', + 'document["createElement"]("style");', + 'document.body.setAttribute("style","color:red");', + 'new CSSStyleSheet().replaceSync("body{}");', + 'document.adoptedStyleSheets=[];', + 'sheet.insertRule("body{}");', + 'document.body.animate([{opacity:0},{opacity:1}],100);', + 'document.body.innerHTML=`hi`;', + 'link.rel="stylesheet";', + 'link.setAttribute("rel","stylesheet");', + ]) assert.ok(cssInjectionErrors(source).length > 0, source); +}); + +test('native canvas rendering and comment text do not count as CSS injection', () => { + const source = '// document.body.style.color="red";\nconst canvas=document.querySelector("canvas");const ctx=canvas.getContext("2d");ctx.fillStyle="#123";ctx.fillRect(0,0,10,10);requestAnimationFrame(()=>{});'; + assert.deepEqual(cssInjectionErrors(source), []); + assert.match(cssInjectionErrors('const = ;').join('\n'), /invalid JavaScript/); +}); + +test('external scripts cannot outsource hidden CSS', async t => { + const site = await fixture(t); + await site.write('index.html', site.html('

Docs

', '')); + assert.match((await site.run()).errors.join('\n'), /external JavaScript/); +}); + +test('SVG assets cannot conceal extra CSS outside the shared budget', async t => { + const site = await fixture(t); + for (const svg of ['', 'Hello']) { + await site.write('assets/graphic.svg', svg); + assert.match((await site.run()).errors.join('\n'), /SVG styles bypass/); + } + await site.write('assets/graphic.svg', ''); + assert.deepEqual((await site.run()).errors, []); +}); + +test('local script injection is checked even when the initial HTML is clean', async t => { + const site = await fixture(t); + await site.write('assets/motion.js', 'document.body.style.background="red";'); + assert.match((await site.run()).errors.join('\n'), /CSS injection/); +}); + +test('root and project-prefix deployments use real canonical and internal routes', async t => { + for (const prefix of ['/', '/RestClient.Net/']) { + const site = await fixture(t, { prefix }); + await site.write('index.html', site.html(`

Docs

Top`)); + assert.deepEqual((await site.run()).errors, []); + if (prefix !== '/') { + await site.write('index.html', site.html('

Docs

Wrong deployment root')); + assert.match((await site.run()).errors.join('\n'), /escapes deployment prefix/); + } + } +}); + +test('missing local routes and anchor fragments fail the build', async t => { + const site = await fixture(t); + await site.write('index.html', site.html('

Docs

MissingMissing heading')); + const errors = (await site.run()).errors.join('\n'); + assert.match(errors, /broken internal link/); + assert.match(errors, /missing anchor/); +}); + +test('hreflang cannot advertise a missing translation', async t => { + const site = await fixture(t); + await site.write('index.html', site.html('

Docs

', '')); + const errors = (await site.run()).errors.join('\n'); + assert.match(errors, /hreflang does not point to a real local page/); + assert.match(errors, /broken internal link/); +}); + +test('canonical, structured data, and crawl output regressions fail visibly', async t => { + const site = await fixture(t); + await site.write('index.html', site.html().replace('rel="canonical"', 'rel="unrelated"').replace('"@context":', 'broken:')); + await site.write('sitemap.xml', 'https://example.test/missing/'); + await site.write('robots.txt', 'User-agent: *'); + const errors = (await site.run()).errors.join('\n'); + assert.match(errors, /canonical must be/); + assert.match(errors, /invalid JSON-LD/); + assert.match(errors, /Sitemap has a nonexistent page/); + assert.match(errors, /Sitemap omits/); + assert.match(errors, /wrong sitemap/); +}); diff --git a/Website/tests/api.test.js b/Website/tests/api.test.js index 6c05b379..75342e62 100644 --- a/Website/tests/api.test.js +++ b/Website/tests/api.test.js @@ -1,7 +1,7 @@ /** * API Reference Tests */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; test.describe('API Reference', () => { test('API index loads', async ({ page }) => { @@ -12,7 +12,9 @@ test.describe('API Reference', () => { test('API page has title', async ({ page }) => { await page.goto('/api/'); const h1 = page.locator('h1'); - await expect(h1).toContainText('API'); + await expect(h1).toHaveText('Know every signature.'); + await expect(page).toHaveTitle(/API reference/); + await expect(page.locator('main')).toContainText('Source API reference'); }); test('API page has package links', async ({ page }) => { diff --git a/Website/tests/blog.test.js b/Website/tests/blog.test.js index 8d843cc3..490ad704 100644 --- a/Website/tests/blog.test.js +++ b/Website/tests/blog.test.js @@ -1,7 +1,7 @@ /** * Blog Tests */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; test.describe('Blog', () => { test('blog index loads', async ({ page }) => { @@ -12,12 +12,13 @@ test.describe('Blog', () => { test('blog has title', async ({ page }) => { await page.goto('/blog/'); const h1 = page.locator('h1'); - await expect(h1).toContainText('Blog'); + await expect(h1).toHaveText('Ideas worth building on.'); + await expect(page).toHaveTitle(/Journal/); }); test('blog has post list', async ({ page }) => { await page.goto('/blog/'); - const posts = page.locator('.post-list li, ul li a'); + const posts = page.locator('article.feature-card'); const count = await posts.count(); expect(count).toBeGreaterThan(0); }); diff --git a/Website/tests/chinese-i18n.test.js b/Website/tests/chinese-i18n.test.js index 2583e7a5..47e66903 100644 --- a/Website/tests/chinese-i18n.test.js +++ b/Website/tests/chinese-i18n.test.js @@ -1,7 +1,7 @@ /** * Chinese i18n Tests */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; test.describe('Chinese Homepage', () => { test('/zh/ loads', async ({ page }) => { @@ -67,13 +67,13 @@ test.describe('Chinese API', () => { test.describe('Language Selector', () => { test('language selector exists', async ({ page }) => { await page.goto('/'); - const langBtn = page.locator('.language-btn, .language-switcher button, [aria-label*="language"]'); + const langBtn = page.locator('.language-switcher summary'); await expect(langBtn.first()).toBeVisible(); }); test('language dropdown has Chinese option', async ({ page }) => { await page.goto('/'); - const langBtn = page.locator('.language-btn, .language-switcher button').first(); + const langBtn = page.locator('.language-switcher summary').first(); await langBtn.click(); const zhOption = page.locator('.language-dropdown a[lang="zh"], .language-dropdown a:has-text("中文")'); await expect(zhOption.first()).toBeVisible(); diff --git a/Website/tests/docs.test.js b/Website/tests/docs.test.js index 54fc67e2..32a64ca2 100644 --- a/Website/tests/docs.test.js +++ b/Website/tests/docs.test.js @@ -1,7 +1,7 @@ /** * Documentation Tests */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; test.describe('Documentation', () => { test('docs index loads', async ({ page }) => { diff --git a/Website/tests/fixtures.js b/Website/tests/fixtures.js new file mode 100644 index 00000000..0fed2da8 --- /dev/null +++ b/Website/tests/fixtures.js @@ -0,0 +1,25 @@ +import { test as base, expect } from '@playwright/test'; + +export const test = base.extend({ + browserHealth: [async ({ page }, use) => { + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); }); + await use(); + expect(errors, 'Every interaction must finish without browser errors').toEqual([]); + if (await page.locator('meta[name="viewport"]').count()) { + await expect(page.locator('main')).toHaveCount(1); + await expect(page.locator('h1')).toHaveCount(1); + await expect(page.locator('html')).toHaveAttribute('lang', /^(en|zh)$/); + await expect(page).toHaveTitle(/RestClient\.Net/); + await expect(page.locator('meta[name="description"]')).toHaveAttribute('content', /\S/); + await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); + await expect(page.locator('link[rel="stylesheet"]')).toHaveCount(1); + await expect(page.locator('style,[style]'), 'Styles must stay inside the 2500-byte stylesheet budget').toHaveCount(0); + const remoteStyles = await page.evaluate(() => [...document.styleSheets].filter(sheet => !sheet.href || new URL(sheet.href).origin !== location.origin).length); + expect(remoteStyles).toBe(0); + } + }, { auto: true }], +}); + +export { expect }; diff --git a/Website/tests/homepage.test.js b/Website/tests/homepage.test.js index 3c0f1783..d48e5e14 100644 --- a/Website/tests/homepage.test.js +++ b/Website/tests/homepage.test.js @@ -1,7 +1,7 @@ /** * Homepage Tests */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; test.describe('Homepage', () => { test('homepage loads', async ({ page }) => { diff --git a/Website/tests/motion-prose.test.js b/Website/tests/motion-prose.test.js new file mode 100644 index 00000000..5b567219 --- /dev/null +++ b/Website/tests/motion-prose.test.js @@ -0,0 +1,195 @@ +import { test, expect } from './fixtures.js'; +import { mkdir } from 'node:fs/promises'; +import path from 'node:path'; + +const pixels = canvas => canvas.evaluate(element => { + const data = element.toDataURL(); + let hash = 2166136261; + for (let index = 0; index < data.length; index++) hash = Math.imul(hash ^ data.charCodeAt(index), 16777619); + return hash >>> 0; +}); + +for (const viewport of [{ width: 1280, height: 800 }, { width: 375, height: 667 }]) { + test(`all outcome edit cycles change real canvas pixels at ${viewport.width}px`, async ({ page }) => { + await page.setViewportSize(viewport); + await page.emulateMedia({ reducedMotion: 'reduce' }); + const outgoing = []; + page.on('request', request => { if (['fetch', 'xhr'].includes(request.resourceType())) outgoing.push(request.url()); }); + await page.goto('/'); + const canvas = page.locator('#flow-canvas'); + await canvas.scrollIntoViewIfNeeded(); + await expect(canvas).toHaveAttribute('data-motion', 'paused'); + await expect(page.locator('#motion-toggle')).toHaveAttribute('aria-pressed', 'true'); + let previous = await pixels(canvas); + const signatures = new Map(); + for (const [outcome, status, branch] of [ + ['response', '404 Not Found', 'ResponseErrorPost'], + ['exception', 'Connection failed', 'ExceptionErrorPost'], + ['success', '200 OK', 'OkPost'], + ['response', '404 Not Found', 'ResponseErrorPost'], + ['success', '200 OK', 'OkPost'], + ]) { + const button = page.locator(`button[data-outcome="${outcome}"]`); + await expect(button).toBeEnabled(); + await button.click(); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('button[data-outcome][aria-pressed="true"]')).toHaveCount(1); + await expect(page.locator('button[data-outcome][aria-pressed="false"]')).toHaveCount(2); + await expect(page.locator('#outcome-status')).toContainText(status); + await expect(page.locator('#outcome-status')).toHaveAttribute('aria-atomic', 'true'); + await expect(page.locator('#outcome-code strong')).toHaveCount(1); + await expect(page.locator('#outcome-code strong')).toContainText(branch); + await expect(canvas).toHaveAttribute('data-outcome', outcome); + await expect(canvas).toHaveAttribute('data-motion', 'paused'); + const current = await pixels(canvas); + expect(current, 'Changing outcome must redraw real graphics').not.toBe(previous); + if (signatures.has(outcome)) expect(current, 'Returning to a paused outcome must restore its exact rendering').toBe(signatures.get(outcome)); + signatures.set(outcome, current); + previous = current; + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await expect(page.locator('style,[style]')).toHaveCount(0); + } + expect(new Set(signatures.values()).size).toBe(3); + expect(outgoing).toEqual([]); + }); +} + +test('animation changes pixels while playing and freezes across pause/resume cycles', async ({ page }) => { + await page.goto('/'); + const canvas = page.locator('#flow-canvas'); + await canvas.scrollIntoViewIfNeeded(); + const toggle = page.locator('#motion-toggle'); + await expect(canvas).toHaveAttribute('data-motion', 'running'); + let initial = await pixels(canvas); + await expect.poll(() => pixels(canvas)).not.toBe(initial); + for (let cycle = 0; cycle < 2; cycle++) { + await toggle.click(); + await expect(toggle).toHaveText('Play motion'); + await expect(toggle).toHaveAttribute('aria-pressed', 'true'); + await expect(canvas).toHaveAttribute('data-motion', 'paused'); + const paused = await pixels(canvas); + await page.waitForTimeout(180); + expect(await pixels(canvas), 'Paused motion must not keep rendering changing pixels').toBe(paused); + await toggle.click(); + await expect(toggle).toHaveText('Pause motion'); + await expect(toggle).toHaveAttribute('aria-pressed', 'false'); + await expect(canvas).toHaveAttribute('data-motion', 'running'); + initial = await pixels(canvas); + await expect.poll(() => pixels(canvas)).not.toBe(initial); + } +}); + +test('reduced-motion preference freezes graphics and changing the preference updates behavior', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/'); + const canvas = page.locator('#flow-canvas'); + await canvas.scrollIntoViewIfNeeded(); + await expect(canvas).toHaveAttribute('data-motion', 'paused'); + const initial = await pixels(canvas); + await page.waitForTimeout(180); + expect(await pixels(canvas)).toBe(initial); + expect(await page.locator('html').evaluate(element => getComputedStyle(element).scrollBehavior)).toBe('auto'); + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await expect(canvas).toHaveAttribute('data-motion', 'running'); + await expect.poll(() => pixels(canvas)).not.toBe(initial); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(canvas).toHaveAttribute('data-motion', 'paused'); + const stopped = await pixels(canvas); + await page.waitForTimeout(180); + expect(await pixels(canvas)).toBe(stopped); +}); + +test('keyboard outcome selection keeps code, live status, and selected graphics synchronized', async ({ page }) => { + await page.goto('/'); + for (const [outcome, text] of [['exception', 'ExceptionError'], ['response', 'ResponseError'], ['success', '200 OK']]) { + const button = page.locator(`button[data-outcome="${outcome}"]`); + await button.focus(); + await page.keyboard.press('Space'); + await expect(button).toBeFocused(); + await expect(button).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#outcome-status')).toContainText(text); + await expect(page.locator('#flow-canvas')).toHaveAttribute('data-outcome', outcome); + await expect(page.locator('button[data-outcome][aria-pressed="true"]')).toHaveCount(1); + } +}); + +test('docs, blog, and source API reference share the exact same prose CSS', async ({ page }) => { + const samples = []; + const screenshots = path.resolve('../.artifacts/website-review'); + await mkdir(screenshots, { recursive: true }); + for (const [route, name] of [['/docs/', 'docs'], ['/blog/introducing-restclient/', 'blog'], ['/api/reference/restclient-net-httpclientextensions/', 'api']]) { + await page.goto(route); + await expect(page.locator('.prose')).toHaveCount(1); + await expect(page.locator('.prose h1')).toHaveCount(1); + const styles = {}; + for (const selector of ['.prose > p:not(.eyebrow)', '.prose h2', '.prose pre']) { + const element = page.locator(selector).first(); + await expect(element).toBeVisible(); + styles[selector] = await element.evaluate(node => { + const style = getComputedStyle(node); + return Object.fromEntries(['fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'color', 'letterSpacing'].map(key => [key, style[key]])); + }); + } + samples.push(styles); + await page.screenshot({ path: path.join(screenshots, `${name}-desktop.png`), fullPage: true }); + await page.setViewportSize({ width: 375, height: 667 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + await page.screenshot({ path: path.join(screenshots, `${name}-mobile.png`), fullPage: true }); + await page.setViewportSize({ width: 1280, height: 720 }); + } + expect(samples[1]).toEqual(samples[0]); + expect(samples[2]).toEqual(samples[0]); +}); + +test('core content and navigation remain usable with JavaScript disabled', async ({ browser, baseURL }) => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + await page.goto(baseURL); + await expect(page.locator('.hero h1')).toBeVisible(); + const fallback = page.locator('noscript p'); + await expect(fallback).toBeVisible(); + expect(await fallback.textContent()).toContain('A request produces a success'); + await expect(page.locator('#outcome-code')).toContainText('OkPost'); + await expect(page.locator('button[data-outcome]')).toHaveCount(3); + for (const button of await page.locator('button[data-outcome]').all()) await expect(button).toBeDisabled(); + await page.locator('.hero-actions a').first().click(); + await expect(page.locator('.prose')).toBeVisible(); + await expect(page.locator('.prose pre code').first()).toBeVisible(); + await context.close(); +}); + +test('every generated API type and its long qualified heading fit mobile width', async ({ page, request }) => { + test.setTimeout(60000); + await page.setViewportSize({ width: 375, height: 667 }); + const response = await request.get('/api/reference/api.json'); + expect(response.status()).toBe(200); + const api = await response.json(); + expect(api.types.length).toBeGreaterThan(20); + expect(api.types.some(type => type.displayName.includes('OpenApiCodeGenerator'))).toBe(true); + for (const type of api.types) { + const loaded = await page.goto(type.url); + expect(loaded.status(), type.url).toBe(200); + await expect(page.locator('.prose h1')).toHaveText(type.displayName); + const bounds = await page.locator('.prose h1').boundingBox(); + expect(bounds.width, type.url).toBeLessThanOrEqual(375); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), type.url).toBe(true); + await expect(page.locator('.prose pre code').first()).toBeVisible(); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', new RegExp(`${type.url}$`)); + } +}); + +test('page lifecycle suspension freezes graphics and restoration resumes real motion', async ({ page }) => { + await page.goto('/'); + const canvas = page.locator('#flow-canvas'); + await canvas.scrollIntoViewIfNeeded(); + const running = await pixels(canvas); + await expect.poll(() => pixels(canvas)).not.toBe(running); + await page.evaluate(() => dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }))); + const suspended = await pixels(canvas); + await page.waitForTimeout(180); + expect(await pixels(canvas)).toBe(suspended); + await page.evaluate(() => dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }))); + await expect.poll(() => pixels(canvas)).not.toBe(suspended); + await expect(page.locator('#motion-toggle')).toHaveAttribute('aria-pressed', 'false'); + await expect(canvas).toHaveAttribute('data-motion', 'running'); +}); diff --git a/Website/tests/seo.test.js b/Website/tests/seo.test.js index 9ada13cb..b7cda881 100644 --- a/Website/tests/seo.test.js +++ b/Website/tests/seo.test.js @@ -1,7 +1,7 @@ /** * SEO Tests */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; test.describe('SEO Files', () => { test('robots.txt exists', async ({ page }) => { diff --git a/Website/tests/syntax-highlighting.test.js b/Website/tests/syntax-highlighting.test.js index ce963c60..792a0973 100644 --- a/Website/tests/syntax-highlighting.test.js +++ b/Website/tests/syntax-highlighting.test.js @@ -3,7 +3,7 @@ * Verifies that ALL pages with code blocks have proper Prism syntax highlighting * NO SKIPPING - FAIL HARD if anything is wrong! */ -import { test, expect } from '@playwright/test'; +import { test, expect } from './fixtures.js'; // All pages that MUST contain code blocks with syntax highlighting // If any page doesn't have code or highlighting, TEST FAILS! diff --git a/Website/tests/visual-qa.test.js b/Website/tests/visual-qa.test.js index 545a6ede..ee8a902c 100644 --- a/Website/tests/visual-qa.test.js +++ b/Website/tests/visual-qa.test.js @@ -1,179 +1,194 @@ -import { test, expect } from '@playwright/test'; - -const baseUrl = 'http://localhost:8080'; - -test.describe('Visual QA Checks', () => { - test('homepage hero section renders correctly', async ({ page }) => { - await page.goto(baseUrl); - const hero = page.locator('.hero'); - await expect(hero).toBeVisible(); - await expect(page.locator('.hero-logo')).toBeVisible(); - await expect(page.locator('.hero-tagline')).toBeVisible(); - await expect(page.locator('.hero-actions')).toBeVisible(); - }); - - test('homepage feature cards have proper styling', async ({ page }) => { - await page.goto(baseUrl); - const cards = page.locator('.feature-card'); - await expect(cards.first()).toBeVisible(); - const count = await cards.count(); - expect(count).toBeGreaterThanOrEqual(6); - }); - - test('Why Discriminated Unions section renders', async ({ page }) => { - await page.goto(baseUrl); - const unionSection = page.locator('text=Why Discriminated Unions?'); - await expect(unionSection).toBeVisible(); - - // Check both code examples exist - const withoutExhaustion = page.locator('text=Without Exhaustion'); - const withExhaustion = page.locator('text=With Exhaustion'); - await expect(withoutExhaustion).toBeVisible(); - await expect(withExhaustion).toBeVisible(); - - // Check code blocks in this section exist - const codeBlocks = page.locator('.feature-card pre code'); - const codeCount = await codeBlocks.count(); - expect(codeCount).toBeGreaterThanOrEqual(2); - }); - - test('navigation links work', async ({ page }) => { - await page.goto(baseUrl); - - // Check docs link - await page.click('a[href="/docs/"]'); - await expect(page).toHaveURL(/\/docs\//); - - // Check API link - await page.click('a[href="/api/"]'); - await expect(page).toHaveURL(/\/api\//); - - // Check blog link - await page.click('a[href="/blog/"]'); - await expect(page).toHaveURL(/\/blog\//); - }); - - test('navigation active state is exclusive - only ONE nav item should be active per page', async ({ page }) => { - // Test home page - only Home should be active - await page.goto(baseUrl); - let activeLinks = page.locator('.nav-link.active'); - await expect(activeLinks).toHaveCount(1); - await expect(activeLinks.first()).toHaveText('Home'); - - // Test docs page - only Docs should be active - await page.goto(`${baseUrl}/docs/`); - activeLinks = page.locator('.nav-link.active'); - await expect(activeLinks).toHaveCount(1); - await expect(activeLinks.first()).toHaveText('Docs'); - - // Test API page - only API should be active - await page.goto(`${baseUrl}/api/`); - activeLinks = page.locator('.nav-link.active'); - await expect(activeLinks).toHaveCount(1); - await expect(activeLinks.first()).toHaveText('API'); - - // Test blog page - only Blog should be active - await page.goto(`${baseUrl}/blog/`); - activeLinks = page.locator('.nav-link.active'); - await expect(activeLinks).toHaveCount(1); - await expect(activeLinks.first()).toHaveText('Blog'); - - // Test examples page - only Examples should be active - await page.goto(`${baseUrl}/examples/`); - activeLinks = page.locator('.nav-link.active'); - await expect(activeLinks).toHaveCount(1); - await expect(activeLinks.first()).toHaveText('Examples'); - }); - - test('docs sidebar navigation works', async ({ page }) => { - await page.goto(`${baseUrl}/docs/`); - const sidebar = page.locator('.sidebar, .docs-sidebar'); - await expect(sidebar.first()).toBeVisible(); - }); - - test('code blocks have proper syntax highlighting colors', async ({ page }) => { - await page.goto(baseUrl); - const codeBlock = page.locator('pre code').first(); - await expect(codeBlock).toBeVisible(); - - // Check that syntax highlighting classes are present (Prism uses .token, hljs uses .hljs) - const hasHighlight = await page.evaluate(() => { - const code = document.querySelector('pre code'); - return code && ( - code.classList.contains('hljs') || - code.querySelector('.hljs-keyword') || - code.querySelector('.token') || - code.classList.contains('language-csharp') || - code.classList.contains('language-bash') - ); - }); - expect(hasHighlight).toBeTruthy(); - }); - - test('footer renders with all sections', async ({ page }) => { - await page.goto(baseUrl); - const footer = page.locator('footer'); - await expect(footer).toBeVisible(); - - // Check footer has links - const footerLinks = footer.locator('a'); - const linkCount = await footerLinks.count(); - expect(linkCount).toBeGreaterThan(5); - }); - - test('theme toggle button exists', async ({ page }) => { - await page.goto(baseUrl); - const themeToggle = page.locator('#theme-toggle'); - await expect(themeToggle).toBeVisible(); - }); - - test('language switcher exists and works', async ({ page }) => { - await page.goto(baseUrl); - const langSwitcher = page.locator('.language-switcher'); - if (await langSwitcher.count() > 0) { - await expect(langSwitcher).toBeVisible(); - } - }); - - test('Chinese pages load correctly', async ({ page }) => { - await page.goto(`${baseUrl}/zh/`); - await expect(page).toHaveURL(/\/zh\//); - - // Check lang attribute - const html = page.locator('html'); - await expect(html).toHaveAttribute('lang', 'zh'); - }); - - test('mobile menu toggle exists on mobile viewport', async ({ page }) => { - await page.setViewportSize({ width: 375, height: 667 }); - await page.goto(baseUrl); - - // Mobile menu toggle should be visible on mobile - const mobileToggle = page.locator('#mobile-menu-toggle, .mobile-menu-toggle, [aria-label*="menu"]'); - // This may or may not be visible depending on CSS - just check page loads - await expect(page.locator('.hero')).toBeVisible(); - }); - - test('blog posts have proper structure', async ({ page }) => { - await page.goto(`${baseUrl}/blog/`); - const posts = page.locator('article, .post, .blog-post'); - - // Should have blog posts - const postCount = await posts.count(); - expect(postCount).toBeGreaterThan(0); - }); - - test('API reference pages load', async ({ page }) => { - await page.goto(`${baseUrl}/api/`); - await expect(page.locator('h1')).toBeVisible(); +import { test, expect } from './fixtures.js'; + +const routes = [['/', 'Home'], ['/docs/', 'Docs'], ['/api/', 'API'], ['/blog/', 'Blog'], ['/examples/', 'Examples']]; + +test('homepage hero has clear copy and working actions', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('.hero')).toBeVisible(); + await expect(page.locator('.hero h1')).toHaveText(/Every outcome.*In view/s); + await expect(page.locator('.hero-tagline')).toContainText('HTTP'); + await expect(page.locator('.hero-actions a')).toHaveCount(2); + await page.locator('.hero-actions a').first().click(); + await expect(page).toHaveURL(/\/docs\/$/); + await expect(page.locator('.prose')).toBeVisible(); + await expect(page.locator('h1')).toHaveCount(1); +}); - // Navigate to a specific API page - await page.goto(`${baseUrl}/api/httpclient-extensions/`); - await expect(page.locator('h1')).toBeVisible(); - }); +test('feature cards expose explanations and destination links', async ({ page }) => { + await page.goto('/'); + const cards = page.locator('.feature-card'); + expect(await cards.count()).toBeGreaterThanOrEqual(6); + for (const card of await cards.all()) { + await expect(card).toBeVisible(); + await expect(card.locator('h3')).not.toBeEmpty(); + await expect(card).toHaveAttribute('href', /\//); + } +}); - test('examples page loads', async ({ page }) => { - await page.goto(`${baseUrl}/examples/`); +test('request explorer explains every outcome with native graphics', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('#flow-canvas')).toBeVisible(); + await expect(page.locator('button[data-outcome]')).toHaveCount(3); + await expect(page.locator('#outcome-status')).toHaveAttribute('aria-live', 'polite'); + await expect(page.locator('#outcome-code')).toContainText('switch'); + await expect(page.locator('video,iframe')).toHaveCount(0); + await expect(page.locator('#motion-toggle')).toBeVisible(); +}); + +test('navigation supports a multi-page reading journey', async ({ page }) => { + await page.goto('/'); + for (const [route, label] of routes) { + await page.locator('#mobile-nav nav').locator(`a[href="${route}"]`).click(); + expect(new URL(page.url()).pathname).toBe(route); await expect(page.locator('h1')).toBeVisible(); - }); + const active = page.locator('#mobile-nav [aria-current="page"]'); + await expect(active).toHaveCount(1); + await expect(active).toHaveText(label); + await expect(active).toHaveAttribute('href', route); + } +}); + +test('active navigation stays exclusive on child pages too', async ({ page }) => { + for (const [route, label] of [...routes, ['/docs/basic-usage/', 'Docs']]) { + await page.goto(route); + const active = page.locator('#mobile-nav .nav-link.active'); + await expect(active).toHaveCount(1); + await expect(active).toHaveText(label); + await expect(active).toHaveAttribute('aria-current', 'page'); + } +}); + +test('docs sidebar opens a guide and returns to the overview', async ({ page }) => { + await page.goto('/docs/'); + const sidebar = page.locator('.docs-sidebar'); + await expect(sidebar).toBeVisible(); + await sidebar.locator('a[href="/docs/basic-usage/"]').click(); + await expect(page.locator('.prose h1')).toContainText('Basic'); + await expect(sidebar.locator('[aria-current="page"]')).toHaveAttribute('href', '/docs/basic-usage/'); + await sidebar.locator('a[href="/docs/"]').click(); + await expect(page).toHaveURL(/\/docs\/$/); + await expect(page.locator('.prose pre code').first()).toBeVisible(); +}); + +test('code stays selectable highlighted text with distinct token colors', async ({ page }) => { + await page.goto('/'); + const code = page.locator('pre code').first(); + await expect(code).toBeVisible(); + await expect(code).toHaveClass(/language-csharp/); + expect(await code.locator('.token').count()).toBeGreaterThan(0); + const colors = await code.evaluate(element => [getComputedStyle(element.querySelector('.keyword')).color, getComputedStyle(element.querySelector('.string')).color]); + expect(colors[0]).not.toBe(colors[1]); + expect(await code.textContent()).toContain('result'); +}); + +test('footer links to guides, source, packages, and generated reference', async ({ page }) => { + await page.goto('/'); + const footer = page.locator('footer'); + await expect(footer).toBeVisible(); + expect(await footer.locator('a').count()).toBeGreaterThan(5); + await expect(footer.locator('a[href*="nuget.org"]')).toBeVisible(); + await expect(footer.locator('a[href*="github.com"]')).toBeVisible(); + await footer.locator('a[href="/api/reference/"]').click(); + await expect(page).toHaveURL(/\/api\/reference\/$/); + await expect(page.locator('.prose')).toBeVisible(); +}); + +test('motion control works with keyboard and creates no inline CSS', async ({ page }) => { + await page.goto('/'); + const toggle = page.locator('#motion-toggle'); + await expect(toggle).toBeVisible(); + await expect(toggle).toHaveAccessibleName(/motion/i); + await toggle.focus(); + await page.keyboard.press('Enter'); + await expect(toggle).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('#flow-canvas')).toHaveAttribute('data-motion', 'paused'); + await page.keyboard.press('Enter'); + await expect(toggle).toHaveAttribute('aria-pressed', 'false'); + await expect(page.locator('#flow-canvas')).toHaveAttribute('data-motion', 'running'); + await expect(page.locator('style,[style]')).toHaveCount(0); +}); + +test('language selector visits translated document and its original', async ({ page }) => { + await page.goto('/docs/basic-usage/'); + await page.locator('.language-switcher summary').click(); + await expect(page.locator('.language-switcher')).toHaveAttribute('open', ''); + await page.locator('.language-switcher a[lang="zh"]').click(); + await expect(page).toHaveURL(/\/zh\/docs\/basic-usage\/$/); + await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); + await page.locator('.language-switcher summary').click(); + await page.locator('.language-switcher a[lang="en"]').click(); + await expect(page).toHaveURL(/\/docs\/basic-usage\/$/); + await expect(page.locator('html')).toHaveAttribute('lang', 'en'); +}); + +test('Chinese homepage keeps localized navigation and outcome controls', async ({ page }) => { + await page.goto('/zh/'); + await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); + await expect(page.locator('h1')).toContainText('每种结果'); + await expect(page.locator('#mobile-nav [aria-current="page"]')).toHaveText('首页'); + await expect(page.locator('button[data-outcome="success"]')).toContainText('成功'); + await expect(page.locator('.hero-actions a').first()).toHaveAttribute('href', '/zh/docs/'); +}); + +test('mobile navigation opens and closes using keyboard and pointer', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/'); + const menu = page.locator('#mobile-nav'); + const summary = menu.locator('summary'); + await expect(summary).toBeVisible(); + for (const control of [summary, page.locator('.language-switcher summary')]) { + const bounds = await control.boundingBox(); + expect(bounds.height, 'Mobile controls must provide a usable touch target').toBeGreaterThanOrEqual(44); + expect(bounds.width).toBeGreaterThanOrEqual(44); + } + if (await menu.getAttribute('open') !== null) await summary.click(); + await expect(menu).not.toHaveAttribute('open'); + await expect(menu.locator('nav')).toBeHidden(); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(menu).toHaveAttribute('open', ''); + await expect(menu.locator('nav')).toBeVisible(); + await menu.locator('a[href="/docs/"]').click(); + await expect(page).toHaveURL(/\/docs\/$/); + await expect(page.locator('.prose')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); + +test('blog articles contain dates, headings, examples, and a return link', async ({ page }) => { + await page.goto('/blog/'); + const article = page.locator('article h2 a[href="/blog/introducing-restclient/"]'); + await expect(article).toBeVisible(); + await article.click(); + await expect(page.locator('article.prose h1')).toContainText('RestClient.Net'); + await expect(page.locator('article.prose time')).toHaveAttribute('datetime', /^\d{4}-\d{2}-\d{2}/); + await expect(page.locator('article.prose pre code').first()).toBeVisible(); + await expect(page.locator('article.prose a[href="/blog/"]')).toBeVisible(); +}); + +test('API guide and generated reference contain useful source documentation', async ({ page }) => { + await page.goto('/api/httpclient-extensions/'); + await expect(page.locator('h1')).toBeVisible(); + await expect(page.locator('main')).toContainText('GetAsync'); + await page.goto('/api/reference/'); + await expect(page.locator('h1')).toContainText('API'); + expect(await page.locator('.prose a').count()).toBeGreaterThan(5); +}); + +test('examples remain readable without horizontal page overflow on mobile', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto('/examples/'); + await expect(page.locator('h1')).toBeVisible(); + expect(await page.locator('pre code').count()).toBeGreaterThan(0); + await expect(page.locator('pre code').first()).toBeVisible(); + const listing = page.locator('pre code.language-csharp'); + await expect(listing).toHaveCount(1); + for (const symbol of ['RestClientExamples', 'GetPostAsync', 'CreatePostAsync', 'GetUsingFactoryAsync', '.Match(', 'CancellationToken']) { + await expect(listing).toContainText(symbol); + } + await expect(page.locator('pre code.language-bash')).toHaveText('dotnet run --project Website/examples/Examples.csproj'); + await expect(page.locator('.prose')).not.toContainText('{{ examples.source'); + for (const anchor of ['basic-get', 'post-request', 'ihttpclientfactory', 'status-code-handling']) { + await expect(page.locator(`#${anchor}`)).toHaveCount(1); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); }); diff --git a/Website/tools/ApiDocs/ApiDocs.csproj b/Website/tools/ApiDocs/ApiDocs.csproj new file mode 100644 index 00000000..a575cc15 --- /dev/null +++ b/Website/tools/ApiDocs/ApiDocs.csproj @@ -0,0 +1,12 @@ + + + Exe + + + + + + + + + diff --git a/Website/tools/ApiDocs/Directory.Build.props b/Website/tools/ApiDocs/Directory.Build.props new file mode 100644 index 00000000..42833fbf --- /dev/null +++ b/Website/tools/ApiDocs/Directory.Build.props @@ -0,0 +1,11 @@ + + + + net9.0 + enable + enable + 12 + false + true + + diff --git a/Website/tools/ApiDocs/Program.cs b/Website/tools/ApiDocs/Program.cs new file mode 100644 index 00000000..7fa762ad --- /dev/null +++ b/Website/tools/ApiDocs/Program.cs @@ -0,0 +1,584 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +var options = Arguments.Parse(args); +var projects = + options.Projects.Length > 0 + ? options.Projects + : + [ + "RestClient.Net", + "Outcome", + "RestClient.Net.OpenApiGenerator", + "RestClient.Net.McpGenerator", + "Exhaustion", + ]; +var files = projects + .SelectMany(project => + Directory + .EnumerateFiles( + Path.Combine(options.Root, project), + "*.cs", + SearchOption.AllDirectories + ) + .Where(file => + !Path.GetRelativePath(options.Root, file) + .Split(Path.DirectorySeparatorChar) + .Any(part => part is "bin" or "obj") + ) + .Select(file => (Project: project, Path: file)) + ) + .OrderBy(item => item.Path, StringComparer.Ordinal) + .ToArray(); +var trees = files + .Select(file => + CSharpSyntaxTree.ParseText( + File.ReadAllText(file.Path), + new CSharpParseOptions(LanguageVersion.CSharp12, DocumentationMode.Diagnose), + file.Path + ) + ) + .ToArray(); +foreach ( + var error in trees + .SelectMany(tree => tree.GetDiagnostics()) + .Where(item => item.Severity == DiagnosticSeverity.Error) +) + throw new InvalidOperationException($"Cannot export invalid C# source: {error}"); +var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator) + .Select(file => MetadataReference.CreateFromFile(file)); + +// Match the SDK implicit imports enabled by the repository Directory.Build.props. +// Semantic symbols identify public declarations and overloads; signatures retain their exact +// source types/aliases, constraints and default expressions without building product packages. +var compilation = CSharpCompilation.Create( + "ApiDocumentation", + trees.Append( + CSharpSyntaxTree.ParseText( + """ + global using global::System; + global using global::System.Collections.Generic; + global using global::System.IO; + global using global::System.Linq; + global using global::System.Net.Http; + global using global::System.Threading; + global using global::System.Threading.Tasks; + """, + new CSharpParseOptions(LanguageVersion.CSharp12) + ) + ), + references, + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable + ) +); +var types = new Dictionary(StringComparer.Ordinal); +for (var index = 0; index < trees.Length; index++) +{ + var tree = trees[index]; + var model = compilation.GetSemanticModel(tree); + foreach ( + var declaration in tree.GetRoot() + .DescendantNodes() + .Where(node => node is BaseTypeDeclarationSyntax or DelegateDeclarationSyntax) + ) + { + if ( + model.GetDeclaredSymbol(declaration) is not INamedTypeSymbol symbol + || !Exporter.IsPublic(symbol) + ) + continue; + var id = symbol.GetDocumentationCommentId()!; + if (types.ContainsKey(id)) + continue; + var declarations = symbol + .DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()) + .OrderBy(node => node.SyntaxTree.FilePath, StringComparer.Ordinal) + .ThenBy(node => node.SpanStart) + .ToArray(); + var primary = + declarations.FirstOrDefault(node => Exporter.Documentation(node).Summary.Length > 0) + ?? declarations[0]; + var members = new List(); + foreach ( + var member in symbol + .GetMembers() + .Where(member => + !member.IsImplicitlyDeclared + && member is not INamedTypeSymbol + && Exporter.IsPublic(member) + ) + ) + { + var node = member.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax(); + if ( + node is null + || node + is not (MemberDeclarationSyntax or VariableDeclaratorSyntax or ParameterSyntax) + ) + continue; + members.Add(Exporter.Member(member, node, options)); + } + types[id] = new ApiType( + id, + symbol.Name, + symbol.ToDisplayString(Exporter.TypeNames), + symbol.ContainingNamespace.ToDisplayString(), + files[index].Project, + symbol.TypeKind.ToString().ToLowerInvariant(), + Exporter.Signature(primary), + Exporter.Documentation(primary), + Exporter.Parameters(primary), + Exporter.Source(primary, options), + "/api/reference/" + Exporter.Slug(id[2..]) + "/", + members.OrderBy(member => member.Id, StringComparer.Ordinal).ToArray() + ); + } +} +var api = new ApiIndex( + 1, + options.SourceRef, + projects.Order(StringComparer.Ordinal).ToArray(), + types.Values.OrderBy(type => type.Id, StringComparer.Ordinal).ToArray() +); +Directory.CreateDirectory(options.Output); +foreach (var old in Directory.EnumerateFiles(options.Output, "*.md")) + File.Delete(old); +File.WriteAllText( + Path.Combine(options.Output, "api.json"), + JsonSerializer.Serialize(api, Exporter.JsonOptions) + "\n" +); +foreach (var type in api.Types) + File.WriteAllText( + Path.Combine(options.Output, Exporter.Slug(type.Id[2..]) + ".md"), + Exporter.Markdown(type) + ); +File.WriteAllText(Path.Combine(options.Output, "index.md"), Exporter.IndexMarkdown(api)); +Console.WriteLine( + $"Exported {api.Types.Length} public types and {api.Types.Sum(type => type.Members.Length)} members from {files.Length} C# source files." +); + +internal sealed record Arguments(string Root, string Output, string SourceRef, string[] Projects) +{ + public static Arguments Parse(string[] args) + { + var values = new Dictionary(); + for (var index = 0; index < args.Length; index += 2) + { + if ( + index + 1 >= args.Length + || !new[] { "--root", "--output", "--source-ref", "--projects" }.Contains( + args[index] + ) + ) + throw new ArgumentException( + "Use --root PATH --output PATH [--source-ref REF] [--projects dir,dir]" + ); + values.Add(args[index], args[index + 1]); + } + return new Arguments( + Path.GetFullPath(values["--root"]), + Path.GetFullPath(values["--output"]), + values.GetValueOrDefault("--source-ref", "main"), + values + .GetValueOrDefault("--projects", "") + .Split(',', StringSplitOptions.RemoveEmptyEntries) + ); + } +} + +internal sealed record SourceLocation(string File, int Line, string Url); + +internal sealed record Parameter(string Name, string Type, string Modifiers, string? Default); + +internal sealed record NamedDocumentation(string Name, string Description); + +internal sealed record Documentation( + string Summary, + string Remarks, + string Returns, + NamedDocumentation[] Parameters, + NamedDocumentation[] TypeParameters +); + +internal sealed record ApiMember( + string Id, + string Anchor, + string Name, + string Kind, + string Signature, + Documentation Docs, + Parameter[] Parameters, + SourceLocation Source +); + +internal sealed record ApiType( + string Id, + string Name, + string DisplayName, + string Namespace, + string Project, + string Kind, + string Signature, + Documentation Docs, + Parameter[] Parameters, + SourceLocation Source, + string Url, + ApiMember[] Members +); + +internal sealed record ApiIndex( + int SchemaVersion, + string SourceRef, + string[] Projects, + ApiType[] Types +); + +internal static partial class Exporter +{ + [GeneratedRegex("[^a-z0-9]+")] + private static partial Regex SlugPattern(); + + [GeneratedRegex(@"\s+")] + private static partial Regex Whitespace(); + + [GeneratedRegex(@"(?m)^\s*/// ?")] + private static partial Regex DocumentationPrefix(); + + [GeneratedRegex(@"^\s*/\*\*|\*/\s*$")] + private static partial Regex BlockCommentBoundary(); + + [GeneratedRegex(@"(?m)^\s*\* ?")] + private static partial Regex BlockCommentLine(); + + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + public static readonly SymbolDisplayFormat TypeNames = new( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + public static bool IsPublic(ISymbol symbol) => + symbol.DeclaredAccessibility == Accessibility.Public + && (symbol.ContainingType is null || IsPublic(symbol.ContainingType)); + + public static string Slug(string value) => + SlugPattern().Replace(value.ToLowerInvariant(), "-").Trim('-'); + + private static string Anchor(ISymbol symbol) => + Slug(symbol.Name) + + "-" + + Convert + .ToHexString( + SHA256.HashData(Encoding.UTF8.GetBytes(symbol.GetDocumentationCommentId()!)) + )[..12] + .ToLowerInvariant(); + + public static SourceLocation Source(SyntaxNode node, Arguments options) + { + var file = Path.GetRelativePath(options.Root, node.SyntaxTree.FilePath).Replace('\\', '/'); + var line = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + return new SourceLocation( + file, + line, + "https://github.com/MelbourneDeveloper/RestClient.Net/blob/" + + Uri.EscapeDataString(options.SourceRef) + + "/" + + string.Join('/', file.Split('/').Select(Uri.EscapeDataString)) + + "#L" + + line + ); + } + + public static ApiMember Member(ISymbol symbol, SyntaxNode node, Arguments options) + { + var documentationNode = node is VariableDeclaratorSyntax ? node.Parent!.Parent! : node; + return new ApiMember( + symbol.GetDocumentationCommentId()!, + Anchor(symbol), + symbol.Name, + symbol.Kind.ToString().ToLowerInvariant(), + symbol is IMethodSymbol { MethodKind: MethodKind.Constructor } + && node is TypeDeclarationSyntax + ? $"public {symbol.ContainingType.Name}({string.Join(", ", Parameters(node).Select(parameter => parameter.Type + " " + parameter.Name + (parameter.Default is null ? "" : " = " + parameter.Default)))});" + : Signature(node), + Documentation(documentationNode), + Parameters(node), + Source(node, options) + ); + } + + public static Parameter[] Parameters(SyntaxNode node) + { + var parameters = node switch + { + BaseMethodDeclarationSyntax method => method.ParameterList.Parameters, + DelegateDeclarationSyntax declaration => declaration.ParameterList.Parameters, + RecordDeclarationSyntax record => record.ParameterList?.Parameters ?? default, + ClassDeclarationSyntax type => type.ParameterList?.Parameters ?? default, + StructDeclarationSyntax type => type.ParameterList?.Parameters ?? default, + IndexerDeclarationSyntax indexer => indexer.ParameterList.Parameters, + _ => default, + }; + return parameters + .Select(parameter => new Parameter( + parameter.Identifier.ValueText, + parameter.Type?.ToString() ?? "", + string.Join(' ', parameter.Modifiers.Select(token => token.Text)), + parameter.Default?.Value.ToString() + )) + .ToArray(); + } + + public static string Signature(SyntaxNode node) + { + SyntaxNode signature = node switch + { + MethodDeclarationSyntax value => value + .WithBody(null) + .WithExpressionBody(null) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ConstructorDeclarationSyntax value => value + .WithBody(null) + .WithExpressionBody(null) + .WithInitializer(null) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + OperatorDeclarationSyntax value => value + .WithBody(null) + .WithExpressionBody(null) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + ConversionOperatorDeclarationSyntax value => value + .WithBody(null) + .WithExpressionBody(null) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)), + PropertyDeclarationSyntax value => value + .WithExpressionBody(null) + .WithInitializer(null) + .WithAccessorList(Accessors(value.AccessorList)) + .WithSemicolonToken(default), + IndexerDeclarationSyntax value => value + .WithExpressionBody(null) + .WithAccessorList(Accessors(value.AccessorList)) + .WithSemicolonToken(default), + EventDeclarationSyntax value => value.WithAccessorList(Accessors(value.AccessorList)), + ParameterSyntax value => SyntaxFactory.ParseMemberDeclaration( + $"public {value.Type} {value.Identifier} {{ get; init; }}" + )!, + VariableDeclaratorSyntax value + when value.Parent?.Parent is FieldDeclarationSyntax field => field.WithDeclaration( + field.Declaration.WithVariables( + SyntaxFactory.SingletonSeparatedList( + field.Modifiers.Any(SyntaxKind.ConstKeyword) + ? value + : value.WithInitializer(null) + ) + ) + ), + VariableDeclaratorSyntax value + when value.Parent?.Parent is EventFieldDeclarationSyntax field => + field.WithDeclaration( + field.Declaration.WithVariables( + SyntaxFactory.SingletonSeparatedList(value.WithInitializer(null)) + ) + ), + _ => node, + }; + signature = signature switch + { + TypeDeclarationSyntax type => type.WithMembers(default) + .WithOpenBraceToken(default) + .WithCloseBraceToken(default) + .WithSemicolonToken(default), + EnumDeclarationSyntax type => type.WithMembers(default) + .WithOpenBraceToken(default) + .WithCloseBraceToken(default) + .WithSemicolonToken(default), + _ => signature, + }; + return Clean(signature); + } + + private static string Clean(SyntaxNode node) => + node.ReplaceTokens(node.DescendantTokens(), (_, token) => token.WithoutTrivia()) + .WithoutTrivia() + .NormalizeWhitespace() + .ToFullString(); + + private static AccessorListSyntax Accessors(AccessorListSyntax? list) => + list is null + ? SyntaxFactory.AccessorList( + SyntaxFactory.SingletonList( + SyntaxFactory + .AccessorDeclaration(SyntaxKind.GetAccessorDeclaration) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)) + ) + ) + : list.WithAccessors( + SyntaxFactory.List( + list.Accessors.Select(accessor => + accessor + .WithBody(null) + .WithExpressionBody(null) + .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken)) + ) + ) + ); + + public static Documentation Documentation(SyntaxNode node) + { + var comments = node.GetLeadingTrivia() + .Where(trivia => + trivia.HasStructure && trivia.GetStructure() is DocumentationCommentTriviaSyntax + ) + .Select(trivia => trivia.ToFullString()); + var xml = string.Join("\n", comments); + xml = DocumentationPrefix().Replace(xml, ""); + xml = BlockCommentBoundary().Replace(xml, ""); + xml = BlockCommentLine().Replace(xml, ""); + var root = XElement.Parse("" + xml + ""); + string Read(string name) => string.Join("\n\n", root.Elements(name).Select(Render)).Trim(); + NamedDocumentation[] Named(string name) => + root.Elements(name) + .Select(element => new NamedDocumentation( + element.Attribute("name")?.Value ?? "", + Render(element).Trim() + )) + .ToArray(); + return new Documentation( + Read("summary"), + Read("remarks"), + Read("returns"), + Named("param"), + Named("typeparam") + ); + } + + private static string Render(XNode node) => + node switch + { + XText text => Whitespace() + .Replace(text.Value, " ") + .Replace("<", "<") + .Replace(">", ">"), + XElement element when element.Name.LocalName is "paramref" or "typeparamref" => "`" + + element.Attribute("name")?.Value + + "`", + XElement element when element.Name.LocalName == "see" => "`" + + ( + element.Attribute("cref")?.Value + ?? element.Attribute("langword")?.Value + ?? element.Value + ) + + "`", + XElement element when element.Name.LocalName == "c" => "`" + element.Value + "`", + XElement element when element.Name.LocalName == "code" => "\n\n```csharp\n" + + element.Value.Trim() + + "\n```\n\n", + XElement element when element.Name.LocalName == "para" => "\n\n" + + string.Concat(element.Nodes().Select(Render)).Trim() + + "\n\n", + XElement element when element.Name.LocalName == "item" => "\n- " + + string.Concat(element.Nodes().Select(Render)).Trim(), + XElement element => string.Concat(element.Nodes().Select(Render)), + _ => "", + }; + + private static string Frontmatter(string title, string permalink) => + "---\nlayout: layouts/api.njk\ntitle: " + + JsonSerializer.Serialize(title) + + "\nlang: en\npermalink: " + + permalink + + "\ntemplateEngineOverride: md\n---\n\n"; + + private static string Cell(string value) => value.Replace("|", "\\|").Replace("\n", " "); + + private static void AppendDocumentation( + StringBuilder builder, + Documentation docs, + Parameter[] parameters + ) + { + if (docs.Summary.Length > 0) + builder.AppendLine(docs.Summary).AppendLine(); + if (parameters.Length > 0) + { + builder + .AppendLine("| Parameter | Type | Default | Description |") + .AppendLine("| --- | --- | --- | --- |"); + foreach (var parameter in parameters) + builder.AppendLine( + $"| `{parameter.Name}` | `{Cell(parameter.Type)}` | {(parameter.Default is null ? "Required" : "`" + Cell(parameter.Default) + "`")} | {Cell(docs.Parameters.FirstOrDefault(item => item.Name == parameter.Name)?.Description ?? "")} |" + ); + builder.AppendLine(); + } + foreach (var parameter in docs.TypeParameters) + builder.AppendLine($"**Type parameter `{parameter.Name}`:** {parameter.Description}\n"); + if (docs.Returns.Length > 0) + builder.AppendLine("**Returns:** " + docs.Returns).AppendLine(); + if (docs.Remarks.Length > 0) + builder.AppendLine("**Remarks:** " + docs.Remarks).AppendLine(); + } + + public static string Markdown(ApiType type) + { + var builder = new StringBuilder(Frontmatter(type.DisplayName, type.Url)); + builder.AppendLine( + $"[All API types](/api/reference/) · `{type.Project}` · [View source]({type.Source.Url})\n" + ); + builder.AppendLine("```csharp").AppendLine(type.Signature).AppendLine("```\n"); + AppendDocumentation(builder, type.Docs, type.Parameters); + if (type.Members.Length > 0) + { + builder.AppendLine("## Members\n"); + foreach (var member in type.Members) + builder.AppendLine($"- [{member.Name}](#{member.Anchor})"); + builder.AppendLine(); + } + foreach (var member in type.Members) + { + builder.AppendLine( + $"

{System.Net.WebUtility.HtmlEncode(member.Name)}

\n" + ); + builder.AppendLine("```csharp").AppendLine(member.Signature).AppendLine("```\n"); + AppendDocumentation(builder, member.Docs, member.Parameters); + builder.AppendLine( + $"[View source: {member.Source.File}:{member.Source.Line}]({member.Source.Url})\n" + ); + } + return builder.ToString(); + } + + public static string IndexMarkdown(ApiIndex api) + { + var builder = new StringBuilder(Frontmatter("Source API reference", "/api/reference/")); + builder.AppendLine( + $"Explore {api.Types.Length} public types and {api.Types.Sum(type => type.Members.Length)} members, exported directly from the C# source and XML documentation.\n" + ); + builder.AppendLine( + "[Download the API index](/api/reference/api.json) · [JSON schema](/api/reference/schema.json)\n" + ); + foreach (var project in api.Projects) + { + builder.AppendLine("## " + project + "\n"); + foreach (var type in api.Types.Where(type => type.Project == project)) + builder.AppendLine( + $"- [`{type.DisplayName}`]({type.Url}){(type.Docs.Summary.Length == 0 ? "" : " — " + type.Docs.Summary.Split('\n')[0])}" + ); + builder.AppendLine(); + } + return builder.ToString(); + } +} diff --git a/Website/tools/ApiDocs/README.md b/Website/tools/ApiDocs/README.md new file mode 100644 index 00000000..adda4b5a --- /dev/null +++ b/Website/tools/ApiDocs/README.md @@ -0,0 +1,43 @@ +# Source API exporter + +`npm run generate-api` runs this .NET 9 command-line tool using Roslyn 4.8.0, +matching the analyzer project's Roslyn version. Only this small project needs +restoring; the application, sample projects, and their analyzers are not built. + +The exporter reads the actual C# files in RestClient.Net, Outcome, both generator +projects, and Exhaustion. Roslyn syntax and semantic symbols supply public types, +nested types, partial declarations, members, delegate signatures, positional record +properties, parameters, constraints, defaults, and overload identities. Private and +internal declarations (including public children of internal types) stay hidden. +Signatures preserve source aliases and expressions; compiler-generated record +helpers and inherited members are not duplicated as declared API. + +XML summaries, remarks, parameter/type parameter descriptions, and return prose +become both JSON data and Markdown. Every declaration carries its repository file, +line, and source URL. The output has no timestamp, absolute local paths, or machine +metadata; repeating an export with identical inputs is byte-for-byte deterministic. + +Generated files live under `src/api/reference/`. `api.json` follows the versioned +`schema.json` contract. Type pages use `layouts/api.njk`, the site's shared prose +styles, and `templateEngineOverride: md` so code and XML comments containing +`{{ expressions }}` cannot be evaluated as Nunjucks templates. Member anchors derive +from Roslyn documentation IDs, keeping overloads distinct and documentation-only +edits stable. Curated `/api/` guides are ordinary Markdown and are not overwritten. + +```sh +node scripts/generate-api-docs.js +node scripts/generate-api-docs.js --source-ref COMMIT_SHA +node --test tests-node/api-export.test.js +``` + +For isolated fixtures, pass `--root PATH --projects ProjectOne,ProjectTwo --output +PATH`. The output directory is exporter-owned: stale generated Markdown is removed +on each run. Source syntax errors fail generation. The small tool restores matching +metadata packages for Urls, Microsoft.Extensions.Http, and Microsoft.OpenApi so +public signature symbols resolve correctly, and supplies the standard SDK implicit +global imports enabled by the repository. No product build or execution is needed. +Displayed signatures retain the exact source-level type names and aliases. + +Exports use C# 12 parsing with the supplied source files; project-specific conditional +compilation and source generators are not evaluated. When a new external type enters +a public signature, add its matching metadata package to this tool as well. diff --git a/Website/tools/ApiDocs/schema.json b/Website/tools/ApiDocs/schema.json new file mode 100644 index 00000000..c59271b8 --- /dev/null +++ b/Website/tools/ApiDocs/schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://restclient.net/api/reference/schema.json", + "title": "RestClient.Net source API reference", + "type": "object", + "required": ["schemaVersion", "sourceRef", "projects", "types"], + "properties": { + "schemaVersion": { "const": 1 }, + "sourceRef": { "type": "string" }, + "projects": { "type": "array", "items": { "type": "string" } }, + "types": { "type": "array", "items": { "$ref": "#/$defs/type" } } + }, + "$defs": { + "source": { + "type": "object", + "required": ["file", "line", "url"], + "properties": { "file": { "type": "string" }, "line": { "type": "integer", "minimum": 1 }, "url": { "type": "string", "format": "uri" } } + }, + "parameter": { + "type": "object", + "required": ["name", "type", "modifiers", "default"], + "properties": { "name": { "type": "string" }, "type": { "type": "string" }, "modifiers": { "type": "string" }, "default": { "type": ["string", "null"] } } + }, + "namedDocumentation": { + "type": "object", "required": ["name", "description"], + "properties": { "name": { "type": "string" }, "description": { "type": "string" } } + }, + "documentation": { + "type": "object", "required": ["summary", "remarks", "returns", "parameters", "typeParameters"], + "properties": { + "summary": { "type": "string" }, "remarks": { "type": "string" }, "returns": { "type": "string" }, + "parameters": { "type": "array", "items": { "$ref": "#/$defs/namedDocumentation" } }, + "typeParameters": { "type": "array", "items": { "$ref": "#/$defs/namedDocumentation" } } + } + }, + "member": { + "type": "object", "required": ["id", "anchor", "name", "kind", "signature", "docs", "parameters", "source"], + "properties": { + "id": { "type": "string" }, "anchor": { "type": "string" }, "name": { "type": "string" }, "kind": { "type": "string" }, "signature": { "type": "string" }, + "docs": { "$ref": "#/$defs/documentation" }, "parameters": { "type": "array", "items": { "$ref": "#/$defs/parameter" } }, "source": { "$ref": "#/$defs/source" } + } + }, + "type": { + "type": "object", "required": ["id", "name", "displayName", "namespace", "project", "kind", "signature", "docs", "parameters", "source", "url", "members"], + "properties": { + "id": { "type": "string" }, "name": { "type": "string" }, "displayName": { "type": "string" }, "namespace": { "type": "string" }, "project": { "type": "string" }, "kind": { "type": "string" }, "signature": { "type": "string" }, + "docs": { "$ref": "#/$defs/documentation" }, "parameters": { "type": "array", "items": { "$ref": "#/$defs/parameter" } }, "source": { "$ref": "#/$defs/source" }, "url": { "type": "string" }, "members": { "type": "array", "items": { "$ref": "#/$defs/member" } } + } + } + } +} From 6f161e0102f8692ec34c729c2b6da280d0038fd0 Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:35:48 +1000 Subject: [PATCH 2/3] Serialize dotnet-backed website tests during SDK initialization --- Website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Website/package.json b/Website/package.json index 3c7746d9..cfd59ba1 100644 --- a/Website/package.json +++ b/Website/package.json @@ -7,7 +7,7 @@ "generate-api": "node scripts/generate-api-docs.js", "dev": "npm run generate-api && eleventy --serve --port=4173", "build": "npm run generate-api && npm run clean && eleventy && node scripts/check-site.js", - "test:unit": "node --test tests-node/*.test.js", + "test:unit": "node --test --test-concurrency=1 tests-node/*.test.js", "test": "playwright test", "clean": "node -e \"require('node:fs').rmSync('_site', {recursive: true, force: true})\"" }, From eca02c85f0009a822fbe421fe1a3d89be41b2f0c Mon Sep 17 00:00:00 2001 From: Christian Findlay <16697547+MelbourneDeveloper@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:43:56 +1000 Subject: [PATCH 3/3] Decode sitemap XML once and anchor page title assertions --- Website/scripts/check-site.js | 2 +- Website/tests-node/site-guards.test.js | 8 ++++++++ Website/tests/fixtures.js | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Website/scripts/check-site.js b/Website/scripts/check-site.js index d0cf1224..1eb54958 100644 --- a/Website/scripts/check-site.js +++ b/Website/scripts/check-site.js @@ -27,7 +27,7 @@ function elements(document) { const attribute = (node, name) => node.attrs?.find(item => item.name === name)?.value; const content = node => node.nodeName === '#text' ? node.value : (node.childNodes ?? []).map(content).join(''); const routeFor = file => `/${file.replaceAll(path.sep, '/').replace(/index\.html$/, '')}`; -const decodeXml = value => value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>'); +const decodeXml = value => value.replace(/&(amp|quot|apos|lt|gt);/g, (_, entity) => ({ amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' })[entity]); export function cssInjectionErrors(source, filename = 'script.js') { const errors = []; diff --git a/Website/tests-node/site-guards.test.js b/Website/tests-node/site-guards.test.js index 2db4c007..0a0ea540 100644 --- a/Website/tests-node/site-guards.test.js +++ b/Website/tests-node/site-guards.test.js @@ -158,3 +158,11 @@ test('canonical, structured data, and crawl output regressions fail visibly', as assert.match(errors, /Sitemap omits/); assert.match(errors, /wrong sitemap/); }); + +test('sitemap URL validation decodes exactly one XML entity layer', async t => { + const site = await fixture(t); + await site.write('sitemap.xml', 'https://example.test/&lt;missing&gt;'); + const report = await site.run(); + assert.ok(report.errors.includes('Sitemap has a nonexistent page: https://example.test/<missing>')); + assert.ok(!report.errors.some(error => error.includes('https://example.test/'))); +}); diff --git a/Website/tests/fixtures.js b/Website/tests/fixtures.js index 0fed2da8..45e7d36c 100644 --- a/Website/tests/fixtures.js +++ b/Website/tests/fixtures.js @@ -11,7 +11,7 @@ export const test = base.extend({ await expect(page.locator('main')).toHaveCount(1); await expect(page.locator('h1')).toHaveCount(1); await expect(page.locator('html')).toHaveAttribute('lang', /^(en|zh)$/); - await expect(page).toHaveTitle(/RestClient\.Net/); + await expect(page).toHaveTitle(/^(?:.+ · )?RestClient\.Net$/); await expect(page.locator('meta[name="description"]')).toHaveAttribute('content', /\S/); await expect(page.locator('link[rel="canonical"]')).toHaveCount(1); await expect(page.locator('link[rel="stylesheet"]')).toHaveCount(1);