From 337559bcaf6dbb98ca58d80cd8f67b18ede0db23 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:42:47 +0200 Subject: [PATCH 01/10] build(repo): move to bun 1.4.2 Pin packageManager, the four setup-bun steps and the CONTRIBUTING requirement to 1.4.2. bun 1.4.2 reads and keeps the v1 text lockfile; the only lockfile change is the workspace version fields it syncs from package.json, with no resolution change. --- .github/workflows/ci.yml | 4 ++-- .github/workflows/docs.yml | 2 +- .github/workflows/release.yml | 2 +- CONTRIBUTING.md | 2 +- bun.lock | 26 +++++++++++++------------- package.json | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a836c8..ea70eda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - run: bun install --frozen-lockfile # Build first: plugins resolve @noctcore/eslint-utils via its emitted # dist/*.d.ts, so typecheck needs the build to have run. @@ -41,7 +41,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - run: bun install --frozen-lockfile # Tests read built dist/ too: plugins import @noctcore/eslint-utils, and # lint-meta-rules runs against the workspace plugins' builds. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e3dcbb2..4838f43 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -44,7 +44,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 # Astro 7 needs Node 22.12 or newer; the runner image ships an older one. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cef31db..d7af93e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,7 +39,7 @@ jobs: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 # Node 24 ships npm 11. Publishing uses npm trusted publishing (OIDC) only, # which needs npm 11.5.1 or later; there is no npm token to fall back to. - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c52d1e..5cc8922 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ exactly what is needed to act. ## Setup and the commands that matter -Requirements: [Bun](https://bun.sh) 1.3.14 (`packageManager` in the root `package.json`) and +Requirements: [Bun](https://bun.sh) 1.4.2 (`packageManager` in the root `package.json`) and Node 22 or newer (`engines`; the docs site needs 22.12 or newer). ```sh diff --git a/bun.lock b/bun.lock index 6c5ecf8..121a294 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ }, "packages/eslint-plugin-architecture": { "name": "@noctcore/eslint-plugin-architecture", - "version": "0.3.2", + "version": "0.3.4", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -33,7 +33,7 @@ }, "packages/eslint-plugin-async-safety": { "name": "@noctcore/eslint-plugin-async-safety", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -54,7 +54,7 @@ }, "packages/eslint-plugin-code-quality": { "name": "@noctcore/eslint-plugin-code-quality", - "version": "0.3.0", + "version": "0.3.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -75,7 +75,7 @@ }, "packages/eslint-plugin-contracts": { "name": "@noctcore/eslint-plugin-contracts", - "version": "0.7.0", + "version": "0.7.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -96,7 +96,7 @@ }, "packages/eslint-plugin-llm": { "name": "@noctcore/eslint-plugin-llm", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -117,7 +117,7 @@ }, "packages/eslint-plugin-monorepo": { "name": "@noctcore/eslint-plugin-monorepo", - "version": "0.2.2", + "version": "0.2.4", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -138,7 +138,7 @@ }, "packages/eslint-plugin-observability": { "name": "@noctcore/eslint-plugin-observability", - "version": "0.3.2", + "version": "0.3.4", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -159,7 +159,7 @@ }, "packages/eslint-plugin-prisma": { "name": "@noctcore/eslint-plugin-prisma", - "version": "0.5.0", + "version": "0.5.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -180,7 +180,7 @@ }, "packages/eslint-plugin-react": { "name": "@noctcore/eslint-plugin-react", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -201,7 +201,7 @@ }, "packages/eslint-plugin-rsc": { "name": "@noctcore/eslint-plugin-rsc", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -222,7 +222,7 @@ }, "packages/eslint-plugin-security": { "name": "@noctcore/eslint-plugin-security", - "version": "0.4.0", + "version": "0.4.2", "dependencies": { "@noctcore/eslint-utils": "^0.1.1", "@typescript-eslint/utils": "^8.61.1", @@ -261,7 +261,7 @@ }, "packages/eslint-utils": { "name": "@noctcore/eslint-utils", - "version": "0.1.1", + "version": "0.1.2", "dependencies": { "@typescript-eslint/utils": "^8.61.1", }, @@ -277,7 +277,7 @@ }, "packages/lint-meta-rules": { "name": "@noctcore/lint-meta-rules", - "version": "0.5.0", + "version": "0.6.2", "dependencies": { "@noctcore/eslint-plugin-contracts": "^0.7.0", "@noctcore/eslint-plugin-prisma": "^0.5.0", diff --git a/package.json b/package.json index 6c8997d..7f5e889 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@noctcore/eslint-plugins", "private": true, "type": "module", - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.2", "engines": { "node": ">=22" }, From 961fd1a5f95bbb29ddfc852349682c31589fdf5a Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:43:00 +0200 Subject: [PATCH 02/10] chore(site): disable astro telemetry in every astro script Astro only skips its usage telemetry on CI. Set ASTRO_TELEMETRY_DISABLED=1 on each astro invocation so local dev, build, preview and sync send nothing too. bun runs scripts through its own shell on Windows, so the prefix works there. --- site/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/package.json b/site/package.json index ba28812..8ee6a0b 100644 --- a/site/package.json +++ b/site/package.json @@ -5,10 +5,10 @@ "type": "module", "scripts": { "sync": "bun scripts/sync.ts", - "dev": "bun run sync && astro dev", - "docs:build": "bun run sync && astro build && bun scripts/check-build.ts", - "preview": "astro preview", - "typecheck": "bun run sync && astro sync && tsc --noEmit", + "dev": "bun run sync && ASTRO_TELEMETRY_DISABLED=1 astro dev", + "docs:build": "bun run sync && ASTRO_TELEMETRY_DISABLED=1 astro build && bun scripts/check-build.ts", + "preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview", + "typecheck": "bun run sync && ASTRO_TELEMETRY_DISABLED=1 astro sync && tsc --noEmit", "test": "bun test scripts" }, "dependencies": { From 6b4eb26b8b76a23a1ecac141a9e97b413ee402a3 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 03/10] feat(site): adopt the noctcore theme with the nocturne preset Replace theme.css with the noctcore docs theme (base.css, components.css and presets/nocturne.css, in that load order) plus site.css for what the theme does not cover. The preset imports its own fonts, so the Head override that only loaded them goes. Expressive Code takes Nocturne's code themes and keeps the verdict plugin. --- site/astro.config.mjs | 10 +- site/ec.config.mjs | 5 +- site/src/components/Head.astro | 9 - site/src/styles/noctcore/base.css | 140 ++++ site/src/styles/noctcore/components.css | 793 ++++++++++++++++++ site/src/styles/noctcore/expressive-code.mjs | 102 +++ site/src/styles/noctcore/presets/nocturne.css | 169 ++++ site/src/styles/site.css | 39 + site/src/styles/theme.css | 358 -------- 9 files changed, 1255 insertions(+), 370 deletions(-) delete mode 100644 site/src/components/Head.astro create mode 100644 site/src/styles/noctcore/base.css create mode 100644 site/src/styles/noctcore/components.css create mode 100644 site/src/styles/noctcore/expressive-code.mjs create mode 100644 site/src/styles/noctcore/presets/nocturne.css create mode 100644 site/src/styles/site.css delete mode 100644 site/src/styles/theme.css diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 770a2fa..08c3b88 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -20,9 +20,15 @@ export default defineConfig({ 'Focused ESLint plugins for architecture and correctness conventions generic linters cannot see: cross-file boundaries, IO contracts, and code that compiles but bites in production.', favicon: '/favicon.svg', logo: { src: './src/assets/mark.svg', alt: '' }, - customCss: ['./src/styles/theme.css'], + // The noctcore docs theme in its load order (tokens, pieces, preset), then + // this site's own rules. The Nocturne preset imports its fonts itself. + customCss: [ + './src/styles/noctcore/base.css', + './src/styles/noctcore/components.css', + './src/styles/noctcore/presets/nocturne.css', + './src/styles/site.css', + ], components: { - Head: './src/components/Head.astro', Footer: './src/components/Footer.astro', }, social: [ diff --git a/site/ec.config.mjs b/site/ec.config.mjs index e5d4deb..bde327f 100644 --- a/site/ec.config.mjs +++ b/site/ec.config.mjs @@ -1,9 +1,11 @@ import { defineEcConfig } from '@astrojs/starlight/expressive-code'; +import { noctcoreCodeConfig } from './src/styles/noctcore/expressive-code.mjs'; + /** * The rule docs mark every example ` ```ts bad ` or ` ```ts good `, and * scripts/sync.ts carries that through as `verdict=bad|good` fence meta. This - * plugin turns it into a class on the rendered block so theme.css can give the + * plugin turns it into a class on the rendered block so components.css can give the * two a different frame, label colour and marker. Rendered identically, a reader * cannot tell which snippet is the one not to write. */ @@ -24,5 +26,6 @@ function pluginVerdict() { } export default defineEcConfig({ + ...noctcoreCodeConfig('nocturne'), plugins: [pluginVerdict()], }); diff --git a/site/src/components/Head.astro b/site/src/components/Head.astro deleted file mode 100644 index dde62a8..0000000 --- a/site/src/components/Head.astro +++ /dev/null @@ -1,9 +0,0 @@ ---- -import Default from '@astrojs/starlight/components/Head.astro'; -// Space Grotesk for headings and the wordmark, JetBrains Mono for code. Both -// self-hosted, so the site makes no third-party font requests. -import '@fontsource-variable/space-grotesk'; -import '@fontsource-variable/jetbrains-mono'; ---- - - diff --git a/site/src/styles/noctcore/base.css b/site/src/styles/noctcore/base.css new file mode 100644 index 0000000..e723b46 --- /dev/null +++ b/site/src/styles/noctcore/base.css @@ -0,0 +1,140 @@ +/* noctcore docs base.css + The token contract. Every preset sets the same --nc-* tokens; this file + maps them onto Starlight 0.42's --sl-* variables once, so a preset never + names a Starlight variable for colour. Starlight's own CSS sits in cascade + layers and customCss is unlayered, so these rules win without !important. + The brand constants are decorative only (the crescent rule, the hero + button, the wordmark): never body text. Load first in customCss. */ + +:root, +::backdrop { + /* Brand constants */ + --nc-violet: #8b7cff; + --nc-cyan: #4dd0e1; + --nc-night: #0b1020; + --nc-gradient: linear-gradient(90deg, var(--nc-violet), var(--nc-cyan)); + + /* Type and measure */ + --sl-font: var(--nc-font-body); + --sl-font-mono: var(--nc-font-mono); + --sl-content-width: var(--nc-measure); + + /* Starlight's grey ramp is named for dark mode; the --nc names say what each step does */ + --sl-color-white: var(--nc-ink); + --sl-color-gray-1: var(--nc-ink-2); + --sl-color-gray-2: var(--nc-text); + --sl-color-gray-3: var(--nc-muted); + --sl-color-gray-4: var(--nc-faint); + --sl-color-gray-5: var(--nc-line); + --sl-color-gray-6: var(--nc-surface); + --sl-color-gray-7: var(--nc-raised); + --sl-color-black: var(--nc-bg); + + /* Accent */ + --sl-color-accent-low: var(--nc-accent-soft); + --sl-color-accent: var(--nc-accent); + --sl-color-accent-high: var(--nc-accent-strong); + + /* Aliases Starlight switches per mode. Mapped once here, so the presets hold all the mode logic */ + --sl-color-text: var(--nc-text); + --sl-color-text-accent: var(--nc-link); + --sl-color-text-invert: var(--nc-on-link); + --sl-color-bg: var(--nc-bg); + --sl-color-bg-nav: var(--nc-surface); + --sl-color-bg-sidebar: var(--nc-surface); + --sl-color-bg-inline-code: var(--nc-code-inline); + --sl-color-bg-accent: var(--nc-link); + --sl-color-hairline-light: var(--nc-line); + --sl-color-hairline: var(--nc-line-soft); + --sl-color-hairline-shade: var(--nc-bg); + + /* Signals: Starlight hues by role (blue note, purple tip, orange caution, red danger, green success) */ + --sl-color-blue-low: var(--nc-note-bg); + --sl-color-blue: var(--nc-note-edge); + --sl-color-blue-high: var(--nc-note-ink); + --sl-color-purple-low: var(--nc-tip-bg); + --sl-color-purple: var(--nc-tip-edge); + --sl-color-purple-high: var(--nc-tip-ink); + --sl-color-orange-low: var(--nc-caution-bg); + --sl-color-orange: var(--nc-caution-edge); + --sl-color-orange-high: var(--nc-caution-ink); + --sl-color-red-low: var(--nc-danger-bg); + --sl-color-red: var(--nc-danger-edge); + --sl-color-red-high: var(--nc-danger-ink); + --sl-color-green-low: var(--nc-success-bg); + --sl-color-green: var(--nc-success-edge); + --sl-color-green-high: var(--nc-success-ink); +} + +/* Shared structure: the wordmark, the crescent rule, the hero button, radius, focus. */ +.site-title { + font-family: var(--sl-font-mono); + font-weight: 600; + letter-spacing: -0.02em; + color: var(--sl-color-white); +} + +.nc-wordmark { + position: relative; +} + +.nc-wordmark::after { + content: ''; + position: absolute; + left: 0; + bottom: -0.3rem; + width: 1.75rem; + height: 2px; + border-radius: 2px; + background: var(--nc-gradient); +} + +h1#_top, +.hero h1, +.sl-markdown-content h2, +.sl-markdown-content h3 { + font-family: var(--nc-font-display); +} + +.hero h1::after { + content: ''; + display: block; + width: 6rem; + height: 0.25rem; + margin-top: 1rem; + border-radius: 999px; + background: var(--nc-gradient); +} + +@media (max-width: 49.99rem) { + .hero h1::after { + margin-inline: auto; + } +} + +.hero .sl-link-button.primary { + background: var(--nc-gradient); + border-color: transparent; + color: var(--nc-night); +} + +.starlight-aside, +.sl-link-card, +.card, +.pagination-links a { + border-radius: var(--nc-radius); +} + +.expressive-code { + --ec-brdRad: var(--nc-radius); +} + +:focus-visible { + outline: 2px solid var(--sl-color-accent); + outline-offset: 2px; +} + +.expressive-code pre, +code { + font-variant-ligatures: none; +} diff --git a/site/src/styles/noctcore/components.css b/site/src/styles/noctcore/components.css new file mode 100644 index 0000000..63e2558 --- /dev/null +++ b/site/src/styles/noctcore/components.css @@ -0,0 +1,793 @@ +/* noctcore docs components.css + The site pieces the noctcore docs sites share. Every colour comes from + Starlight's --sl-* variables or the preset's --nc-* tokens, so each piece + follows the preset in dark and light. No bare element selectors: it + only touches the classes below and Starlight's own component classes. + + Load order in customCss: base.css, components.css, presets/.css, + then the site's own CSS. The preset comes after this file so it can tune a + component. + Markup for each piece is in COMPONENTS.md. The cells of the grid pieces + (facts, stats, the fit pair, the split bar) reset their margin: Starlight's + Markdown content puts a top margin between any two sibling elements. */ + +/* --------------------------------------------------------------------------- + Labels. The small line over facts, stats and stacked table cells. The + preset sets the look through --nc-label-*. +--------------------------------------------------------------------------- */ +.nc-label, +.nc-facts dt, +.nc-stats dt, +.package-card-version { + font-family: var(--nc-label-font); + font-size: var(--nc-label-size); + font-weight: var(--nc-label-weight); + letter-spacing: var(--nc-label-tracking); + text-transform: var(--nc-label-case); + line-height: 1.3; + color: var(--sl-color-gray-3); +} + +/* A page's opening paragraph: the showcase-kit lead, and the rule summary that + sync.ts writes where the doc has its "> summary" blockquote. */ +.sl-markdown-content .nc-lead, +.nc-lead { + font-size: var(--sl-text-lg); + line-height: 1.6; + color: var(--sl-color-gray-2); + text-wrap: pretty; +} + +/* Starlight styles inline code only inside the Markdown body; a lead can sit above it. */ +.nc-lead code { + padding: 0.125rem 0.375rem; + background-color: var(--sl-color-bg-inline-code); + font-family: var(--__sl-font-mono); + font-size: 0.875em; +} + +/* --------------------------------------------------------------------------- + Site title: the wordmark (its crescent rule is in base.css), then the + product. Markup in COMPONENTS.md (a SiteTitle override). +--------------------------------------------------------------------------- */ +.site-title .nc-sep { + color: var(--sl-color-gray-4); + font-weight: 400; +} + +.site-title .nc-product { + overflow: hidden; + color: var(--sl-color-gray-2); + font-weight: 500; + text-overflow: ellipsis; +} + +/* A PageTitle override drops Starlight's scoped h1 styles; these are the same + values, at zero specificity so a preset can still restyle the title. */ +:where(.sl-container > h1#_top) { + margin-top: 1rem; + font-size: var(--sl-text-h1); + line-height: var(--sl-line-height-headings); + font-weight: 600; + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Version pill (showcase-kit header, links to the changelog). +--------------------------------------------------------------------------- */ +.nc-version { + display: inline-flex; + align-items: center; + height: 1.6rem; + padding-inline: 0.55rem; + border-radius: calc(var(--nc-radius) * 2 + 0.25rem); + font: 600 0.75rem/1 var(--__sl-font-mono); + color: var(--sl-color-text-accent); + background: var(--sl-color-accent-low); + text-decoration: none; + white-space: nowrap; +} + +.nc-version:hover { + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Window-frame figure: a raw capture in the frame showcase-kit draws, on the + preset's --nc-motif backdrop. Sizes are relative to the frame's width, so it + scales like the rendered image. Set --nc-win on .nc-window when a window is + narrower than its frame (the hero stack uses 74). +--------------------------------------------------------------------------- */ +.nc-shot { + margin: 0; +} + +.nc-shot figcaption { + margin-top: 0.75rem; + font-size: var(--sl-text-sm); + line-height: 1.55; + color: var(--sl-color-gray-3); +} + +.nc-frame { + container-type: inline-size; + padding: 4.5%; + border-radius: calc(var(--nc-radius) * 1.25); + background: var(--nc-motif); +} + +.nc-window { + --nc-u: calc(var(--nc-win, 100) * 0.01cqi); + position: relative; + overflow: hidden; + border-radius: calc(var(--nc-u) * 0.97); + background: #0f1017; + box-shadow: + 0 calc(var(--nc-u) * 2.4) calc(var(--nc-u) * 5) calc(var(--nc-u) * -1) rgba(2, 4, 12, 0.65), + 0 0 0 1px rgba(255, 255, 255, 0.07); +} + +.nc-window > img { + display: block; + width: 100%; + height: auto; +} + +/* The title bar showcase-kit draws with frame.theme 'dark'. */ +.nc-titlebar { + position: relative; + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--nc-u) * 2.78); + background: #1b1c26; + border-bottom: 1px solid #0a0a10; +} + +.nc-lights { + position: absolute; + top: 50%; + left: var(--nc-u); + display: flex; + gap: calc(var(--nc-u) * 0.55); + transform: translateY(-50%); +} + +.nc-lights i { + width: calc(var(--nc-u) * 0.85); + height: calc(var(--nc-u) * 0.85); + border-radius: 50%; + background: #ff5f57; +} + +.nc-lights i:nth-child(2) { + background: #febc2e; +} + +.nc-lights i:nth-child(3) { + background: #28c840; +} + +.nc-wtitle { + font: 500 max(calc(var(--nc-u) * 0.95), 5px)/1 var(--sl-font-system, system-ui, sans-serif); + color: #a3a6b8; +} + +/* The hero stack: what `showcase hero` composes, minus its text. */ +.nc-banner { + position: relative; + container-type: inline-size; + width: 100%; + max-width: 34rem; + aspect-ratio: 5 / 4; + margin-inline: auto; + overflow: hidden; + border-radius: calc(var(--nc-radius) * 1.5); + background: var(--nc-motif); +} + +.nc-banner .nc-window { + --nc-win: 74; + position: absolute; + width: 74%; +} + +.nc-banner .nc-window:first-child { + top: 8%; + left: 4%; + transform: rotate(-5deg); +} + +.nc-banner .nc-window:last-child { + right: 3%; + bottom: 9%; + transform: rotate(3.5deg); +} + +/* --------------------------------------------------------------------------- + Cards. Starlight's LinkCard and Card, and the ESLint package cards, share one + surface: --nc-card-bg and --nc-card-border from the preset. +--------------------------------------------------------------------------- */ +.sl-link-card, +.card { + background-color: var(--nc-card-bg); + border-color: var(--nc-card-border); +} + +.package-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15.5rem, 1fr)); + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.package-grid .package-card { + margin: 0; +} + +.package-card a { + position: relative; + display: flex; + flex-direction: column; + gap: 0.45rem; + height: 100%; + padding: 1rem 1.1rem 0.95rem; + overflow: hidden; + border: 1px solid var(--nc-card-border); + border-radius: var(--nc-radius); + background: var(--nc-card-bg); + color: var(--sl-color-gray-2); + text-decoration: none; +} + +.package-card a::before { + content: ''; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: var(--nc-gradient); + opacity: 0; +} + +.package-card a:hover, +.package-card a:focus-visible { + border-color: var(--sl-color-accent); +} + +.package-card a:hover::before, +.package-card a:focus-visible::before { + opacity: 1; +} + +.package-card-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.package-card-short { + font-family: var(--nc-font-display); + font-size: var(--sl-text-lg); + font-weight: 600; + line-height: 1.2; + color: var(--sl-color-white); +} + +.package-card-name { + font: 500 var(--sl-text-2xs)/1.4 var(--__sl-font-mono); + color: var(--sl-color-gray-3); + overflow-wrap: anywhere; +} + +.package-card-desc { + font-size: var(--sl-text-sm); + line-height: 1.5; +} + +.package-card-meta { + font-size: var(--sl-text-xs); + font-variant-numeric: tabular-nums; + color: var(--sl-color-gray-3); +} + +/* How a package's rules split across its preset: on, registered off, not listed. */ +.nc-split { + display: flex; + gap: 2px; + height: 0.3125rem; + margin-top: auto; + overflow: hidden; + border-radius: 999px; +} + +.nc-split i { + min-width: 3px; + margin: 0; + background: var(--sl-color-gray-5); +} + +.nc-split .on { + background: var(--sl-color-green); +} + +.nc-split .off { + background: var(--sl-color-gray-4); +} + +.nc-split .harness { + background: var(--sl-color-accent); +} + +/* --------------------------------------------------------------------------- + Badges. Rule state, as scannable chips: the preset severity, autofix, + suggestions, required options, type information, deprecated. The .pill + classes the ESLint site's tables already emit get the same look. +--------------------------------------------------------------------------- */ +:is(.nc-badge, .pill) { + --nc-badge-bg: transparent; + --nc-badge-ink: var(--sl-color-gray-2); + --nc-badge-edge: var(--sl-color-gray-4); + display: inline-flex; + align-items: center; + gap: 0.3em; + padding: 0.0625rem 0.45rem; + border: 1px solid color-mix(in srgb, var(--nc-badge-edge) 55%, transparent); + border-radius: calc(var(--nc-radius) * 0.5 + 0.1875rem); + background: var(--nc-badge-bg); + color: var(--nc-badge-ink); + font: 600 var(--sl-text-2xs)/1.5 var(--__sl-font-mono); + white-space: nowrap; +} + +:is(.nc-badge--on, .pill-error) { + --nc-badge-bg: var(--sl-color-green-low); + --nc-badge-ink: var(--sl-color-green-high); + --nc-badge-edge: var(--sl-color-green); +} + +:is(.nc-badge--off, .pill-off) { + --nc-badge-ink: var(--sl-color-gray-2); + --nc-badge-edge: var(--sl-color-gray-4); +} + +:is(.nc-badge--out, .pill-not-listed) { + --nc-badge-ink: var(--sl-color-gray-3); + border-style: dashed; +} + +:is(.nc-badge--fix) { + --nc-badge-bg: var(--sl-color-purple-low); + --nc-badge-ink: var(--sl-color-purple-high); + --nc-badge-edge: var(--sl-color-purple); +} + +:is(.nc-badge--suggest) { + --nc-badge-bg: var(--sl-color-blue-low); + --nc-badge-ink: var(--sl-color-blue-high); + --nc-badge-edge: var(--sl-color-blue); +} + +:is(.nc-badge--options) { + --nc-badge-bg: var(--sl-color-orange-low); + --nc-badge-ink: var(--sl-color-orange-high); + --nc-badge-edge: var(--sl-color-orange); +} + +:is(.nc-badge--types) { + --nc-badge-bg: var(--sl-color-blue-low); + --nc-badge-ink: var(--sl-color-blue-high); + --nc-badge-edge: var(--sl-color-blue); +} + +:is(.nc-badge--harness, .pill-harness) { + --nc-badge-bg: var(--sl-color-accent-low); + --nc-badge-ink: var(--sl-color-text-accent); + --nc-badge-edge: var(--sl-color-accent); +} + +:is(.nc-badge--deprecated, .pill-deprecated) { + --nc-badge-bg: var(--sl-color-red-low); + --nc-badge-ink: var(--sl-color-red-high); + --nc-badge-edge: var(--sl-color-red); +} + +/* "opt-in" beside an off or not-listed preset: the rule is yours to turn on. */ +.nc-optin { + margin-inline-start: 0.35rem; + font-size: var(--sl-text-2xs); + color: var(--sl-color-gray-3); + white-space: nowrap; +} + +.deprecated-note { + display: block; + margin-top: 0.25rem; + font-size: var(--sl-text-xs); +} + +/* --------------------------------------------------------------------------- + Rule facts: the strip under a rule's title. sync.ts writes it in place of + the "Recommended preset: ... · Autofix: ..." line. Same six cells on every + rule page, so the eye finds a fact in the same place each time. +--------------------------------------------------------------------------- */ +.sl-markdown-content .nc-facts, +.nc-facts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7.25rem, 1fr)); + margin-inline: 0; + border-block: 1px solid var(--sl-color-hairline-light); +} + +.nc-facts > div { + display: grid; + align-content: start; + gap: 0.3rem; + margin: 0; + padding: 0.7rem 0.9rem 0.8rem 0; +} + +.nc-facts dt { + margin: 0; +} + +.nc-facts dd { + margin: 0; + padding: 0; + font-size: var(--sl-text-sm); + line-height: 1.4; + color: var(--sl-color-white); +} + +.nc-facts dd.is-no { + color: var(--sl-color-gray-3); +} + +.nc-facts dd code { + font-size: var(--sl-text-xs); +} + +.nc-facts-note { + margin-inline-start: 0.35rem; + white-space: nowrap; + font: 500 var(--sl-text-2xs) var(--__sl-font-mono); + color: var(--sl-color-gray-3); +} + +/* A rule title reads as namespace plus name; the PageTitle override splits it. */ +.nc-title-ns { + display: block; + margin-bottom: 0.2em; + font: 500 max(0.875rem, 0.36em)/1.3 var(--__sl-font-mono); + letter-spacing: 0; + color: var(--sl-color-gray-3); +} + +/* --------------------------------------------------------------------------- + Incorrect and Correct examples. ec.config.mjs puts .verdict-bad or + .verdict-good on the frame. Each frame restyles itself by setting Expressive + Code's own variables: a tinted title bar, a coloured frame edge, a verdict + mark before the title. Three signals, never colour alone. +--------------------------------------------------------------------------- */ +.expressive-code .verdict-bad { + --nc-verdict: var(--sl-color-red); + --nc-verdict-bg: var(--sl-color-red-low); + --nc-verdict-ink: var(--sl-color-red-high); + --nc-verdict-mark: '\2715'; +} + +.expressive-code .verdict-good { + --nc-verdict: var(--sl-color-green); + --nc-verdict-bg: var(--sl-color-green-low); + --nc-verdict-ink: var(--sl-color-green-high); + --nc-verdict-mark: '\2713'; +} + +.expressive-code :is(.verdict-bad, .verdict-good) { + --ec-brdCol: color-mix(in srgb, var(--nc-verdict) 55%, transparent); + --ec-frm-edTabBarBg: var(--nc-verdict-bg); + --ec-frm-edTabBarBrdBtmCol: color-mix(in srgb, var(--nc-verdict) 55%, transparent); + --ec-frm-edActTabBg: transparent; + --ec-frm-edActTabFg: var(--nc-verdict-ink); + --ec-frm-edActTabIndTopCol: transparent; +} + +.expressive-code :is(.verdict-bad, .verdict-good) .header .title { + display: inline-flex; + align-items: center; + gap: 0.55rem; + font-weight: 600; +} + +.expressive-code :is(.verdict-bad, .verdict-good) .header .title::before { + content: var(--nc-verdict-mark); + display: inline-grid; + place-items: center; + width: 1.2rem; + height: 1.2rem; + border-radius: 50%; + background: var(--nc-verdict); + color: var(--sl-color-black); + font-size: 0.6875rem; + font-weight: 800; +} + +.expressive-code :is(.verdict-bad, .verdict-good) pre { + box-shadow: inset 3px 0 0 var(--nc-verdict); +} + +.expressive-code :is(.verdict-bad, .verdict-good) + * { + margin-top: 0.75rem; +} + +/* --------------------------------------------------------------------------- + Tables. The ESLint rule tables and the showcase-kit config reference. Wide + screens get a dense table; below 40rem each row becomes a stacked entry + (name, text, then the badges), labelled from data-label on each cell. +--------------------------------------------------------------------------- */ +.rule-table-wrap { + overflow-x: auto; +} + +.sl-markdown-content :is(.rule-table, .nc-ref) { + display: table; + width: 100%; + font-size: var(--sl-text-sm); + border-collapse: collapse; +} + +.sl-markdown-content :is(.rule-table, .nc-ref) :is(th, td) { + vertical-align: top; +} + +.sl-markdown-content :is(.rule-table, .nc-ref) thead th { + font-family: var(--nc-label-font); + font-size: var(--nc-label-size); + font-weight: var(--nc-label-weight); + letter-spacing: var(--nc-label-tracking); + text-transform: var(--nc-label-case); + color: var(--sl-color-gray-3); + white-space: nowrap; +} + +/* The all-rules index has no table of contents, so it takes a wider column. */ +main:has(.rule-table-all) { + --sl-content-width: 68rem; +} + +.sl-markdown-content .rule-table td:first-child { + width: 30%; +} + +.sl-markdown-content .rule-table td:first-child code { + overflow-wrap: break-word; + background: none; + padding: 0; + font-weight: 600; +} + +/* A rule id as two lines: the namespace, quiet, over the rule's name. Both stay + in one code element, so find-in-page still matches the full id. */ +.nc-id-ns { + display: block; + font-size: 0.85em; + font-weight: 400; + color: var(--sl-color-gray-3); +} + +/* A package's rows start with a group row: the package name and its count. */ +.sl-markdown-content .rule-table .nc-group th { + padding-top: 1.4rem; + border-bottom: 1px solid var(--sl-color-gray-4); + font-family: var(--nc-font-display); + font-size: var(--sl-text-base); + font-weight: 600; + color: var(--sl-color-white); + text-align: start; +} + +.nc-group-count { + margin-inline-start: 0.5rem; + font: 500 var(--sl-text-2xs) var(--__sl-font-mono); + color: var(--sl-color-gray-3); +} + +.sl-markdown-content .nc-ref td:first-child, +.sl-markdown-content .nc-ref td:last-child { + white-space: nowrap; +} + +.sl-markdown-content .nc-ref .sl-badge + .sl-badge { + margin-inline-start: 0.25rem; +} + +@media (max-width: 40rem) { + .rule-table-wrap { + overflow: visible; + } + + .sl-markdown-content :is(.rule-table, .nc-ref), + .sl-markdown-content :is(.rule-table, .nc-ref) :is(tbody, tr) { + display: block; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + } + + .sl-markdown-content :is(.rule-table, .nc-ref) tbody tr { + padding-block: 0.8rem; + border-bottom: 1px solid var(--sl-color-gray-5); + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td { + display: block; + width: auto !important; + padding: 0; + border: 0; + white-space: normal; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td + td { + margin-top: 0.3rem; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td:nth-child(n + 3) { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin: 0.5rem 0.9rem 0 0; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td:nth-child(n + 3):empty { + display: none; + } + + .sl-markdown-content :is(.rule-table, .nc-ref) td[data-label]:nth-child(n + 3)::before { + content: attr(data-label); + font-family: var(--nc-label-font); + font-size: var(--nc-label-size); + font-weight: var(--nc-label-weight); + letter-spacing: var(--nc-label-tracking); + text-transform: var(--nc-label-case); + color: var(--sl-color-gray-3); + } + + .sl-markdown-content .rule-table .nc-group { + display: block; + border: 0; + } + + .sl-markdown-content .rule-table .nc-group th { + display: block; + padding-inline: 0; + } +} + +.rule-table-legend, +.quickstart-note, +.site-footer-note { + font-size: var(--sl-text-sm); + color: var(--sl-color-gray-3); +} + +/* Summary numbers over the all-rules index. */ +.sl-markdown-content .nc-stats, +.nc-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); + gap: 1px; + margin-inline: 0; + overflow: hidden; + border: 1px solid var(--nc-card-border); + border-radius: var(--nc-radius); + background: var(--nc-card-border); +} + +.nc-stats > div { + display: grid; + gap: 0.15rem; + margin: 0; + padding: 0.75rem 0.9rem; + background: var(--nc-card-bg); +} + +.nc-stats dd { + order: -1; + margin: 0; + font-family: var(--nc-font-display); + font-size: var(--sl-text-2xl); + font-weight: 600; + line-height: 1.1; + font-variant-numeric: tabular-nums; + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Tabs with many entries (the twelve quick-start tabs) wrap into a row of + chips instead of scrolling sideways past the fold. +--------------------------------------------------------------------------- */ +starlight-tabs:has(.tab:nth-child(6)) [role='tablist'] { + flex-wrap: wrap; + gap: 0.35rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--sl-color-hairline-light); +} + +starlight-tabs:has(.tab:nth-child(6)) .tab { + margin-bottom: 0; +} + +starlight-tabs:has(.tab:nth-child(6)) .tab > [role='tab'] { + padding: 0.25rem 0.7rem; + border: 1px solid var(--sl-color-gray-5); + border-radius: calc(var(--nc-radius) * 2 + 0.25rem); + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-xs); +} + +starlight-tabs:has(.tab:nth-child(6)) .tab > [role='tab'][aria-selected='true'] { + border-color: var(--sl-color-text-accent); + background: var(--sl-color-accent-low); + color: var(--sl-color-white); +} + +/* --------------------------------------------------------------------------- + Who a site is for: the two landing-page lists side by side. +--------------------------------------------------------------------------- */ +.sl-markdown-content .nc-fit, +.nc-fit { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + gap: 0.75rem; +} + +.nc-fit > div { + margin: 0; + padding: 1rem 1.1rem; + border: 1px solid var(--nc-card-border); + border-top: 2px solid var(--sl-color-green); + border-radius: var(--nc-radius); + background: var(--nc-card-bg); + font-size: var(--sl-text-sm); +} + +.nc-fit > div + div { + border-top-color: var(--sl-color-orange); +} + +.nc-fit > div > p + p { + margin-top: 0.5rem; +} + +/* --------------------------------------------------------------------------- + Changelog: a release's version and date as one line. +--------------------------------------------------------------------------- */ +.sl-markdown-content .nc-release-meta, +.nc-release-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.75rem; + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-sm); + color: var(--sl-color-gray-3); +} + +.nc-release-meta b { + font-size: var(--sl-text-lg); + font-weight: 600; + color: var(--sl-color-white); +} + +.nc-sha { + font-family: var(--__sl-font-mono); + font-size: var(--sl-text-xs); + white-space: nowrap; +} diff --git a/site/src/styles/noctcore/expressive-code.mjs b/site/src/styles/noctcore/expressive-code.mjs new file mode 100644 index 0000000..1e9b7fd --- /dev/null +++ b/site/src/styles/noctcore/expressive-code.mjs @@ -0,0 +1,102 @@ +/* noctcore docs: Expressive Code settings for the Nocturne preset. + + // ec.config.mjs + import { defineEcConfig } from '@astrojs/starlight/expressive-code'; + import { noctcoreCodeConfig } from './src/styles/noctcore/expressive-code.mjs'; + + export default defineEcConfig({ + ...noctcoreCodeConfig('nocturne'), + plugins: [], + }); + + What noctcoreCodeConfig sets, and why: + - themes: a dark and a light theme built from the preset's --nc-code-ink and + --nc-syn-* values. Starlight switches between them with its own theme + picker, because one is type 'dark' and the other 'light'. + - useStarlightUiThemeColors: true, so title bars, tabs and borders follow the + preset through Starlight's --sl-* variables. Starlight turns this off by + default once a site passes its own themes. + - customizeTheme: puts the code area on the preset's --nc-code-bg. Starlight + would otherwise use --sl-color-gray-6 in dark mode and -7 in light. + - minSyntaxHighlightingColorContrast: 0. By default Expressive Code nudges + token colours toward 5.5:1 against a fixed grey it uses for that sum, not + against the real code background. These colours are checked against the + real background instead: 4.5:1 or more, see contrast.md. + Keep these values in step with presets/nocturne.css when either changes. */ +import { ExpressiveCodeTheme } from '@astrojs/starlight/expressive-code'; + +const SYNTAX = { + "nocturne": { + "dark": { + "bg": "#0e1528", + "ink": "#d6ddef", + "keyword": "#b3a8ff", + "string": "#7fe0ea", + "number": "#ffc98a", + "function": "#9fe8f1", + "property": "#dde3f1", + "comment": "#8792b3", + "punct": "#939fbf" + }, + "light": { + "bg": "#f5f7fc", + "ink": "#232c44", + "keyword": "#5a3fd0", + "string": "#0b6f7e", + "number": "#9a4f00", + "function": "#0b5563", + "property": "#232c44", + "comment": "#5f6b88", + "punct": "#56627f" + } + } +}; + +/* TextMate scopes per token role, for the TypeScript, JavaScript, shell and + JSON grammars the docs use. */ +const SCOPES = [ + ['comment', ['comment', 'punctuation.definition.comment'], 'italic'], + ['keyword', ['keyword', 'storage', 'storage.type', 'storage.modifier', 'keyword.control', 'constant.language', 'variable.language']], + ['string', ['string', 'string.template', 'punctuation.definition.string', 'punctuation.definition.template-expression']], + ['number', ['constant.numeric', 'constant.other']], + ['function', ['entity.name.function', 'support.function', 'meta.function-call entity.name.function', 'entity.name.type', 'support.type', 'support.class', 'entity.name.class', 'entity.name.command']], + ['property', ['variable.other.property', 'meta.object-literal.key', 'support.type.property-name', 'entity.other.attribute-name']], + ['punct', ['punctuation', 'meta.brace', 'keyword.operator']], +]; + +function theme(preset, type) { + const c = SYNTAX[preset][type]; + return new ExpressiveCodeTheme({ + name: `noctcore-${preset}-${type}`, + type, + colors: { 'editor.background': c.bg, 'editor.foreground': c.ink }, + tokenColors: SCOPES.map(([key, scope, fontStyle]) => ({ + scope, + settings: fontStyle ? { foreground: c[key], fontStyle } : { foreground: c[key] }, + })), + }); +} + +/** The dark and light themes for one preset. */ +export function noctcoreCodeThemes(preset = 'nocturne') { + if (!SYNTAX[preset]) throw new Error(`Unknown noctcore preset "${preset}". Use one of: ${Object.keys(SYNTAX).join(', ')}.`); + return [theme(preset, 'dark'), theme(preset, 'light')]; +} + +/** Everything ec.config.mjs needs for one preset; spread it and add the site's plugins. */ +export function noctcoreCodeConfig(preset = 'nocturne') { + return { + themes: noctcoreCodeThemes(preset), + useStarlightUiThemeColors: true, + minSyntaxHighlightingColorContrast: 0, + customizeTheme(theme) { + theme.styleOverrides.frames = { + ...theme.styleOverrides.frames, + editorBackground: 'var(--nc-code-bg)', + terminalBackground: 'var(--nc-code-bg)', + editorActiveTabBackground: 'var(--nc-code-bg)', + }; + return theme; + }, + }; +} diff --git a/site/src/styles/noctcore/presets/nocturne.css b/site/src/styles/noctcore/presets/nocturne.css new file mode 100644 index 0000000..3133ac3 --- /dev/null +++ b/site/src/styles/noctcore/presets/nocturne.css @@ -0,0 +1,169 @@ +/* noctcore docs preset: Nocturne + The current night-sky theme, refined: brand-tuned signal colours and the crescent rule under every page title. Its sans body keeps the 113-rule index and the code-heavy rule pages easy to scan. + Type: Space Grotesk for headings, system UI for text, JetBrains Mono for code. + Needs: @fontsource-variable/space-grotesk, @fontsource-variable/jetbrains-mono. + Dark is the default, as in Starlight: :root holds the dark values and + :root[data-theme='light'] overrides them. Load after base.css and + components.css. Contrast: every text pair passes WCAG AA, see contrast.md. */ +@import '@fontsource-variable/space-grotesk'; +@import '@fontsource-variable/jetbrains-mono'; + +:root, +::backdrop { + /* Type, shape and labels */ + --nc-font-display: 'Space Grotesk Variable', 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; + --nc-font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; + --nc-font-mono: 'JetBrains Mono Variable', 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, monospace; + --nc-label-font: var(--nc-font-body); + --nc-label-case: none; + --nc-label-tracking: 0; + --nc-label-size: 0.8125rem; + --nc-label-weight: 600; + --nc-radius: 0.5rem; + --nc-measure: 50rem; + + /* Surfaces */ + --nc-bg: #0b1020; + --nc-surface: #10172b; + --nc-raised: #141c33; + --nc-line: #26324f; + --nc-line-soft: #1a2340; + + /* Ink */ + --nc-ink: #eef2fb; + --nc-ink-2: #dde3f1; + --nc-text: #b7c0d8; + --nc-muted: #939fbf; + --nc-faint: #5b6a90; + + /* Accent */ + --nc-link: #5fd6e5; + --nc-on-link: #0b1020; + --nc-accent: #8b7cff; + --nc-accent-soft: #1b2450; + --nc-accent-strong: #5fd6e5; + + /* Signals: Starlight asides and badges, the ESLint verdict frames and rule badges */ + --nc-note-bg: #0f2536; + --nc-note-edge: #4dd0e1; + --nc-note-ink: #9fe8f1; + --nc-tip-bg: #1d1a45; + --nc-tip-edge: #8b7cff; + --nc-tip-ink: #cbc4ff; + --nc-caution-bg: #2e230f; + --nc-caution-edge: #f0b545; + --nc-caution-ink: #ffd89a; + --nc-danger-bg: #35141c; + --nc-danger-edge: #ff7b72; + --nc-danger-ink: #ffbdb8; + --nc-success-bg: #0f2b22; + --nc-success-edge: #56d68f; + --nc-success-ink: #a9efc6; + + /* Components: cards and package cards */ + --nc-card-bg: #10172b; + --nc-card-border: #26324f; + + /* Code: frames read these; the Expressive Code theme carries the syn-* values */ + --nc-code-inline: #1c2644; + --nc-code-bg: #0e1528; + --nc-code-ink: #d6ddef; + --nc-syn-keyword: #b3a8ff; + --nc-syn-string: #7fe0ea; + --nc-syn-number: #ffc98a; + --nc-syn-function: #9fe8f1; + --nc-syn-property: #dde3f1; + --nc-syn-comment: #8792b3; + --nc-syn-punct: #939fbf; + + /* Motif: the window-frame backdrop */ + --nc-motif: linear-gradient(135deg, #0f766e, #1e1b4b); +} + +:root[data-theme='light'], +[data-theme='light'] ::backdrop { + /* Surfaces */ + --nc-bg: #ffffff; + --nc-surface: #f5f7fc; + --nc-raised: #f5f7fc; + --nc-line: #c9d1e3; + --nc-line-soft: #e9edf7; + + /* Ink */ + --nc-ink: #0b1020; + --nc-ink-2: #232c44; + --nc-text: #3a4560; + --nc-muted: #56627f; + --nc-faint: #7b86a4; + + /* Accent */ + --nc-link: #5a4ad6; + --nc-on-link: #ffffff; + --nc-accent: #5a4ad6; + --nc-accent-soft: #ebe9ff; + --nc-accent-strong: #2a2470; + + /* Signals: Starlight asides and badges, the ESLint verdict frames and rule badges */ + --nc-note-bg: #e3f6f9; + --nc-note-edge: #0e8a9a; + --nc-note-ink: #0b5563; + --nc-tip-bg: #eeebff; + --nc-tip-edge: #6f5ef0; + --nc-tip-ink: #3b2fa0; + --nc-caution-bg: #fcf1dc; + --nc-caution-edge: #c98512; + --nc-caution-ink: #7a4a06; + --nc-danger-bg: #fde8e6; + --nc-danger-edge: #d8453b; + --nc-danger-ink: #8f1f18; + --nc-success-bg: #e2f5ea; + --nc-success-edge: #1f9d5a; + --nc-success-ink: #135c35; + + /* Components: cards and package cards */ + --nc-card-bg: #f5f7fc; + --nc-card-border: #c9d1e3; + + /* Code: frames read these; the Expressive Code theme carries the syn-* values */ + --nc-code-inline: #eceff8; + --nc-code-bg: #f5f7fc; + --nc-code-ink: #232c44; + --nc-syn-keyword: #5a3fd0; + --nc-syn-string: #0b6f7e; + --nc-syn-number: #9a4f00; + --nc-syn-function: #0b5563; + --nc-syn-property: #232c44; + --nc-syn-comment: #5f6b88; + --nc-syn-punct: #56627f; + + /* Motif: the window-frame backdrop */ + --nc-motif: linear-gradient(135deg, #0f766e, #1e1b4b); +} + +/* Structure */ +:root:not([data-theme='light']) body { + background-image: + radial-gradient(70rem 30rem at 20% -8rem, rgba(139, 124, 255, 0.16), transparent 65%), + radial-gradient(50rem 24rem at 90% -10rem, rgba(77, 208, 225, 0.1), transparent 65%); + background-repeat: no-repeat; +} + +h1#_top:not(.hero h1)::after { + content: ''; + display: block; + width: 3rem; + height: 0.1875rem; + margin-top: 0.875rem; + border-radius: 999px; + background: var(--nc-gradient); +} + +.page > .header { + border-bottom: 1px solid var(--sl-color-hairline-light); +} + +.sl-link-card:hover, +.sl-link-card:focus-within { + border-color: var(--sl-color-accent); + background: var(--sl-color-gray-6); +} diff --git a/site/src/styles/site.css b/site/src/styles/site.css new file mode 100644 index 0000000..17eb36c --- /dev/null +++ b/site/src/styles/site.css @@ -0,0 +1,39 @@ +/* The ESLint plugins site's own rules, loaded last in customCss after the + noctcore theme (noctcore/base.css, components.css, presets/nocturne.css). + Only what the theme does not cover lives here. */ + +/* On a phone the header has room for the mark, the search and menu buttons + and a short title: the product name alone, without the wordmark. */ +@media (max-width: 29.99rem) { + .site-title { + gap: 0.5rem; + font-size: 0.9375rem; + } + + .site-title :is(.nc-wordmark, .nc-sep) { + display: none; + } +} + +.hero .tagline { + max-width: 38rem; +} + +.hero .sl-link-button.primary:hover, +.hero .sl-link-button.primary:focus-visible { + color: var(--nc-night); + filter: brightness(1.08); +} + +/* The options tables in the rule docs are plain Markdown tables, which + Starlight scrolls sideways at phone width. Their last column ("Meaning") + gets a floor so it does not wrap one word per line. */ +@media (max-width: 40rem) { + .sl-markdown-content table:not(.rule-table) td:last-child { + min-width: 16rem; + } +} + +.site-footer-note { + margin-top: 1.5rem; +} diff --git a/site/src/styles/theme.css b/site/src/styles/theme.css deleted file mode 100644 index f4b2d13..0000000 --- a/site/src/styles/theme.css +++ /dev/null @@ -1,358 +0,0 @@ -/* noctcore docs theme. - The noctcore brand: a night sky (near-black navy, not violet), a crescent moon - that runs from violet (#8b7cff) on one limb to cyan (#4dd0e1) on the other, - and a white wordmark over a short violet-to-cyan rule. Surfaces here are that - navy; the gradient is the signature and cyan does the link and highlight work. - Starlight is dark by default, so :root is the dark theme and - :root[data-theme='light'] overrides it. */ - -:root { - --sl-font: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; - --sl-font-mono: 'JetBrains Mono Variable', ui-monospace, 'SFMono-Regular', monospace; - --nc-font-display: 'Space Grotesk Variable', ui-sans-serif, system-ui, sans-serif; - - /* Brand constants. Decorative only (gradients, the moon): never body text. */ - --nc-violet: #8b7cff; - --nc-cyan: #4dd0e1; - --nc-gradient: linear-gradient(90deg, var(--nc-violet), var(--nc-cyan)); - --nc-night: #0b1020; - - /* Starlight maps text-accent to accent-high in dark mode: links, the - selected sidebar item and the primary button all use it, so it is the - brand cyan. accent (violet) drives hover borders and focus. */ - --sl-color-accent-low: #1b2450; - --sl-color-accent: #8b7cff; - --sl-color-accent-high: #5fd6e5; - - --sl-color-white: #eef2fb; - --sl-color-gray-1: #dde3f1; - --sl-color-gray-2: #b7c0d8; - --sl-color-gray-3: #939fbf; - --sl-color-gray-4: #5b6a90; - --sl-color-gray-5: #26324f; - --sl-color-gray-6: #141c33; - --sl-color-gray-7: #10172b; - --sl-color-black: #0b1020; - - /* Cyan as text: the card meta line. Same hue family as the links. */ - --nc-glow: #5fd6e5; - --nc-bad: #ff7b72; - --nc-bad-bg: rgba(255, 123, 114, 0.1); - --nc-good: #56d68f; - --nc-good-bg: rgba(86, 214, 143, 0.1); - --nc-card: #10172b; - --nc-card-hover: #161f3a; - - --sl-content-width: 50rem; -} - -:root[data-theme='light'] { - /* Light mode is genuinely light: white paper, navy ink, the violet darkened - until it holds 4.5:1 as link text, cyan deepened to teal for the same - reason. The gradient constants stay the brand hexes because they only - ever sit under navy text or on their own. */ - --sl-color-accent-low: #e7e5ff; - --sl-color-accent: #5a4ad6; - --sl-color-accent-high: #2a2470; - - --sl-color-white: #0b1020; - --sl-color-gray-1: #232c44; - --sl-color-gray-2: #3a4560; - --sl-color-gray-3: #5a6684; - --sl-color-gray-4: #7b86a4; - --sl-color-gray-5: #c9d1e3; - --sl-color-gray-6: #e9edf7; - --sl-color-gray-7: #f5f7fc; - --sl-color-black: #ffffff; - - --nc-glow: #0b6f7e; - --nc-bad: #c22f26; - --nc-bad-bg: rgba(194, 47, 38, 0.07); - --nc-good: #1a7f47; - --nc-good-bg: rgba(26, 127, 71, 0.07); - --nc-card: #f5f7fc; - --nc-card-hover: #eef1f9; -} - -/* Night sky. A faint violet and cyan glow at the top of the page in dark - mode, the way the banner lifts from black at the horizon. Light mode gets - none of this: paper stays paper. */ -:root:not([data-theme='light']) body { - background-image: - radial-gradient(70rem 30rem at 20% -8rem, rgba(139, 124, 255, 0.16), transparent 65%), - radial-gradient(50rem 24rem at 90% -10rem, rgba(77, 208, 225, 0.1), transparent 65%); - background-repeat: no-repeat; -} - -/* The wordmark is monospace white over a gradient rule in the banner; the - site title follows it. */ -.site-title { - font-family: var(--sl-font-mono); - font-weight: 600; - letter-spacing: -0.02em; - color: var(--sl-color-white) !important; -} - -.site-title span { - text-overflow: ellipsis; -} - -/* Below Starlight's 50em breakpoint the header has room for the logo, the - search and menu buttons and about 15rem of title. Monospace at h4 size - clips "plugins" on a 390px phone, base size fits. */ -@media (max-width: 50em) { - .site-title { - font-size: var(--sl-text-base); - } -} - -/* Hero: the banner's short violet-to-cyan rule under the title, and the - primary action carries the gradient with night-navy text, which holds AA - against both ends in both modes. */ -.hero h1::after { - content: ''; - display: block; - width: 6rem; - height: 0.25rem; - margin-top: 1rem; - border-radius: 999px; - background: var(--nc-gradient); -} - -/* Starlight centres the hero below 50rem and starts it above; the rule - follows the title. */ -@media (max-width: 49.99rem) { - .hero h1::after { - margin-inline: auto; - } -} - -.hero .sl-link-button.primary { - background: var(--nc-gradient); - border-color: transparent; - color: var(--nc-night); -} - -.hero .sl-link-button.primary:hover, -.hero .sl-link-button.primary:focus-visible { - color: var(--nc-night); - filter: brightness(1.08); -} - -.site-title, -.sl-markdown-content h1, -.sl-markdown-content h2, -h1#_top, -.hero h1 { - font-family: var(--nc-font-display); - letter-spacing: -0.015em; -} - -/* --------------------------------------------------------------------------- - Bad and good examples. - - The rule docs mark each fence `bad` or `good`; ec.config.mjs turns that into - .verdict-bad / .verdict-good on the rendered block. Three signals, so the - difference never rests on colour alone: a coloured rail down the left edge, - a coloured title tab, and a cross or tick glyph in front of the label. ---------------------------------------------------------------------------- */ - -.expressive-code .verdict-bad, -.expressive-code .verdict-good { - position: relative; -} - -.expressive-code .verdict-bad pre, -.expressive-code .verdict-good pre { - border-left-width: 4px !important; -} - -.expressive-code .verdict-bad pre { - border-left-color: var(--nc-bad) !important; -} - -.expressive-code .verdict-good pre { - border-left-color: var(--nc-good) !important; -} - -.expressive-code .verdict-bad .header .title, -.expressive-code .verdict-good .header .title { - font-weight: 600; -} - -.expressive-code .verdict-bad .header .title { - color: var(--nc-bad) !important; - background: var(--nc-bad-bg) !important; -} - -.expressive-code .verdict-good .header .title { - color: var(--nc-good) !important; - background: var(--nc-good-bg) !important; -} - -.expressive-code .verdict-bad .header .title::before { - content: '\2715\00a0\00a0'; -} - -.expressive-code .verdict-good .header .title::before { - content: '\2713\00a0\00a0'; -} - -/* --------------------------------------------------------------------------- - Generated rule tables. ---------------------------------------------------------------------------- */ - -.rule-table-wrap { - overflow-x: auto; -} - -.sl-markdown-content .rule-table { - display: table; - width: 100%; - font-size: var(--sl-text-sm); -} - -.sl-markdown-content .rule-table td:first-child { - width: 30%; -} - -.sl-markdown-content .rule-table td:first-child code { - overflow-wrap: anywhere; -} - -/* At phone width a 30% name column is under 100px and rule names wrap one - syllable per line. Give the name column a floor and let .rule-table-wrap - scroll sideways instead. The options tables in the rule docs have the same - problem in their last column ("Meaning"), so it gets a floor too. */ -@media (max-width: 40rem) { - .sl-markdown-content .rule-table td:first-child { - width: auto; - min-width: 11rem; - } - - .sl-markdown-content table:not(.rule-table) td:last-child { - min-width: 16rem; - } -} - -.pill { - display: inline-block; - padding: 0.05rem 0.5rem; - border-radius: 999px; - font-size: var(--sl-text-xs); - font-weight: 600; - white-space: nowrap; - border: 1px solid var(--sl-color-gray-4); - color: var(--sl-color-gray-2); -} - -.pill-error { - color: var(--sl-color-accent-high); - background: var(--sl-color-accent-low); - border-color: transparent; -} - -.pill-deprecated { - color: var(--sl-color-orange-high); - background: var(--sl-color-orange-low); - border-color: transparent; -} - -.deprecated-note { - display: block; - margin-top: 0.25rem; - font-size: var(--sl-text-xs); -} - -/* The all-rules index shows the full id (`noctcore-react/...`), which is - wider than a bare name: the first column gets more of the row. */ -.sl-markdown-content .rule-table-all td:first-child { - width: 36%; -} - -.rule-table-legend, -.quickstart-note { - font-size: var(--sl-text-sm); - color: var(--sl-color-gray-3); -} - -/* --------------------------------------------------------------------------- - Landing page. ---------------------------------------------------------------------------- */ - -.hero .tagline { - max-width: 38rem; -} - -.package-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); - gap: 0.75rem; - padding: 0 !important; - list-style: none; -} - -.package-grid .package-card { - margin: 0 !important; -} - -.package-card a { - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 0.4rem; - height: 100%; - padding: 1rem 1.1rem; - border: 1px solid var(--sl-color-gray-5); - border-radius: 0.6rem; - background: var(--nc-card); - color: var(--sl-color-gray-2); - text-decoration: none; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.package-card a::before { - content: ''; - position: absolute; - inset: 0 0 auto 0; - height: 3px; - background: var(--nc-gradient); - opacity: 0; - transition: opacity 0.15s ease; -} - -.package-card a:hover, -.package-card a:focus-visible { - border-color: var(--sl-color-accent); - background: var(--nc-card-hover); -} - -.package-card a:hover::before, -.package-card a:focus-visible::before { - opacity: 1; -} - -.package-card-name { - font-family: var(--sl-font-mono); - font-size: var(--sl-text-sm); - font-weight: 600; - color: var(--sl-color-white); -} - -.package-card-desc { - font-size: var(--sl-text-sm); - line-height: 1.45; -} - -.package-card-meta { - margin-top: auto; - font-size: var(--sl-text-xs); - color: var(--nc-glow); -} - -.site-footer-note { - margin-top: 1.5rem; - font-size: var(--sl-text-xs); - color: var(--sl-color-gray-3); -} From 68b35df0d0ed6f14cefd853dce548ec52f99a20f Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 04/10] test(site): check the nocturne preset sets every theme token Every --nc-* token base.css and components.css read must be set by the preset for dark and for light; a missing one resolves to nothing and fails no build. --- site/scripts/theme.test.ts | 83 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 site/scripts/theme.test.ts diff --git a/site/scripts/theme.test.ts b/site/scripts/theme.test.ts new file mode 100644 index 0000000..05b062c --- /dev/null +++ b/site/scripts/theme.test.ts @@ -0,0 +1,83 @@ +/** + * The noctcore theme's token contract, checked on the files this site ships: + * every `--nc-*` custom property that base.css and components.css read is set + * by the Nocturne preset, for dark (`:root`) and for light + * (`:root[data-theme='light']`). A token the preset forgets does not fail the + * build: the `var()` just resolves to nothing and a colour silently drops out, + * so this is the only thing that notices. + */ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const STYLES = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'styles', 'noctcore'); +const read = (file: string) => readFileSync(join(STYLES, file), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); + +/** + * Tokens with one value for both modes (the theme README's "Type, shape and + * labels"): the preset sets them once on `:root` and light inherits them. + */ +const MODE_INDEPENDENT = new Set([ + '--nc-font-display', + '--nc-font-body', + '--nc-font-mono', + '--nc-label-font', + '--nc-label-case', + '--nc-label-tracking', + '--nc-label-size', + '--nc-label-weight', + '--nc-radius', + '--nc-measure', +]); + +const readsOf = (css: string) => new Set([...css.matchAll(/var\(\s*(--nc-[\w-]+)/g)].map((m) => m[1]!)); +const setsOf = (css: string) => new Set([...css.matchAll(/(--nc-[\w-]+)\s*:/g)].map((m) => m[1]!)); + +/** The declarations inside the first block whose selector list starts with `selector`. */ +function block(css: string, selector: string): string { + const start = css.indexOf(`${selector},`); + if (start === -1) throw new Error(`no "${selector}" block in the preset`); + const open = css.indexOf('{', start); + return css.slice(open + 1, css.indexOf('}', open)); +} + +function missingTokens(base: string, components: string, preset: string): { dark: string[]; light: string[] } { + // Tokens base.css or components.css set themselves (the brand constants, a + // badge's own colours, a frame's unit) are not the preset's to provide. + const local = new Set([...setsOf(base), ...setsOf(components)]); + const needed = [...new Set([...readsOf(base), ...readsOf(components)])].filter((token) => !local.has(token)).sort(); + const dark = setsOf(block(preset, ':root')); + const light = setsOf(block(preset, ":root[data-theme='light']")); + return { + dark: needed.filter((token) => !dark.has(token)), + light: needed.filter((token) => !MODE_INDEPENDENT.has(token) && !light.has(token)), + }; +} + +describe('noctcore theme token contract', () => { + const base = read('base.css'); + const components = read('components.css'); + const preset = read('presets/nocturne.css'); + + test('the two shared files read tokens at all (the parse is not vacuous)', () => { + expect(readsOf(base)).toContain('--nc-ink'); + expect(readsOf(base)).toContain('--nc-success-edge'); + expect(readsOf(components)).toContain('--nc-card-bg'); + expect(readsOf(components)).toContain('--nc-motif'); + }); + + test('Nocturne sets every token base.css and components.css read, in dark and in light', () => { + expect(missingTokens(base, components, preset)).toEqual({ dark: [], light: [] }); + }); + + test('a token dropped from either mode is reported', () => { + const noLightEdge = preset.replace( + /(:root\[data-theme='light'\][\s\S]*?)--nc-success-edge:[^;]*;/, + '$1', + ); + expect(missingTokens(base, components, noLightEdge)).toEqual({ dark: [], light: ['--nc-success-edge'] }); + const noDarkRadius = preset.replace(/--nc-radius:[^;]*;/, ''); + expect(missingTokens(base, components, noDarkRadius)).toEqual({ dark: ['--nc-radius'], light: [] }); + }); +}); From a3a85ff5db6be48447dcdc322d14bff5c406822e Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 05/10] feat(site): split the site title and rule titles into namespace and name The site title reads as the noctcore wordmark over its crescent rule, then the product; on a phone only the product. A rule page title shows its namespace, quiet, over the rule name. --- site/astro.config.mjs | 2 ++ site/src/components/PageTitle.astro | 12 ++++++++++ site/src/components/SiteTitle.astro | 37 +++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 site/src/components/PageTitle.astro create mode 100644 site/src/components/SiteTitle.astro diff --git a/site/astro.config.mjs b/site/astro.config.mjs index 08c3b88..14399be 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -29,6 +29,8 @@ export default defineConfig({ './src/styles/site.css', ], components: { + SiteTitle: './src/components/SiteTitle.astro', + PageTitle: './src/components/PageTitle.astro', Footer: './src/components/Footer.astro', }, social: [ diff --git a/site/src/components/PageTitle.astro b/site/src/components/PageTitle.astro new file mode 100644 index 0000000..78039ec --- /dev/null +++ b/site/src/components/PageTitle.astro @@ -0,0 +1,12 @@ +--- +/** + * A rule id reads as its namespace, quiet, over the rule name + * (`noctcore-contracts/` then `fetch-must-check-ok`). Every other page keeps + * the plain title. components.css restores the h1 styles Starlight scopes to + * its own PageTitle. + */ +const title = Astro.locals.starlightRoute.entry.data.title; +const match = /^([\w-]+\/)(.+)$/.exec(title); +--- + +

{match ? (<>{match[1]}{match[2]}) : title}

diff --git a/site/src/components/SiteTitle.astro b/site/src/components/SiteTitle.astro new file mode 100644 index 0000000..da75a51 --- /dev/null +++ b/site/src/components/SiteTitle.astro @@ -0,0 +1,37 @@ +--- +/** + * The site title as the noctcore wordmark over its crescent rule, then the + * product name. Replacing Starlight's SiteTitle drops its scoped styles, so the + * layout it had (logo height, gap, no wrapping) is restored below. + */ +import mark from '../assets/mark.svg'; + +const { siteTitleHref } = Astro.locals.starlightRoute; +--- + + + + noctcore + + ESLint plugins + + + From b00a6ec4fbe04c65da22e8d52dc14d6047a1dfe1 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 06/10] feat(site): lead paragraph and facts strip on every rule page sync.ts writes the doc's summary as the page lead and replaces the one-line metadata with a six-cell facts strip (package, preset, autofix, suggestions, options, type information; harness cells for lint-meta rules), plus a status cell on a deprecated rule. The readmes and deprecation tests pinned the old line and now check the strip. --- site/scripts/deprecation.test.ts | 3 + site/scripts/facts.test.ts | 128 +++++++++++++++++++++++++++++++ site/scripts/readmes.test.ts | 5 +- site/scripts/sync.ts | 85 ++++++++++++++++---- 4 files changed, 203 insertions(+), 18 deletions(-) create mode 100644 site/scripts/facts.test.ts diff --git a/site/scripts/deprecation.test.ts b/site/scripts/deprecation.test.ts index cbe3916..87d6948 100644 --- a/site/scripts/deprecation.test.ts +++ b/site/scripts/deprecation.test.ts @@ -173,6 +173,8 @@ describe('rendering a deprecated rule', () => { 'client. Use [`noctcore-fixture/new-rule`](../../fixture/new-rule/) instead.\n:::', ); expect(page).toContain(' badge:\n text: Deprecated\n variant: caution'); + expect(page).toContain('
Status
deprecated
'); + expect(page).not.toContain('nc-optin'); expect(page).not.toContain(''); }); @@ -181,6 +183,7 @@ describe('rendering a deprecated rule', () => { const page = renderRuleDoc(doc, '/repo/packages/eslint-plugin-fixture/docs/rules/new-rule.md', pkg, newRule); expect(page).not.toContain(':::caution'); expect(page).not.toContain('badge:'); + expect(page).not.toContain('
Status
'); }); test('llms.txt lists deprecated rules with their replacement, and nothing when there are none', () => { diff --git a/site/scripts/facts.test.ts b/site/scripts/facts.test.ts new file mode 100644 index 0000000..86b2b0a --- /dev/null +++ b/site/scripts/facts.test.ts @@ -0,0 +1,128 @@ +/** + * The header a rule page gets from sync.ts: the doc's summary as the lead + * paragraph, then the facts strip. Same cells in the same order on every page, + * a badge where a value is a state, plain text for a yes or no, and `is-no` on + * a negative. Proven on fixtures so every branch is covered, then on the real + * inventory so no rule falls outside them. + */ +import { describe, expect, test } from 'bun:test'; + +import { SITE_BASE, loadInventory, type PackageEntry, type RuleEntry } from './inventory'; +import { factsStrip, renderRuleDoc } from './sync'; + +const inventory = await loadInventory('src'); + +function rule(overrides: Partial = {}): RuleEntry { + return { + name: 'some-rule', + id: 'noctcore-fixture/some-rule', + description: 'Fixture rule.', + recommended: 'error', + fixable: false, + hasSuggestions: false, + typeInfo: 'none', + requiresOptions: false, + hasOptions: false, + deprecated: false, + replacedBy: [], + deprecatedSince: null, + deprecationMessage: null, + docsUrl: null, + ...overrides, + }; +} + +const plugin: PackageEntry = { + short: 'fixture', + npmName: '@noctcore/eslint-plugin-fixture', + kind: 'eslint-plugin', + namespace: 'noctcore-fixture', + version: '1.2.3', + description: 'Fixture plugin.', + dir: '/repo/packages/eslint-plugin-fixture', + rules: [], +}; + +/** The strip as `label: value` pairs, with ` (no)` after a quiet value. */ +function cells(html: string): string[] { + return [...html.matchAll(/
([^<]+)<\/dt>(.*?)<\/dd><\/div>/g)].map( + ([, label, quiet, value]) => `${label}: ${value}${quiet ? ' (no)' : ''}`, + ); +} + +describe('facts strip', () => { + test('a rule on in the preset, with nothing else set', () => { + expect(cells(factsStrip(plugin, rule()))).toEqual([ + `Package: fixturev1.2.3`, + 'Recommended preset: error', + 'Autofix: No (no)', + 'Suggestions: No (no)', + 'Options: None (no)', + 'Type information: Not needed (no)', + ]); + }); + + test('an opt-in rule with every capability', () => { + const html = factsStrip( + plugin, + rule({ recommended: 'off', fixable: true, hasSuggestions: true, requiresOptions: true, hasOptions: true, typeInfo: 'required' }), + ); + expect(cells(html).slice(1)).toEqual([ + 'Recommended preset: offopt-in', + 'Autofix: Yes', + 'Suggestions: Yes', + 'Options: Required', + 'Type information: required', + ]); + }); + + test('a rule left out of the preset, with optional options and optional types', () => { + const html = factsStrip(plugin, rule({ recommended: null, hasOptions: true, typeInfo: 'optional' })); + expect(cells(html)).toContain( + 'Recommended preset: not listedopt-in', + ); + expect(cells(html)).toContain('Options: Optional'); + expect(cells(html)).toContain('Type information: optional'); + }); + + test('a lint-meta rule gets the harness cells', () => { + const lintMeta: PackageEntry = { ...plugin, short: 'lint-meta-rules', kind: 'lint-meta', namespace: null }; + const html = factsStrip( + lintMeta, + rule({ factory: 'createThingRule', entry: '@noctcore/lint-meta-rules/i18n', category: 'source-text', ciCritical: false }), + ); + expect(cells(html).map((cell) => cell.split(':')[0])).toEqual([ + 'Package', + 'Runs under', + 'Factory', + 'Entry point', + 'Category', + 'Fails CI by default', + ]); + expect(cells(html)).toContain('Fails CI by default: No (no)'); + }); + + test('every real rule gets a complete strip', () => { + for (const pkg of inventory) { + for (const entry of pkg.rules) { + const html = factsStrip(pkg, entry); + expect(html.startsWith('
')).toBe(true); + expect(cells(html)).toHaveLength(entry.deprecated ? 7 : 6); + } + } + }); +}); + +describe('rule page header', () => { + test('the summary becomes the lead, above the facts strip, and the blockquote is gone', () => { + const sections = ['Why', 'What it flags', 'What it does not flag', 'When not to use it']; + const body = sections.map((section) => `## ${section}\n\nText.\n`).join('\n'); + const doc = `# \`noctcore-fixture/some-rule\`\n\n> Checks \`x\` twice.\n> **Opt-in.**\n\n${body}`; + const page = renderRuleDoc(doc, '/repo/packages/eslint-plugin-fixture/docs/rules/some-rule.md', plugin, rule()); + const rendered = page.slice(page.indexOf('---\n', 4) + 4); + expect(rendered.startsWith('\n
\n\nChecks `x` twice.\n**Opt-in.**\n\n
\n\n
')).toBe( + true, + ); + expect(rendered).not.toMatch(/^>/m); + }); +}); diff --git a/site/scripts/readmes.test.ts b/site/scripts/readmes.test.ts index b2b65df..2442c03 100644 --- a/site/scripts/readmes.test.ts +++ b/site/scripts/readmes.test.ts @@ -71,7 +71,7 @@ describe('generated README tables and rule-doc headers', () => { } }); - test('the site page drops the doc header and keeps its own metadata line', () => { + test('the site page drops the doc header and keeps its own facts strip', () => { const pkg = inventory.find((entry) => entry.kind === 'eslint-plugin')!; const rule = pkg.rules[0]!; const sections = ['Why', 'What it flags', 'What it does not flag', 'When not to use it']; @@ -81,7 +81,8 @@ describe('generated README tables and rule-doc headers', () => { const page = renderRuleDoc(doc, `${pkg.dir}/docs/rules/${rule.name}.md`, pkg, rule); expect(page).not.toContain(HEADER_BEGIN); expect(page).not.toContain(HEADER_END); - expect(page).toContain('**Recommended preset:**'); + expect(page).toContain('
'); + expect(page).toContain('
Recommended preset
'); }); }); diff --git a/site/scripts/sync.ts b/site/scripts/sync.ts index 4fc0cfa..adac6b6 100644 --- a/site/scripts/sync.ts +++ b/site/scripts/sync.ts @@ -145,24 +145,77 @@ function plainText(markdown: string): string { .trim(); } -function factsLine(pkg: PackageEntry, rule: RuleEntry): string { +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +/** One cell of the facts strip. `quiet` marks a negative value, so it reads quieter. */ +function fact(label: string, value: string, quiet = false): string { + return `
${label}
${value}
`; +} + +const badge = (kind: string, text: string) => `${text}`; +const yesNo = (label: string, yes: boolean) => fact(label, yes ? 'Yes' : 'No', !yes); + +function presetFact(rule: RuleEntry): string { + if (rule.recommended === 'error') return badge('on', 'error'); + const state = rule.recommended === 'off' ? badge('off', 'off') : badge('out', 'not listed'); + // A deprecated rule is in no preset on purpose; it is not one to turn on. + return rule.deprecated ? state : `${state}opt-in`; +} + +/** + * The facts strip under a rule's title and summary: the same cells in the same + * place on every rule page, with a badge where a value is a state and plain + * text for a yes or no. Markup and classes are the noctcore theme's + * (`.nc-facts` in src/styles/noctcore/components.css). + */ +export function factsStrip(pkg: PackageEntry, rule: RuleEntry): string { + const packageLink = + `${pkg.short}` + + `v${pkg.version}`; + let cells: string[]; if (pkg.kind === 'lint-meta') { // A reader who lands here from search has no other route to what runs a // lint-meta rule: not ESLint, the harness. The package page explains it. - return [ - `**Runs under:** [\`@noctcore/harness\` lint-meta](${SITE_BASE}/packages/${pkg.short}/), not ESLint`, - `**Factory:** \`${rule.factory}\` from \`${rule.entry}\``, - `**Category:** \`${rule.category}\``, - `**Fails CI by default:** ${rule.ciCritical ? 'yes' : 'no'}`, - ].join(' · '); + cells = [ + fact('Package', packageLink), + fact('Runs under', `@noctcore/harness lint-meta, not ESLint`), + fact('Factory', `${escapeHtml(rule.factory ?? '')}`), + fact('Entry point', `${escapeHtml(rule.entry ?? '')}`), + fact('Category', `${escapeHtml(rule.category ?? '')}`), + yesNo('Fails CI by default', rule.ciCritical === true), + ]; + } else { + const options = rule.requiresOptions + ? fact('Options', badge('options', 'Required')) + : fact('Options', rule.hasOptions ? 'Optional' : 'None', !rule.hasOptions); + const typeInfo = + rule.typeInfo === 'none' + ? fact('Type information', 'Not needed', true) + : fact('Type information', badge('types', rule.typeInfo)); + cells = [ + fact('Package', packageLink), + fact('Recommended preset', presetFact(rule)), + yesNo('Autofix', rule.fixable), + yesNo('Suggestions', rule.hasSuggestions), + options, + typeInfo, + ]; } - const typeInfo = { required: 'required', optional: 'used when available', none: 'not needed' }; - return [ - `**Recommended preset:** ${rule.recommended ? `\`${rule.recommended}\`` : 'not included'}`, - `**Autofix:** ${rule.fixable ? 'yes' : 'no'}`, - `**Suggestions:** ${rule.hasSuggestions ? 'yes' : 'no'}`, - `**Type information:** ${typeInfo[rule.typeInfo]}`, - ].join(' · '); + if (rule.deprecated) cells.push(fact('Status', badge('deprecated', 'deprecated'))); + return ['
', ...cells, '
'].join('\n'); +} + +/** + * The doc's `> summary` blockquote as the page's lead paragraph. The summary is + * Markdown (code spans, bold), so it stays Markdown inside the wrapper and the + * site's own renderer turns it into HTML; the blank lines around it are what + * make that happen inside an HTML block. + */ +function lead(quote: readonly string[]): string[] { + const text = quote.map((line) => line.replace(/^>\s?/, '')); + return ['
', '', ...text, '', '
']; } /** A replacement rule's page, relative to a rule page at rules///. */ @@ -180,7 +233,7 @@ function deprecationBanner(rule: RuleEntry): string[] { /** * Render one rule doc as a Starlight page. Throws on a doc out of shape. The * doc's generated status header is dropped: the page states the same facts in - * its own metadata line, and showing both would say everything twice. + * its own facts strip, and showing both would say everything twice. */ export function renderRuleDoc(source: string, docPath: string, pkg: PackageEntry, rule: RuleEntry): string { const lines = stripRuleHeader(source.replace(/\r\n/g, '\n')).split('\n'); @@ -202,7 +255,7 @@ export function renderRuleDoc(source: string, docPath: string, pkg: PackageEntry .join(' '), ); - const body: string[] = [...quote, ...deprecationBanner(rule), '', factsLine(pkg, rule)]; + const body: string[] = [...lead(quote), ...deprecationBanner(rule), '', factsStrip(pkg, rule)]; let inFence = false; for (const line of lines.slice(i)) { const fence = FENCE.exec(line); From effcbb154c2d84b14a65c7b9378503c9947447c3 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 07/10] feat(site): text badges, package groups and summary numbers on the rule tables Rows carry data-label so they stack at phone width, the emoji become text badges with the opt-in and needs-options markers, the all-rules index opens with summary numbers and a heading row per package, and a rule id splits into namespace over name inside one code element. --- site/src/components/AllRules.astro | 97 ++++++++++++++++-------- site/src/components/DeprecatedNote.astro | 2 +- site/src/components/RuleBadges.astro | 45 +++++++++++ site/src/components/RuleTable.astro | 40 +++++----- 4 files changed, 133 insertions(+), 51 deletions(-) create mode 100644 site/src/components/RuleBadges.astro diff --git a/site/src/components/AllRules.astro b/site/src/components/AllRules.astro index 5b66f29..951ecbd 100644 --- a/site/src/components/AllRules.astro +++ b/site/src/components/AllRules.astro @@ -7,7 +7,8 @@ * descriptions all come from the plugins' exported metadata. */ import DeprecatedNote from './DeprecatedNote.astro'; -import { codeSpans, href, packages, plural, presetLabel } from '../lib/catalog'; +import RuleBadges from './RuleBadges.astro'; +import { codeSpans, href, packages, plural } from '../lib/catalog'; const rows = packages.flatMap((pkg) => pkg.rules.map((rule) => ({ pkg, rule }))); const total = rows.length; @@ -20,11 +21,30 @@ const optIn = pluginRules.filter(({ rule }) => rule.recommended === null).length const lintMetaCount = rows.length - pluginRules.length; const typed = rows.filter(({ rule }) => rule.typeInfo !== 'none'); const typeLabel = { required: 'required', optional: 'optional', none: '' } as const; -/** `react` for a plugin, `lint-meta-rules` for the catalog: the id a reader searches for. */ -const ruleId = (pkg: (typeof packages)[number], rule: (typeof rows)[number]['rule']) => - pkg.kind === 'eslint-plugin' ? rule.id : `${pkg.short}/${rule.name}`; +const stats = [ + ['Rules', total], + ['On in recommended', onInPreset], + ['Opt-in', registeredOff + optIn], + ['lint-meta', lintMetaCount], + ['Autofix', rows.filter(({ rule }) => rule.fixable).length], + ['Suggestions', rows.filter(({ rule }) => rule.hasSuggestions).length], +] as const; +/** `noctcore-react/` for a plugin, `lint-meta-rules/` for the catalog: with the name, the id a reader searches for. */ +const namespaceOf = (pkg: (typeof packages)[number]) => + pkg.kind === 'eslint-plugin' ? `${pkg.namespace}/` : `${pkg.short}/`; --- +
+ { + stats.map(([label, count]) => ( +
+
{label}
+
{count}
+
+ )) + } +
+

{total} rules across {plugins.length} ESLint plugins and {lintMeta.length} lint-meta catalog. Of the{' '} {pluginRules.length} ESLint rules, {onInPreset} are switched on by a recommended preset,{' '} @@ -52,38 +72,53 @@ const ruleId = (pkg: (typeof packages)[number], rule: (typeof rows)[number]['rul { - rows.map(({ pkg, rule }) => ( - - - - {ruleId(pkg, rule)} - - - - {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - - {pkg.kind === 'eslint-plugin' ? ( - - {presetLabel(rule.recommended)} + packages.map((pkg) => ( + <> + + + {pkg.short} + + {plural(pkg.rules.length, 'rule')} · v{pkg.version} - ) : ( - harness - )} - - {[rule.fixable && '🔧', rule.hasSuggestions && '💡'].filter(Boolean).join(' ')} - {typeLabel[rule.typeInfo as keyof typeof typeLabel]} - + + + {pkg.rules.map((rule) => ( + + + + {namespaceOf(pkg)}{rule.name} + + + + + {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} + + + {pkg.kind === 'eslint-plugin' ? ( + + ) : ( + harness + )} + + + + + ))} + )) }

- Preset: severity in the package's configs.recommended; off means the - preset registers the rule switched off, not listed means it leaves the rule out; both are opt-in, so - you turn the rule on yourself. harness - means the rule is a lint-meta rule with no ESLint preset. ❌ deprecated: the rule still - works but is in no preset and will be removed; move to the rule named after it. Fix: 🔧 fixable with - --fix, 💡 offers editor suggestions. Types: whether the rule needs a - type-checked program (parserOptions.projectService), or uses one when present. + Preset: the rule's severity in its package's configs.recommended.{' '} + error is on. off (registered, switched off) + and not listed (left out) are both opt-in: + you turn the rule on yourself. needs options means it does nothing + until you configure it. harness marks a lint-meta rule, which has no ESLint + preset. deprecated marks a rule that still works but is in no preset and + will be removed; move to the rule named after it. Fix: + autofix with --fix, + suggestion in your editor. Types: whether the + rule needs a type-checked program (parserOptions.projectService), or uses one when present.

diff --git a/site/src/components/DeprecatedNote.astro b/site/src/components/DeprecatedNote.astro index 0959cfa..8e45c6f 100644 --- a/site/src/components/DeprecatedNote.astro +++ b/site/src/components/DeprecatedNote.astro @@ -16,7 +16,7 @@ const replacements = rule.deprecated ? replacementsOf(rule) : []; { rule.deprecated && ( - ❌ deprecated + deprecated {replacements.length > 0 && ( <> {' '}use{' '} diff --git a/site/src/components/RuleBadges.astro b/site/src/components/RuleBadges.astro new file mode 100644 index 0000000..c0d9390 --- /dev/null +++ b/site/src/components/RuleBadges.astro @@ -0,0 +1,45 @@ +--- +/** + * The badges a rule row shows in one table column: the preset state (with the + * opt-in and needs-options markers), the fix kinds, or the type information. + * Shared by the all-rules index and each package's table, so the two cannot + * mark the same rule differently. Text badges, never emoji: a screen reader + * and find-in-page both get words. + */ +import { presetLabel, type CatalogRule } from '../lib/catalog'; + +interface Props { + rule: CatalogRule; + column: 'preset' | 'fix' | 'types'; +} + +const { rule, column } = Astro.props; +const preset = presetLabel(rule.recommended); +// A deprecated rule is in no preset on purpose; it is not one to turn on. +const optIn = preset !== 'error' && !rule.deprecated; +--- + +{ + column === 'preset' && ( + <> + {preset} + {optIn && opt-in} + {rule.requiresOptions && ( + <> + {' '} + needs options + + )} + + ) +} +{ + column === 'fix' && ( + <> + {rule.fixable && autofix} + {rule.fixable && rule.hasSuggestions && ' '} + {rule.hasSuggestions && suggestion} + + ) +} +{column === 'types' && rule.typeInfo !== 'none' && {rule.typeInfo}} diff --git a/site/src/components/RuleTable.astro b/site/src/components/RuleTable.astro index eb6be54..561e22d 100644 --- a/site/src/components/RuleTable.astro +++ b/site/src/components/RuleTable.astro @@ -4,7 +4,8 @@ * rule's `meta` via the catalog. Nothing in it is typed by hand. */ import DeprecatedNote from './DeprecatedNote.astro'; -import { codeSpans, getPackage, href, presetLabel } from '../lib/catalog'; +import RuleBadges from './RuleBadges.astro'; +import { codeSpans, getPackage, href } from '../lib/catalog'; interface Props { pkg: string; @@ -12,7 +13,6 @@ interface Props { const pkg = getPackage(Astro.props.pkg); const isPlugin = pkg.kind === 'eslint-plugin'; -const typeLabel = { required: 'required', optional: 'optional', none: '' } as const; ---
@@ -42,33 +42,33 @@ const typeLabel = { required: 'required', optional: 'optional', none: '' } as co pkg.rules.map((rule) => isPlugin ? ( - + {rule.name} - {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - - - {presetLabel(rule.recommended)} - + + {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - {rule.fixable ? 'autofix' : rule.hasSuggestions ? 'suggestion' : ''} - {typeLabel[rule.typeInfo as keyof typeof typeLabel]} + + + ) : ( - + {rule.name} - {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} - + + {codeSpans(rule.description).map(({ part, code }) => (code ? {part} : part))} + + {rule.category} - + {rule.entry} @@ -81,11 +81,13 @@ const typeLabel = { required: 'required', optional: 'optional', none: '' } as co { isPlugin && (

- Preset: severity in configs.recommended; off means the preset - registers the rule switched off, not listed means it leaves the rule out; both are opt-in, so you - turn the rule on yourself. Fix: - whether the rule ships an autofix or an editor suggestion. Types: whether the rule needs a - type-checked program (parserOptions.projectService). + Preset: severity in configs.recommended.{' '} + off means the preset registers the rule switched off,{' '} + not listed means it leaves the rule out; both are{' '} + opt-in, so you turn the rule on yourself.{' '} + needs options means it does nothing until you configure it.{' '} + Fix: whether the rule ships an autofix or an editor suggestion. Types: whether + the rule needs a type-checked program (parserOptions.projectService), or uses one when present.

) } From b2e751e61d18030ed89c1078c133dabda6b7f0f2 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 08/10] feat(site): package cards with version and preset split Each card leads with the short name and version and shows how its rules split across the recommended preset, as a bar and in words. --- site/src/components/PackageGrid.astro | 50 ++++++++++++++++++++------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/site/src/components/PackageGrid.astro b/site/src/components/PackageGrid.astro index 3a8c0eb..9d39a6b 100644 --- a/site/src/components/PackageGrid.astro +++ b/site/src/components/PackageGrid.astro @@ -1,14 +1,37 @@ --- /** - * Landing-page index of every package: name, npm description, rule and preset - * counts. All of it comes from the catalog, so a new rule shows up here without - * anyone editing the landing page. + * Landing-page index of every package: short name, version, npm name and + * description, and how its rules split across the recommended preset. All of it + * comes from the catalog, so a new rule shows up here without anyone editing the + * landing page. The split bar is decoration; the meta line says the same in words. */ import { enabledInPreset, href, packages, plural } from '../lib/catalog'; -/** A preset that turns nothing on says so, so a 0 does not read as a mistake. */ -const presetSummary = (enabled: number) => - enabled === 0 ? ' · 0 on in recommended, all opt-in' : ` · ${enabled} on in recommended`; +type Pkg = (typeof packages)[number]; + +/** How a plugin's rules split: on in recommended, registered off, left out. */ +function split(pkg: Pkg) { + const on = enabledInPreset(pkg); + const off = pkg.rules.filter((rule) => rule.recommended === 'off').length; + return { on, off, out: pkg.rules.length - on - off }; +} + +function meta(pkg: Pkg): string { + const rules = plural(pkg.rules.length, 'rule'); + if (pkg.kind !== 'eslint-plugin') return `${rules} · harness lint-meta catalog`; + const { on, off, out } = split(pkg); + return [rules, `${on} on`, off > 0 && `${off} off`, out > 0 && `${out} not listed`].filter(Boolean).join(' · '); +} + +function segments(pkg: Pkg): { kind: string; count: number }[] { + if (pkg.kind !== 'eslint-plugin') return [{ kind: 'harness', count: 1 }]; + const { on, off, out } = split(pkg); + return [ + { kind: 'on', count: on }, + { kind: 'off', count: off }, + { kind: 'out', count: out }, + ].filter(({ count }) => count > 0); +} ---
    @@ -16,15 +39,18 @@ const presetSummary = (enabled: number) => packages.map((pkg) => (
  • + + {pkg.short} + v{pkg.version} + {pkg.npmName} {pkg.description} - - {plural(pkg.rules.length, 'rule')} - {pkg.kind === 'eslint-plugin' - ? presetSummary(enabledInPreset(pkg)) - : ' · harness lint-meta catalog'} - {` · v${pkg.version}`} + + {meta(pkg)}
  • )) From 804e7aa8c674fde7ca0d5f4bb7944b6b8f01a5c3 Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 13:59:58 +0200 Subject: [PATCH 09/10] feat(site): show who the plugins are for side by side Wrap the landing page's Yes, if and Probably not, if paragraphs in the fit pair. The prose is unchanged. --- site/src/content/docs/index.mdx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index 36e20eb..d4087b6 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -25,16 +25,25 @@ import AllQuickStarts from '../../components/AllQuickStarts.astro'; ## Is this for you? +
    +
    + **Yes, if** you run a TypeScript codebase on ESLint 9 or newer with flat config, and the bugs that reach review are structural: a component that drills props five levels down, a `fetch` with no timeout, a Prisma write outside its transaction, a log line that interpolates a user's email. These plugins catch those with high-precision, mostly syntactic rules, and every rule is `error` or `off`, never `warn` ([why](/eslint-plugins/getting-started/#severity-policy)). +
    +
    + **Probably not, if** you are on legacy `.eslintrc` config (these are flat-config only), you want a general style guide (use `typescript-eslint` and a formatter; these plugins assume both), or your codebase does not share the conventions a plugin encodes. Each package page says when it is a bad fit. +
    +
    + Every package is independently versioned. Install the one that matches the problem you have. ## Packages From 0dd6a53c26b711a67a40b86a9b2da5c4a784f10f Mon Sep 17 00:00:00 2001 From: Shironex Date: Sun, 27 Sep 2026 14:05:36 +0200 Subject: [PATCH 10/10] docs(contributing): describe the facts strip that replaced the metadata line --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5cc8922..bb5a142 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -90,7 +90,9 @@ and each package's `recommended` preset: `lint-meta-rules` gets a table of rule id, factory, entry point and category instead; - a one-line status header in every `docs/rules/.md`, right after the title and blockquote summary, between `` and ``. - The site page drops it and shows its own metadata line. + The site page drops it and shows the same facts as its own facts strip, built by `factsStrip` in + `site/scripts/sync.ts` (an ESLint rule: package, preset, autofix, suggestions, options, type + information; a lint-meta rule: its package, factory, entry point and category). Never edit between the markers by hand. Run the command after adding a rule, changing a description, or moving a rule in or out of the preset, and commit what it writes. Running it twice