diff --git a/.claude/.markdownlint.json b/.claude/.markdownlint.json new file mode 100644 index 00000000..56df99ac --- /dev/null +++ b/.claude/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "extends": "../.markdownlint.json", + "WH001": false +} diff --git a/.claude/hooks/markdown-on-save.sh b/.claude/hooks/markdown-on-save.sh new file mode 100755 index 00000000..e6196368 --- /dev/null +++ b/.claude/hooks/markdown-on-save.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# PostToolUse hook: auto-fix Markdown/MDX after edits — the same fixers `make +# fix` runs, narrowed to the one file just written. +# +# Why a hook (vs leaving it to `make fix` or CI): these are deterministic, +# mechanical corrections — unwrapping hard-wrapped prose (WH001), inserting the +# blank line an MDX fence needs beside a JSX tag (WH002), common typos and +# US spelling. Catching them at write time means an agent's own output is +# already correct, instead of costing a lint failure and a second pass to fix +# by hand. Sibling of gofumpt-on-save.sh, which does the same for Go. +# +# Deliberately NOT wired into .githooks/pre-commit: a commit hook that rewrites +# and re-stages files silently changes what you reviewed and fights `git add -p`. +# The commit hook stays a check; this fixes early enough that it rarely fires. +# +# The two branches below are mutually exclusive by extension: markdownlint's +# generic fixers never see .mdx. See scripts/fix-mdx-fences.mjs for why. +# +# Safety: best-effort throughout. A missing tool, an unparseable file, or a +# lint error that has no fix leaves the file alone and never blocks the edit. + +set -uo pipefail + +input=$(cat) +file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null) + +case "$file_path" in + *.md | *.mdx) ;; + *) exit 0 ;; +esac +[ -f "$file_path" ] || exit 0 + +cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null || exit 0 + +# Resolve both sides to physical paths before comparing. A literal prefix strip +# is not a containment check: `/../notes.md` strips to `../notes.md`, +# which is not absolute and would sail past a `case */*` bail. Symlinks have the +# same problem in reverse. This repo's Markdown conventions have no business +# rewriting agent memory files under ~/.claude/, scratch notes in /tmp, or +# Markdown in an unrelated checkout — all of which a session routinely writes. +root=$(pwd -P) || exit 0 +dir=$(cd "$(dirname "$file_path")" 2>/dev/null && pwd -P) || exit 0 +case "$dir" in + "$root" | "$root"/*) ;; + *) exit 0 ;; +esac + +# markdownlint-cli2 resolves globs (and per-directory config) from the repo +# root, so hand it a repo-relative path. +if [ "$dir" = "$root" ]; then + rel=$(basename "$file_path") +else + rel="${dir#"$root"/}/$(basename "$file_path")" +fi + +# .mdx gets exactly one STRUCTURAL fixer, and it is ours (misspell below still +# corrects spelling there). The generic markdownlint rules are deliberately +# never run against MDX — markdownlint parses CommonMark, MDX +# does not, and where the two disagree a generic autofix rewrites the inside of +# a code block. fix-mdx-fences only ever inserts a blank line beside a JSX tag, +# so its worst failure is a render-neutral blank line. `make lint` still CHECKS +# .mdx; it just never acts on the disagreement. Mirrors `fix:md`. +if [ "${rel##*.}" = "mdx" ]; then + node scripts/fix-mdx-fences.mjs "$rel" >/dev/null 2>&1 || true +elif [ -x node_modules/.bin/markdownlint-cli2 ]; then + # Plain Markdown: markdownlint's parse IS authoritative, so the full fixer + # chain is safe. `--no-globs` keeps it to this one file rather than the whole + # repo; a nonzero exit only means something unfixable remains (e.g. a fence + # with no language), which CI reports. + # + # Twice, mirroring `fix:md`: WH001's insert carries the pre-fix text of the + # lines it joins, so another rule's fix for a joined line is dropped on the + # first pass. One pass would leave behind exactly the issue this hook exists + # to prevent, in the common case where WH001 fires. + node_modules/.bin/markdownlint-cli2 --no-globs --fix ":$rel" >/dev/null 2>&1 || true + node_modules/.bin/markdownlint-cli2 --no-globs --fix ":$rel" >/dev/null 2>&1 || true +fi + +# Phase 3: spelling, over docs prose only — the same scope `make lint-prose` +# uses, resolved by the canonical script rather than a second copy of the list. +if scripts/docs-prose.sh is-match "$rel" 2>/dev/null; then + for misspell in .bin/*/misspell-*; do + [ -x "$misspell" ] || continue + "$misspell" -locale US -source text -w "$rel" >/dev/null 2>&1 || true + break + done +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json index 2c15d6a7..95c28c02 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -49,6 +49,10 @@ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/gofumpt-on-save.sh" + }, + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/markdown-on-save.sh" } ] } diff --git a/.github/.markdownlint.json b/.github/.markdownlint.json new file mode 100644 index 00000000..56df99ac --- /dev/null +++ b/.github/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "extends": "../.markdownlint.json", + "WH001": false +} diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index f5870101..5ae734e7 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -13,8 +13,28 @@ // `astro build` (CI, via `make ci` → build-docs), the single source of truth // for broken links. See docs/astro.config.mjs. // - // NOTE: .mdx is intentionally NOT linted — markdownlint parses CommonMark, not - // MDX's JSX/import syntax (docs/src/content/docs/index.mdx). + // .mdx IS linted, with a caveat worth knowing: markdownlint parses CommonMark, + // not MDX, so it reads JSX as HTML blocks. That turns out to be a feature — + // when a fence is glued to a (see WH002) the fence stops being a + // code block and the rules light up — which is exactly the hazard we want + // caught, since it is what makes a generic --fix rewrite the code. + // It also means generic `--fix` must never run over .mdx AT ALL — the rules + // would "fix" code that only looks like prose. .mdx IS linted, but through the + // `**/*.mdx` glob on `lint:md` in package.json, not this file's `globs`, which + // are .md only so that no generic pass — `fix:md` or a bare `--fix` — can + // reach MDX (see the globs note below). The only fixer that touches .mdx is + // scripts/fix-mdx-fences.mjs, which just inserts blank lines beside JSX tags. + // + // customRules live here and nowhere else — the VS Code extension reads this + // file too, so no editor-side setting is needed (markdownlint.customRules is + // deprecated in favor of this file). Note the editor only activates on + // Markdown, so .mdx gets no squiggles either way. CI reports both; `make fix` + // repairs WH001 in .md and only WH002 in .mdx: + // WH001 prose paragraphs must not be hard-wrapped (autofix: joins them) + // WH002 MDX fence adjacent to a JSX tag needs a blank line (fixed in phase 1) + // Both are enabled in .markdownlint.json. WH001 is turned off for CI docs and + // agent prompts by .github/.markdownlint.json and .claude/.markdownlint.json, + // and applies everywhere else — a narrower exclusion than scripts/docs-prose.sh. // // markdownlint-cli2 globs with dot:true, so `**/*.md` descends into hidden // dirs — including .worktrees/, where this repo nests git worktrees. Honor @@ -23,7 +43,18 @@ // (`--fix`) never rewrites another branch's files. The explicit ignores below // stay as a fallback for the case where .gitignore is absent. "gitignore": true, + // .md ONLY, deliberately. The .mdx glob lives on `lint:md` in package.json + // instead, because --fix is orthogonal to globs: leaving **/*.mdx here means + // a bare `markdownlint-cli2 --fix` from the repo root rewrites the inside of + // MDX code blocks, and "don't run that by hand" is a prohibition, not a + // guard. With the glob on the lint script, a bare --fix is safe by + // construction and the worst a bare lint can do is under-report .mdx — + // the strictly better failure of the two. "globs": ["**/*.md"], + "customRules": [ + "./scripts/markdownlint-rules/no-hard-wrapped-prose.mjs", + "./scripts/markdownlint-rules/mdx-fence-needs-blank-line.mjs" + ], "ignores": [ "**/node_modules/**", "**/dist/**", diff --git a/.markdownlint.json b/.markdownlint.json index f0dfe2e0..9db42027 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -4,5 +4,7 @@ "MD024": { "siblings_only": true }, "MD033": false, "MD041": false, - "MD060": false + "MD060": false, + "WH001": true, + "WH002": true } diff --git a/.vscode/settings.json b/.vscode/settings.json index fe4c4de5..8940e513 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -63,8 +63,23 @@ "editor.tabSize": 2, // Apply markdownlint's auto-fixes on save — mirrors `make fix` / `pnpm run // fix:md`. The DavidAnson.vscode-markdownlint extension and the CLI both read - // the same .markdownlint.json (rules) + .markdownlint-cli2.jsonc (globs), so - // the editor and CI stay in lockstep. + // the same .markdownlint.json (rules) + .markdownlint-cli2.jsonc (globs AND + // customRules — so no setting is needed here; markdownlint.customRules is + // deprecated in favor of exactly that file), so the editor and CI stay in + // lockstep for Markdown. + // + // Not for MDX: the extension activates on the `markdown` language ID only, + // and .mdx is not associated with it, so WH001 squiggles in .md alone and + // WH002 — which only applies to .mdx — never squiggles at all. `make fix` + // and .claude/hooks/markdown-on-save.sh are the MDX path. Associating .mdx + // with markdown would be the wrong fix: it would turn on the fix-on-save + // below for MDX, running exactly the generic fixers the config globs keep + // away from .mdx (see the note below). + // + // WH002's autofix is owned by scripts/fix-mdx-fences.mjs, not by fix-on-save: + // the generic markdownlint fixers are never run over .mdx, because where + // their CommonMark parse disagrees with MDX they rewrite the inside of a + // code block. `make fix` (or the markdown-on-save hook) repairs it instead. "editor.codeActionsOnSave": { "source.fixAll.markdownlint": "explicit" } @@ -88,7 +103,14 @@ "markdown.validate.fileLinks.enabled": "ignore", "markdown.validate.fileLinks.markdownFragmentLinks": "ignore", "markdown.validate.fragmentLinks.enabled": "warning", - "markdown.validate.referenceLinks.enabled": "warning", + // Off, not "warning": Starlight asides are `:::note[Title]`, and the validator + // reads that trailing `[Title]` as a shortcut reference link with no + // definition — ~40 false positives across the docs, one per aside, plus a few + // more where it parses MDX JSX props the same way. This repo defines no + // reference-style links at all, so the check has nothing true to say here. + // (markdownlint's equivalent, MD052, stays quiet for its own reason: its + // shortcut_syntax option is off by default. Editor and CLI agree.) + "markdown.validate.referenceLinks.enabled": "ignore", "markdown.validate.unusedLinkDefinitions.enabled": "warning", "markdown.validate.duplicateLinkDefinitions.enabled": "warning", diff --git a/AGENTS.md b/AGENTS.md index 2a2e834c..4883e04a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ make ci # Full pre-push pipeline — run it the documented way ( make build # Compile → bin/wavehouse make dev # ClickHouse + hot-reload server on :8080 (Docker) make deps-up # Start ClickHouse alone — for `make dev`; NOT needed by `make ci` -make dev-docs # Prod-faithful docs dev loop: rebuild-on-save + wrangler dev on :4321 +make dev-docs # Prod-faithful docs dev loop: rebuild-on-save + wrangler dev on :4321 (next free port if busy) make build-docs # Production build → docs/dist/ make preview-docs # Wrangler preview of the production build (auto-builds if dist/ missing) make branding-docs # Regenerate logo/favicon/OG assets from docs/src/assets/branding/mark.svg @@ -113,7 +113,7 @@ Tooling notes (the non-obvious bits `make help` won't tell you): - `golangci-lint` is pinned in the Makefile (v2.11.4), auto-installed to `.bin/` on first `make lint` — kept out of `go.mod` (its deps conflict with the main module). - `pnpm` (≥ 11.21) + `Node 22 LTS` (`.nvmrc`, matches CI) must be on PATH; `make tools` runs one root `pnpm install --frozen-lockfile` across the three workspaces (SDK `clients/ts/`, E2E `tests/e2e/sdk/`, docs `docs/`). - **GNU Make 4+** required (uses `--output-sync=target`); macOS BSD Make 3.81 won't parse it. Full setup: `docs/src/content/docs/development.md` § Prerequisites. -- **Lint split**: Biome owns JS/TS/JSON, markdownlint owns Markdown *style*, misspell owns spelling (all under `make lint`/`make fix`); accuracy/clarity/doc-sync is the `docs-reviewer` gate (§Docs review). +- **Lint split**: Biome owns JS/TS/JSON, markdownlint owns Markdown *and MDX* style — including two repo-local rules, WH001 (no hard-wrapped prose) and WH002 (MDX fence beside a JSX tag) in `scripts/markdownlint-rules/` — misspell owns spelling (all under `make lint`/`make fix`); accuracy/clarity/doc-sync is the `docs-reviewer` gate (§Docs review). See §Markdown authoring rules. - **Worktrunk** (`wt`, `.config/wt.toml`): `wt switch --create` seeds `.bin/` + `node_modules/` from main, then runs `make tools`. ## Testing Conventions @@ -321,6 +321,34 @@ Source-of-truth pairs that must agree: Before finishing a task, grep for the identifiers you touched (field names, env var names, endpoint paths) across docs to catch staleness. +### Markdown authoring rules + +- **Never hard-wrap prose. One paragraph is one line.** No wrapping at 72/80 columns, no "semantic linefeeds" splitting a paragraph at sentence boundaries. Wrapped prose makes every later edit rewrap the whole block, so a one-word change lands as a five-line diff. Enforced by WH001 (`scripts/markdownlint-rules/no-hard-wrapped-prose.mjs`), which autofixes. Tables (with or without leading pipes), code, headings, setext underlines, blockquotes, JSX, `$$` display math, multi-line MDX `import`/`export`, and `:::` aside delimiters are left alone; a list item is joined as a unit, marker line included; an aside's *body* is joined but its delimiters are not. +- **In MDX, leave a blank line between a JSX tag and a code fence.** MDX itself renders the glued form correctly — verified by compiling both shapes with the same `@mdx-js/mdx` Astro uses. The blank line is what keeps *markdownlint* agreeing with it: markdownlint parses CommonMark, where `` opens an HTML block that runs to the next blank line, so a glued fence is not a code block to any generic rule and `markdownlint --fix` will reformat the code inside it: + + ````mdx + + + ```yaml + data_dir: ./data + ``` + + + ```` + + Enforced by WH002. **`.mdx` is never auto-fixed by the generic markdownlint rules** — `make fix` scopes that pass to `**/*.md`, because where markdownlint's CommonMark parse and MDX disagree a generic autofix rewrites the inside of a code block. MDX gets exactly one *structural* fixer, `scripts/fix-mdx-fences.mjs`, which only ever inserts a blank line beside a JSX tag (misspell still corrects spelling there — its curated list needs no parse). So `make lint` reports MDX problems but `make fix` will not silently repair them — including WH001 wrapping, which you must unwrap by hand in `.mdx`. You can't reach MDX with a bare `markdownlint-cli2 --fix` either — the config globs `.md` only, and the `.mdx` glob lives on `lint:md` — so that hazard is closed by construction rather than by this instruction. +- **Editors see WH001 in `.md` only.** The markdownlint extension reads `.markdownlint-cli2.jsonc`, `customRules` included, so no `.vscode` setting is needed (`markdownlint.customRules` is deprecated in favor of that file). But it activates on the `markdown` language ID, and `.mdx` is not associated with it — so WH002 never squiggles in the editor, and WH001 squiggles only in `.md`. Don't "fix" that with a `files.associations` entry: it would enable `source.fixAll.markdownlint` on `.mdx`, running exactly the generic fixers that must never see MDX. `make fix` and the agent hook are the MDX path. +- **These fix themselves as you write.** `.claude/hooks/markdown-on-save.sh` (PostToolUse, sibling of `gofumpt-on-save.sh`) runs the MDX fence pass on `.mdx`, markdownlint `--fix` on `.md`, and misspell on both, so an agent's output is corrected in the same pass rather than costing a lint failure and a manual cleanup. It only sees `Edit`/`Write`/`MultiEdit` — a file written through a Bash heredoc bypasses it, so run `make fix` after doing that. +- **WH001 is off under `.github/` and `.claude/`** (CI docs and agent prompts) via their own `.markdownlint.json`. It applies everywhere else, `AGENTS.md` and `CHANGELOG.md` included — so this is a narrower exclusion than `scripts/docs-prose.sh`, which also skips those two. + +### Authoring docs-site pages + +Three invariants the docs site enforces in code, each of which fails quietly rather than loudly if you hand-write around it: + +- **Opt a page into the Cloud CTA with `cloudCta` frontmatter**, not by importing the component. `cloudCta: true` takes the default copy; `cloudCta: { title?, body? }` overrides it, which is the point — the CTA lands hardest when it names the work *that* page just described. Schema in `docs/src/content.config.ts`; the footer renders it. (The homepage is the exception: it passes `` inline, because the wide band variant is splash-only and `template: splash` pages don't render the footer's copy.) +- **Never hand-write `®` or `™` in prose.** `rehype-trademarks` appends the symbol to each mark's first mention automatically, and `markFirstMentions` (`docs/src/config/trademarks.ts`) matches the bare name with no check for a symbol already there — so "ClickHouse®" renders as "ClickHouse®®". Add the mark to the registry in `trademarks.ts` and let the plugin place it; the footer notice is generated from the same registry. +- **Never hand-write `utm_*` params or `rel` on a link to `wavehouse.cloud` or `wave-rf.com`.** Use `cloudLink()` / `relFor()` from `docs/src/config/outbound.ts`. First-party links deliberately carry `rel="noopener"` *without* `noreferrer`, because `noreferrer` suppresses the `Referer` header PostHog turns into `$referring_domain` — writing the `rel` by hand is the easy way to silently destroy the attribution the whole feature exists for. Third-party links keep both. + ### Authoring Mermaid diagrams Diagrams render inside the Starlight content column (~46–58rem wide) as build-time SVG via `astro-themed-mermaid`, themed by `docs/src/config/mermaid-theme.mjs`. **Author them vertically so they fit the page at a legible size** — the single most common diagram mistake here is a wide left-to-right flowchart that gets scaled down to fit the column until its labels are unreadable. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b3fe75f..2477b7a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,7 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). @@ -11,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Docs site: WaveHouse Cloud referrals, per-page trademark notices, and attributed outbound links** (`docs/src/components/CloudCta.astro`, `docs/src/components/ExternalIcon.astro`, `docs/src/components/Trademarks.astro`, `docs/src/config/outbound.ts`, `docs/src/config/trademarks.ts`, `docs/src/plugins/rehype-trademarks.ts`, `docs/src/content.config.ts`, `docs/src/components/{Footer,Header,Hero,LiveDemo}.astro`, `docs/src/styles/global.css`, and the pages carrying a CTA): the docs had no path to the managed service, and no consistent way to attribute a visit once someone took one. A new `cloudCta` frontmatter key (`true` for default copy, or `{ title, body }`) opts a page into a CTA panel, deliberately page-specific — the CTA lands hardest when it names the work *that* page just finished describing — the homepage carries the wider `band` variant inline, and its hero's second action now points at Cloud rather than GitHub. Outbound links to Wave RF properties are centralised in `outbound.ts` so every one carries the same UTM params (`utm_source=wavehouse.dev` plus a `utm_content` naming the exact placement, since PostHog reads them off the landing URL). First-party links deliberately get `rel="noopener"` **without** `noreferrer`: `noreferrer` suppresses the `Referer` header that PostHog turns into `$referring_domain`, while `noopener` alone still closes the reverse-tabnabbing hole — third-party links keep both. The referrer survives because Referrer-Policy is never overridden, so the browser default `strict-origin-when-cross-origin` sends the origin only, which is why per-placement detail rides in `utm_content` rather than being inferred from the path. A `rehype-trademarks` plugin appends the ®/™ symbol to each mark's first mention in prose, and `Trademarks.astro` renders the matching per-page attribution notice — both driven off one registry in `trademarks.ts`, replacing a hand-maintained footer blob, and external links get a consistent affordance via `ExternalIcon`. +- **Markdown/MDX lint rules that autofix — WH001 (no hard-wrapped prose) and WH002 (MDX fence beside a JSX tag)** (`scripts/markdownlint-rules/`, `scripts/fix-mdx-fences.mjs`, `.claude/hooks/markdown-on-save.sh`, `.markdownlint.json`, `.markdownlint-cli2.jsonc`, `.vscode/settings.json`, `Makefile`, `package.json`, `AGENTS.md`, `CONTRIBUTING.md`, `docs/src/content/docs/development.md`): two classes of docs defect were being introduced faster than they were caught, both mechanical. **WH001** flags a paragraph broken across lines and joins it — hard-wrapped prose makes a one-word edit land as a five-line diff — skipping tables, code, headings, setext underlines, blockquotes, JSX, and `:::` aside delimiters, and joining a list item as a unit, and turned off for CI docs and agent prompts (`.github/`, `.claude/`) while applying everywhere else. **WH002** flags a code fence sitting directly against a JSX tag. MDX renders that shape correctly (verified by compiling both against the same `@mdx-js/mdx` Astro uses), but markdownlint parses CommonMark, where the tag opens an HTML block running to the next blank line — so the fence is not a code block to any generic rule, and `markdownlint --fix` reformats the code inside it. The blank line keeps the two parsers agreeing. `.mdx` is now linted at all, which it previously wasn't; markdownlint reading MDX as CommonMark turns out to be the feature that exposes WH002's failure mode. The WH002 autofix is a standalone pass (`scripts/fix-mdx-fences.mjs`, sharing its detector with the rule) that must run *before* markdownlint: while the blank line is missing, CommonMark sees no code block, so a YAML block's `#` comments read as ATX headings and MD022/MD023/MD026/MD034 will de-indent them out of the block and rewrite bare URLs inside verbatim code. The generic markdownlint fixers are scoped to `**/*.md` and never run over `.mdx` at all — where markdownlint's CommonMark parse and MDX disagree, an autofix rewrites the inside of a code block, so `.mdx` is checked but structurally fixed only by `fix-mdx-fences.mjs` (misspell still corrects spelling there). The practical cost is that a markdownlint finding in `.mdx` may need fixing by hand ([#499](https://github.com/Wave-RF/WaveHouse/issues/499) tracks doing this properly). The Markdown track of `make fix` is also now serial, since markdownlint and misspell both write the same files. Editors need no setting of their own — the extension reads `customRules` from `.markdownlint-cli2.jsonc` — though it activates on Markdown only, so `.mdx` squiggles nowhere and CI owns it. A `markdown-on-save` PostToolUse hook (sibling of `gofumpt-on-save`) applies the whole chain to agent-written files at write time, so a mechanical defect costs no review round-trip. - **HTTP customization for the SDK — `options.headers`, `options.fetchOptions`, and `options.fetch`** (`clients/ts/src/types.ts`, `clients/ts/src/client.ts`, `clients/ts/src/http.ts`, `clients/ts/src/index.ts`, `clients/ts/src/client.test.ts`, `docs/src/content/docs/sdk/index.mdx`): closes #269. `ClientOptions` exposed only `maxRetries`, so a WaveHouse behind a header-gated proxy (Cloudflare Access, an mTLS sidecar, an auth gateway) or a cookie-authenticated origin was unreachable from the SDK — a defense-in-depth gate forced consumers off the client entirely. Three knobs land together, shaped after the conventions in Supabase's, OpenAI's, and Anthropic's clients rather than invented here. **`headers`** adds static headers to every REST request; names match case-insensitively as HTTP requires, and they apply *underneath* the SDK's own — `auth` keeps `Authorization`, and a request's `Content-Type`/`Accept` can't be displaced by a global one, since a header joined rather than replaced is how you ship `Content-Type: application/json, image/png`. **`fetchOptions`** merges extra `RequestInit` fields (`credentials: "include"` for the cookie case, `mode`, `cache`, or a runtime extension like Next.js's `next: { tags }`); the fields the SDK controls — `method`, `headers`, `body`, `signal` — always win, so it cannot corrupt the request. **`fetch`** replaces the HTTP implementation outright. All three shipped REST-only, because `.stream()` went through `EventSource`, which accepts neither headers nor a `fetch`; **#203 closed that gap within this same unreleased cycle**, so as released they apply to streaming too — see the entry above for the streaming contract they carry. `.liveQuery()`'s initial backfill is an ordinary REST call and *is* covered. Per-call overrides and dynamic header callbacks are deliberately deferred to #459; the per-call slot (`.fetch(opts)`) already exists, so adding them later is additive. The exported `FetchLike` is written out rather than spelled `typeof fetch`, because that resolves differently depending on whether the consumer's TypeScript `lib` includes DOM — the same fragility behind Supabase's long tail of `node-fetch` resolution issues. (Its URL parameter narrows further in the BREAKING entry below, so it is deliberately *not* the standard signature.) `options.fetch` accepts any `fetch`-compatible function (exported as the `FetchLike` type) and is used for every request, retries included, so middleware sees each attempt. The motivating case is a runtime bug consumers can't fix themselves: undici 8.8.0–8.9.0 stalls a request before it goes out when a keep-alive socket is reused while the event loop is idle ([nodejs/undici#5600](https://github.com/nodejs/undici/issues/5600), fixed in 8.10.0), and Node 26 bundles 8.9.0 — so calls that should take milliseconds don't, with no recourse inside the SDK. Severity varies with the runtime and the idle gap: the upstream report measured ~450–465 ms, and our own runs against an instant-answering server have ranged from ~100 ms to tens of seconds. Upgrading undici is the real fix, and `options.fetch` is how you get it without waiting for a new runtime: install undici yourself and route through it, passing its dispatcher **explicitly**. That last part is load-bearing and not obvious — undici keeps its connection pool on a shared `globalThis` symbol claimed by whichever copy loads first (Node claims it for the bundled copy on the first built-in `fetch` call, not at startup), so calling an installed 8.10.0's `fetch` without a `dispatcher` resolves whatever is on that symbol and can still stall through the bundled 8.9.0's pool. Measured with both copies loaded, 1.5 s idle gaps against a 10 ms server: `21, 1514, 1495, 583 ms` with an implied dispatcher versus `17, 14, 12, 13 ms` with an explicit `new Agent()`. (Tuning `keepAliveTimeout` is *not* a workaround — measured, it changes nothing, because the retirement timer is starved by the same idle event loop; `new Agent({ pipelining: 0 })` does work for anyone pinned to an affected version, at a connection per request.) The same hook covers the ordinary reasons an SDK grows one: proxies, client certificates, tracing or circuit-breaker middleware, and mocking HTTP in a consumer's own tests without monkey-patching a global. `fetch` stays optional all the way through to the internal `HttpContext` rather than being defaulted at construction, so the default path still calls the global directly — that keeps it late-bound (replacing `globalThis.fetch` after a client exists still works, which is what `vi.stubGlobal` does) and avoids invoking a detached `fetch` reference, which is not universally safe; both properties are pinned by tests. Implementations shipping their own request/response declarations (undici, `node-fetch`) need casts on the init and the return value, since those types are separate from the ones behind the global `fetch`; the narrow documented runtime contract is what makes them safe — a string URL and plain `RequestInit` in, and only `.ok`/`.headers` plus `.text()` on success and `.status`/`.statusText`/`.json()` on a non-`ok` response read back. Abort handling is part of that contract: a rejection is reported as `ABORTED` whenever the `AbortSignal` you passed has been aborted, decided from the signal rather than the rejection's type, so `AbortSignal.timeout()` and `node-fetch` behave the same as the platform `fetch`. - **Non-JWT operator key for full-access admin + break-glass recovery** (`internal/auth/auth.go`, `internal/auth/context.go`, `internal/auth/auth_test.go`, `internal/api/router.go`, `internal/api/router_test.go`, `internal/config/config.go`, `internal/config/config_test.go`, `cmd/wavehouse/main.go`, `config.yaml`, `deployments/compose/standalone.yaml`, `tests/e2e/fixtures/config.yaml`, `docs/src/content/docs/configuration.mdx`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/api.md`, `docs/src/content/docs/deployment.md`, `docs/src/content/docs/development.md`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/reverse-proxy.mdx`, `AGENTS.md`, `SECURITY.md`): closes #240; partially advances the auth-hardening epic [#228](https://github.com/Wave-RF/WaveHouse/issues/228) and the management/data-plane split [#359](https://github.com/Wave-RF/WaveHouse/issues/359). Adds an optional `auth.operator_key` (`WH_AUTH_OPERATOR_KEY`): a request presenting it — via an `Authorization: Operator ` header (forwarded verbatim by proxies, no collision with Bearer JWTs) or the `X-Operator-Key` alias — is authorized as a full-access platform operator — the whole data plane *and* the `/v1/ops/*` management surface — without minting a JWT and independently of the token verifier, giving the person running the deployment a role-free credential for bootstrap and break-glass. The middleware checks it before the Bearer token with a constant-time comparison (`crypto/subtle`) and stamps two things into the request context: the live `admin_role` (so the policy evaluator's admin bypass grants unrestricted data-plane access while a policy exists) and a platform-operator bit that `RequireAdmin` honors **even when the policy is `nil`/deleted** — the one HTTP path that can restore a wiped policy, which previously required SSH access and a reboot. Empty (the default) disables it, so existing deployments are unchanged; treat it as an admin secret (load from a secret store, serve only over TLS). A successful operator authentication is audit-logged at `INFO`; a request presenting a *non-matching* operator key is logged at `WARN` and counted by a new `wavehouse_auth_operator_key_failures_total` counter (a probing/brute-force signal on the most privileged credential in the system) before falling through to the normal token/default path — the middleware still never rejects. The `Authorization` auth-scheme is matched case-insensitively (RFC 7235) via a shared `authScheme` helper, which also makes the existing `Bearer` JWT scheme case-insensitive (previously it required the canonical `Bearer` casing). Explicitly out of scope, tracked in #359: scoping the operator credential to the management surface only, and capability-scoped admin permissions. @@ -25,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **`development.md` told readers to install `golangci-lint` globally, which `make lint` never uses** (`docs/src/content/docs/development.md`): the page said "If not found, `make lint` prints install instructions" and led with `brew install` / `go install @latest`. The Makefile downloads the pinned v2.11.4 into `.bin/_/` on first use, and a global copy is never on the path it takes — so the instructions sent contributors to install a version the build ignores. Now states the auto-install, and keeps the links only for running the tool outside `make`. Same pass corrected "the Makefile uses `go run`" to `go tool `. +- **Docs: prose reflowed, one MDX measurement block tagged, aside bodies unwrapped, one stale caution removed** (`docs/src/content/docs/sdk/index.mdx`, `docs/src/content/docs/sdk/reference.md`, `docs/src/content/docs/pipes.mdx`, `docs/src/content/docs/reverse-proxy.mdx`, `README.md`, `CODE_OF_CONDUCT.md`, `CHANGELOG.md`): the first pass of the new WH001/WH002 rules over the tree. The undici timing block in the SDK docs had no info string (MD040) and is now `text`; two paragraphs inside a `:::caution` aside, plus paragraphs in the README, Code of Conduct, and this file's header, were hard-wrapped and are now one line each. No wording changed in any of that. Separately — a content change, not a reflow — `reverse-proxy.mdx` drops its `:::caution[Check the prefix actually survives to the wire]` block, which told readers the path-prefix fix was unreleased and shipped on the `@dev` tag; that stops being true with the first tagged release, and the durable half of the warning (an unstripped prefix returns a clean `404`) already lives in the nginx and `ingress-nginx` notes on the same page. - **A cancelled REST request could throw instead of returning `ABORTED`** (`clients/ts/src/http.ts`, `clients/ts/src/http.test.ts`, `clients/ts/src/types.ts`, `docs/src/content/docs/sdk/index.mdx`): found by review while checking a sentence about `ABORTED` on #470. The retry backoff in the network-error path is the one `sleep` that runs *inside* `request()`'s catch block, so its rejection had no handler and escaped as a raw `DOMException` — the two sleeps in the `try` are caught and converted. Nothing wraps `request()`, so it surfaced to the caller as an unhandled rejection, breaking the SDK's "never throws" contract on the REST path and making the `AbortController` example in the SDK reference (a 5s timeout against an unreachable server) demonstrate a branch that could not be taken. Reachable with the default `maxRetries: 2` and any `AbortSignal`. Abort is now also classified from **the signal** rather than the rejection's type: keying off the error alone meant an implementation throwing something other than a `DOMException` named `AbortError` — `AbortSignal.timeout()` raises a `TimeoutError`, `node-fetch` its own class — was reported as `NETWORK_ERROR` at `maxRetries: 0` but `ABORTED` at `2`, since only the second had a backoff left for the sleep to notice the signal. Same abort, different code, depending on a retry setting. Both directions are pinned by tests. ### Changed diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 274c6d82..fcf36826 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -2,73 +2,46 @@ ## Our Pledge -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards -Examples of behavior that contributes to a positive environment for our -community include: +Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or advances of - any kind +* The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official email address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -****. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ****. All complaints will be reviewed and investigated promptly and fairly. -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. +All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33f8c3d3..a1d656b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,7 @@ test(cache): add tiered cache stampede test - **Naming**: Follow [Go naming conventions](https://go.dev/doc/effective_go#names). - **Interfaces**: Define interfaces where they are consumed, not where they are implemented. - **Errors**: Return errors rather than panicking. Use `fmt.Errorf("context: %w", err)` for wrapping. +- **Docs prose**: never hard-wrap Markdown — one paragraph is one line. `make lint` enforces it (rule `WH001`) everywhere, and `make fix` applies it in `.md`. In `.mdx` the generic fixers are deliberately never run, so unwrap by hand there. See [Development → Markdown and MDX](https://wavehouse.dev/development#markdown-and-mdx). ## Code Review diff --git a/Makefile b/Makefile index 3be77d2b..dbc92b20 100644 --- a/Makefile +++ b/Makefile @@ -263,8 +263,11 @@ dev-ts: pnpm-install ## Watch-build SDK (tsup --watch) # production while you edit, and the browser refreshes itself per build. # Slower per change than Astro HMR; the raw dev server remains available as # `pnpm --filter wavehouse-docs run start` when fidelity doesn't matter. +# Serves :4321, walking upward if that's taken (ports are machine-wide, so a +# dev server in another worktree or repo will claim it) — the script prints the +# port it settled on. DOCS_PORT=… moves the starting point. .PHONY: dev-docs -dev-docs: install-playwright-docs build-ts ## Prod-faithful docs dev loop: rebuild-on-save + wrangler dev on :4321 +dev-docs: install-playwright-docs build-ts ## Prod-faithful docs dev loop: rebuild-on-save + wrangler dev on :4321 (next free port if busy) @$(PNPM) --filter $(DOCS_FILTER) run dev # preview-docs serves the production build through wrangler (Cloudflare Workers @@ -371,11 +374,13 @@ lint-md: pnpm-install $(call run,markdownlint,$(PNPM) -s -w run lint:md,run make fix to auto-fix what is fixable) # lint-prose: docs prose quality, owned by misspell — a curated common-typo + -# US-locale (UK → US) checker over the Starlight content (.md + .mdx). Its word -# list is finite and maintained upstream, so it gates with ~zero false positives -# and no project dictionary to babysit. `-error` makes it exit non-zero on -# findings; `make fix` (fix-prose) auto-applies the corrections. Distinct domain -# from markdownlint (*style*) and Biome (JS/TS/JSON) — no overlap. (A full +# US-locale (UK → US) checker over the canonical docs-prose set (see DOCS_PROSE +# and scripts/docs-prose.sh — the Starlight content plus the root governance +# docs). Its word list is finite and maintained upstream, so it gates with +# ~zero false positives and no project dictionary to babysit. `-error` makes it +# exit non-zero on findings; `make fix` (fix-prose) auto-applies the +# corrections. Distinct domain from markdownlint (*style*) and Biome +# (JS/TS/JSON) — no overlap. (A full # dictionary spell-checker, cspell, was trialled and dropped: on these jargon- # dense docs it flagged ~64 legitimate terms and zero real typos — an unbounded # dictionary tax for no signal. Catching novel typos is left to human/LLM @@ -404,6 +409,14 @@ lint-gha: $(ACTIONLINT) $(SHELLCHECK) # classifier behind CI's `changes` job and the local git hooks) against the # canonical change shapes — fast, dependency-free, so the allowlists can't # silently regress. A verify leaf so CI's lint job runs it. +# test-md-rules: fixtures for the repo-local markdownlint rules. They rewrite +# every .md/.mdx on every agent write, and they classify by line shape with no +# parse tree, so an unrecognized construct is corrupted rather than skipped — +# cheap fixtures are the only thing that catches the next shape regression. +.PHONY: test-md-rules +test-md-rules: pnpm-install + $(call run,markdownlint rule tests,node --test scripts/markdownlint-rules/rules.test.mjs,) + .PHONY: test-classify-paths test-classify-paths: $(call run,classify-paths test,scripts/classify-paths.test.sh,) @@ -426,14 +439,26 @@ tidy: ## Verify go.mod/go.sum are tidy (run `make fix` to apply) # fix: apply auto-fixes everywhere, fanned out into three tracks that touch # disjoint files — Go (.go + go.mod/sum), TS/JS/JSON (Biome), Markdown — so they -# run in parallel safely. The Go track is itself a serial chain (tidy → gofumpt → -# goimports → golangci --fix): order matters there, since each rewrites the same -# files and the formatters must settle before lint --fix runs. +# run in parallel safely. Two of the three are themselves serial chains, because +# inside a track every step rewrites the same files: Go is tidy → gofumpt → +# goimports → golangci --fix (the formatters must settle before lint --fix), and +# Markdown is fix-md → fix-prose (markdownlint and misspell both write .md/.mdx, +# so running them concurrently is a lost-update race). .PHONY: fix fix: ## Apply auto-fixes across Go (tidy + gofumpt + goimports + lint --fix) + TS/JSON (Biome) + Markdown (markdownlint) + docs prose (misspell) - @$(MAKE) -j $(JOBS) fix-go fix-ts fix-md fix-prose + @$(MAKE) -j $(JOBS) fix-go fix-ts fix-docs @echo "$(GREEN)==> Done$(RESET)" +# fix-docs: the Markdown track, serial. A wrapper, so `make fix-md` and +# `make fix-prose` still stand on their own. It names pnpm-install even though +# fix-md already does: fix-md is reached through a SUB-make, whose prerequisites +# the parent cannot dedup against fix-ts's, so without this `make -j fix` can +# run two `pnpm install` processes against one node_modules. +.PHONY: fix-docs +fix-docs: pnpm-install + @$(MAKE) fix-md + @$(MAKE) fix-prose + .PHONY: fix-go fix-go: $(GOLANGCI_LINT) @echo "$(CYAN)==> Applying Go auto-fixes (tidy + gofumpt + goimports + lint --fix)...$(RESET)" @@ -447,9 +472,28 @@ fix-ts: pnpm-install @echo "$(CYAN)==> Applying Biome fixes (format + lint + imports)...$(RESET)" @$(PNPM) -w run fix +# fix-md: the generic markdownlint --fix pass reaches **/*.md only and never +# .mdx — the config globs .md, and the .mdx glob lives on `lint:md`, so even a +# bare `markdownlint-cli2 --fix` is safe. +# +# That is the root fix for a whole class of corruption: +# markdownlint parses CommonMark, MDX does not, and where the two disagree a +# generic autofix rewrites the inside of a code block — de-indenting YAML +# comments it reads as headings, autolinking bare URLs. Reporting on that +# disagreement is useful (lint-md still checks .mdx); acting on it is not. +# +# .mdx therefore gets exactly one STRUCTURAL fixer, our own +# scripts/fix-mdx-fences.mjs — misspell still corrects spelling there, since its +# curated list needs no parse. That fixer only ever inserts a blank line next to +# a JSX tag, so its worst failure is a render-neutral blank line rather than +# rewritten code. +# +# The md pass runs twice because it is not a fixpoint in one: WH001's insert +# carries the pre-fix text of the lines it joins, so another rule's fix for a +# joined line is dropped on the first pass. .PHONY: fix-md fix-md: pnpm-install - @echo "$(CYAN)==> Applying markdownlint fixes...$(RESET)" + @echo "$(CYAN)==> Applying MDX structure + markdownlint fixes...$(RESET)" @$(PNPM) -w run fix:md # fix-prose: misspell autofix (common typos + UK → US) over the docs prose. Its @@ -467,9 +511,11 @@ fix-prose: $(MISSPELL) # slowest tool, not the slowest *group* (e.g. golangci no longer drags Biome + # markdownlint along behind it). # -# Leaves (9): tidy, fmt-go (gofumpt), lint-go (golangci), vulncheck on the Go +# Leaves (13): tidy, fmt-go (gofumpt), lint-go (golangci), vulncheck on the Go # side; lint-ts (biome check) + lint-md (markdownlint) + lint-prose (misspell, -# docs spelling) for JS/TS + Markdown + prose; +# docs spelling) + test-md-rules (node --test over the WH001/WH002 fixtures) +# for JS/TS + Markdown + prose; lint-sh (shellcheck), lint-gha (actionlint) and +# test-classify-paths for the tooling; # check-docs (astro check — the only leaf that writes, to docs/.astro/, and # nothing else touches it) and typecheck-ts (tsc --noEmit). It runs lint-ts # (`biome check`) but NOT fmt-ts (`biome format`) — check already covers @@ -483,7 +529,7 @@ verify: ## Run all static checks across the repo (Go + TS + docs, parallelized) @printf "$(GREEN)$(BOLD)✔ All static checks passed$(RESET)\n" .PHONY: verify-parallel -verify-parallel: tidy fmt-go lint-go lint-ts lint-md lint-prose lint-sh lint-gha test-classify-paths vulncheck check-docs typecheck-ts +verify-parallel: tidy fmt-go lint-go lint-ts lint-md lint-prose lint-sh lint-gha test-classify-paths test-md-rules vulncheck check-docs typecheck-ts # typecheck-ts: tsc --noEmit on the SDK. Its own target (was inline in verify's # recipe) so it can run as a parallel leaf of verify-parallel. @@ -611,11 +657,25 @@ DOCS_DIR := docs SDK_NAME := @wavehouse/sdk DOCS_FILTER := wavehouse-docs -# Markdown + MDX prose sources under the Starlight content dir. lint-prose / -# fix-prose hand misspell this explicit list (lazily expanded via `=`, so the -# find only runs when those targets run) rather than a directory — so misspell -# never reads a .ts content-config as text. -DOCS_PROSE = $(shell find $(DOCS_DIR)/src/content -type f \( -name '*.md' -o -name '*.mdx' \) 2>/dev/null) +# The canonical docs-prose set, from the one script that defines it (AGENTS.md +# §DRY). lint-prose / fix-prose hand misspell this explicit file list rather +# than a directory, so it never reads a .ts content-config as text — the +# script's extension filter enforces that now, where a local `find -name` used +# to. That `find` covered only docs/src/content, so README, CONTRIBUTING, +# SECURITY, SUPPORT, CODE_OF_CONDUCT and the SDK readme were being rewritten by +# the on-save hook's misspell pass but never checked by this gate. +# +# Recursively expanded (`=`, not `:=`), so the git call fires only inside the +# lint-prose / fix-prose recipes — `make help` never pays for it. +# +# One asymmetry to know about: the script lists TRACKED files (`git ls-files`), +# while the hook gates on `docs-prose.sh is-match`, a pure path test. So a +# brand-new page that hasn't been `git add`ed is fixed on write but not seen by +# `make fix-prose`. It self-heals — pre-commit stages first, so `make verify` +# does see it — but the symptom is a commit that fails on spelling right after +# a clean `make fix`. Widening the script to `git ls-files -co` would also feed +# untracked drafts to the docs-reviewer, which is why it lists tracked only. +DOCS_PROSE = $(shell bash scripts/docs-prose.sh all 2>/dev/null) # pnpm-install: hidden internal target. Node targets depend on it to ensure # workspace deps are present; on a warm tree `--frozen-lockfile` is a fast diff --git a/README.md b/README.md index c9eace15..129625bf 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,7 @@ gh attestation verify oci://ghcr.io/wave-rf/wavehouse:latest --repo Wave-RF/Wave go install github.com/Wave-RF/WaveHouse/cmd/wavehouse@latest ``` -You'll still need ClickHouse reachable — point WaveHouse at it via `WH_CH_ADDR` (defaults to `localhost:9000`). -See [Configuration](https://wavehouse.dev/configuration). +You'll still need ClickHouse reachable — point WaveHouse at it via `WH_CH_ADDR` (defaults to `localhost:9000`). See [Configuration](https://wavehouse.dev/configuration). ## 🚦 Project status @@ -133,7 +132,7 @@ make dev # hot-reload on .go save > **AI-assisted, human-reviewed.** Much of WaveHouse — code and docs alike — is written with AI assistance ([Claude Code](https://claude.com/claude-code)). Every change, whether AI- or human-authored, goes through the same review gates, tests, and CI before it lands. We note it for transparency: treat the docs as the source of truth, and please [open an issue](https://github.com/Wave-RF/WaveHouse/issues) if anything reads as off or out of date. -The repo ships minimal team-wide [Claude Code](https://claude.com/claude-code) configuration — safety guardrails, a couple of slash commands / subagents, an auto-format hook, and [worktrunk](https://worktrunk.dev) project hooks for parallel agent workflows. Personal preferences (status line, model, allow lists) stay user-level. See [Claude Code & AI agents](docs/src/content/docs/claude-code.md) for setup + reference. `AGENTS.md` at the repo root is the canonical source of truth for project conventions. +The repo ships minimal team-wide [Claude Code](https://claude.com/claude-code) configuration — safety guardrails, a couple of slash commands / subagents, auto-format hooks, and [worktrunk](https://worktrunk.dev) project hooks for parallel agent workflows. Personal preferences (status line, model, allow lists) stay user-level. See [Claude Code & AI agents](docs/src/content/docs/claude-code.md) for setup + reference. `AGENTS.md` at the repo root is the canonical source of truth for project conventions. ## 🤝 Contributing diff --git a/biome.json b/biome.json index 6dcfb89d..e29be7f3 100644 --- a/biome.json +++ b/biome.json @@ -10,7 +10,8 @@ "clients/ts/src/**", "clients/ts/*.{ts,js,mjs,cjs,json}", "tests/e2e/sdk/**/*.{ts,js,mjs,cjs,json}", - "docs/**/*.{ts,js,mjs,cjs,json}" + "docs/**/*.{ts,js,mjs,cjs,json}", + "scripts/**/*.{ts,js,mjs,cjs}" ] }, "formatter": { diff --git a/clients/ts/README.md b/clients/ts/README.md index f4231767..4ec5a9f2 100644 --- a/clients/ts/README.md +++ b/clients/ts/README.md @@ -57,7 +57,7 @@ const wh = createClient({ }); ``` -`baseURL` may include a path prefix (`https://app.example.com/api/warehouse`) when WaveHouse is served under one — see [Serving under a path prefix](https://wavehouse.dev/sdk#serving-under-a-path-prefix). +`baseURL` may include a path prefix (`https://app.example.com/api/wavehouse`) when WaveHouse is served under one — see [Serving under a path prefix](https://wavehouse.dev/sdk#serving-under-a-path-prefix). ### Query Data diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 41318980..e712fffe 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -12,6 +12,7 @@ import starlightLinksValidator from "starlight-links-validator"; import { mermaidTheme } from "./src/config/mermaid-theme.mjs"; import { sidebar } from "./src/config/sidebar.ts"; import { diagramPng } from "./src/integrations/diagram-png.mjs"; +import { rehypeTrademarks } from "./src/plugins/rehype-trademarks.ts"; // Color-agnostic Mermaid plugin (astro-themed-mermaid pkg) + WaveHouse's palette // (src/config/mermaid-theme). Diagram colors are defined once in global.css @@ -31,7 +32,10 @@ export default defineConfig({ // mermaid.rehypeMermaid = rehype-mermaid behind the package's per-diagram // render cache (node_modules/.cache/astro-themed-mermaid/) — rebuilds that // don't change a diagram skip Chromium entirely (~6.5s → ~3.7s per build). - rehypePlugins: [mermaid.rehypeMermaid, rehypeKatex], + // rehypeTrademarks runs last on purpose: it skips the SVG and .katex + // subtrees the two plugins before it produce, so it has to see them already + // rendered rather than as ```mermaid fences and $math$. + rehypePlugins: [mermaid.rehypeMermaid, rehypeKatex, rehypeTrademarks], }, integrations: [ starlight({ diff --git a/docs/scripts/dev.mjs b/docs/scripts/dev.mjs index 68411d66..5a1a5954 100644 --- a/docs/scripts/dev.mjs +++ b/docs/scripts/dev.mjs @@ -5,8 +5,8 @@ * search index, and the starlight-llm-tools outputs only exist in real * builds. So instead of the dev server, this loop runs a full `astro build` * on every save and serves the result through `wrangler dev --live-reload` - * on :4321 — the same Worker + Static Assets pipeline as wavehouse.dev, - * with the browser auto-refreshing when a build lands. + * on :4321 (or the next free port) — the same Worker + Static Assets pipeline + * as wavehouse.dev, with the browser auto-refreshing when a build lands. * * Builds go to a .dev-dist/ staging dir and are synced into dist/ (plain * node fs — no rsync or any other external tool, so WSL/minimal images work) @@ -19,7 +19,10 @@ * available as `pnpm run start` when HMR matters more than fidelity. * * Knobs: - * DOCS_PORT=… serve port (default 4321) + * DOCS_PORT=… first port to try (default 4321). If it's taken the + * loop walks upward to the next free one and prints + * where it landed — wrangler itself would just die, + * see findFreePort() below. * DOCS_WATCH_STRICT=1 keep starlight-links-validator in watch builds — * a broken link then fails the build loudly here * instead of waiting for CI. Off by default because @@ -31,13 +34,35 @@ import { spawn } from "node:child_process"; import { existsSync, watch } from "node:fs"; import { cp, readdir, rm, stat } from "node:fs/promises"; +import { createServer as createNetServer } from "node:net"; import { join, resolve } from "node:path"; const ROOT = resolve(import.meta.dirname, ".."); const BIN = join(ROOT, "node_modules", ".bin"); const STAGING = join(ROOT, ".dev-dist"); const DIST = join(ROOT, "dist"); -const PORT = process.env.DOCS_PORT ?? "4321"; +const DEFAULT_PORT = 4321; +const MAX_PORT = 65535; +const PORT_TRIES = 20; + +/* DOCS_PORT has to be a real port before it reaches the scan: "" and "0" would + * bind an ephemeral port and announce http://localhost:0, anything non-numeric + * becomes NaN (the loop then runs zero times and reports "NaN–NaN"), and + * anything out of range throws ERR_SOCKET_BAD_PORT from inside the probe. */ +function parsePort(raw) { + if (raw === undefined || raw === "") return DEFAULT_PORT; + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > MAX_PORT) return null; + return port; +} + +const PORT_START = parsePort(process.env.DOCS_PORT); +if (PORT_START === null) { + console.log( + `\x1b[36m[dev-docs]\x1b[0m \x1b[1;31mDOCS_PORT must be an integer 1-${MAX_PORT}, got ${JSON.stringify(process.env.DOCS_PORT)}\x1b[0m\x07`, + ); + process.exit(1); +} const DEBOUNCE_MS = 300; const log = (msg) => console.log(`\x1b[36m[dev-docs]\x1b[0m ${msg}`); @@ -45,6 +70,55 @@ const log = (msg) => console.log(`\x1b[36m[dev-docs]\x1b[0m ${msg}`); const fail = (msg) => console.log(`\x1b[36m[dev-docs]\x1b[0m \x1b[1;31m${msg}\x1b[0m\x07`); const STRICT = Boolean(process.env.DOCS_WATCH_STRICT); +/* Pick the port BEFORE handing it to wrangler. + * + * `wrangler dev` hunts for a free port when you don't name one, but treats an + * explicit `--port` as strict — it dies with a raw kj bind exception rather + * than moving. We have to pass `--port` (the URL is logged below, and bare + * wrangler would land somewhere we couldn't announce), so the hunting is ours + * to do. `astro dev` never enters into it: this loop serves builds through the + * Worker, so Vite's own port-hunting is not in the path. + * + * Ports are machine-wide, not per-worktree or per-repo, so the usual collision + * is a dev server from an entirely different checkout. + * + * Both stacks get probed because wrangler binds 127.0.0.1 and [::1] as + * separate sockets, and the common squatter — an `astro dev` elsewhere — holds + * only [::1]. Probing IPv4 alone would call the port free and we would fail on + * the v6 bind anyway, which is exactly the failure this replaces. */ +/** Bind errors that mean "this host has no usable IPv6", not "the port is taken". */ +const IPV6_UNAVAILABLE = new Set(["EADDRNOTAVAIL", "EAFNOSUPPORT", "EPROTONOSUPPORT"]); + +function portFree(port, host) { + return new Promise((resolvePort) => { + const probe = createNetServer(); + // A host without usable IPv6 must not veto the port — but "without usable + // IPv6" surfaces as more than one code. EADDRNOTAVAIL is ::1 merely not + // being configured; when IPv6 is compiled out of the runtime altogether + // (ipv6.disable=1 kernels, WSL1, images built without AF_INET6) the + // socket() call fails first and libuv reports EAFNOSUPPORT or + // EPROTONOSUPPORT. Allowing only the first would fail every candidate on + // those hosts and report "no free port" where nothing is holding one. + // + // Everything else — EADDRINUSE, EACCES, a bad host, any of these on an + // address other than ::1 — means we cannot claim the port. + probe.once("error", (err) => resolvePort(host === "::1" && IPV6_UNAVAILABLE.has(err.code))); + probe.listen({ port, host, exclusive: true }, () => probe.close(() => resolvePort(true))); + }); +} + +/** Last port the scan will try — clamped, since start+tries can exceed the range. */ +const lastPortFor = (start, tries) => Math.min(start + tries - 1, MAX_PORT); + +async function findFreePort(start, tries) { + for (let port = start, last = lastPortFor(start, tries); port <= last; port++) { + if ((await portFree(port, "127.0.0.1")) && (await portFree(port, "::1"))) { + return port; + } + } + return null; +} + let activeBuild = null; function run(cmd, args, opts = {}) { return new Promise((done) => { @@ -204,7 +278,22 @@ if (existsSync(join(DIST, "index.html"))) { // A signal during the cold-start build means we're done before serving starts. if (shuttingDown) process.exit(); -wrangler = spawn(join(BIN, "wrangler"), ["dev", "--live-reload", "--port", PORT], { +// Resolved here rather than at startup so the gap between "it was free" and +// "wrangler has it" stays as small as possible — a cold-start build is minutes +// of window during which someone else could take the port. +const PORT = await findFreePort(PORT_START, PORT_TRIES); +if (PORT === null) { + fail( + `no free port in ${PORT_START}–${lastPortFor(PORT_START, PORT_TRIES)}. ` + + `Stop one of the servers holding them, or set DOCS_PORT to a clear range.`, + ); + process.exit(1); +} +if (PORT !== PORT_START) { + log(`port ${PORT_START} is busy (often a dev server from another checkout) — using ${PORT}`); +} + +wrangler = spawn(join(BIN, "wrangler"), ["dev", "--live-reload", "--port", String(PORT)], { cwd: ROOT, stdio: "inherit", }); diff --git a/docs/src/components/CloudCta.astro b/docs/src/components/CloudCta.astro new file mode 100644 index 00000000..30a9f21d --- /dev/null +++ b/docs/src/components/CloudCta.astro @@ -0,0 +1,228 @@ +--- +// "We'll run this for you" callout pointing at WaveHouse Cloud. +// +// Placed at the end of the ops-heavy pages (deployment, durability, access +// control, …) via the `cloudCta` frontmatter flag, which Footer.astro reads — +// that indirection is what lets plain-.md pages carry the CTA without being +// converted to .mdx. .mdx pages can also import it directly for inline use. +// +// The link is built through cloudLink() so it always carries UTMs and keeps its +// Referer; see src/config/outbound.ts for why both matter. +import { cloudLink, REL_KEEP_REFERRER } from "../config/outbound"; +import ExternalIcon from "./ExternalIcon.astro"; + +interface Props { + /** + * Where on the site this instance lives — "docs-deployment", + * "homepage-closer", … Rides along as utm_content and as a PostHog property, + * so it must be unique per placement or the two can't be told apart. + */ + placement: string; + /** Override the default heading. */ + title?: string; + /** Override the default body copy — use it to name what THIS page stops being your problem. */ + body?: string; + /** "panel" for in-content use, "band" for the full-width homepage treatment. */ + variant?: "panel" | "band"; +} + +const { + placement, + title = "Don't want to run this yourself?", + body = + "WaveHouse Cloud runs both halves for you — managed ClickHouse plus the WaveHouse gateway, with schema-aware ingest, SSE streaming, and tiered caching. Same open-source binary, zero ops.", + variant = "panel", +} = Astro.props; + +const href = cloudLink(placement); +--- + + + + + + diff --git a/docs/src/components/ExternalIcon.astro b/docs/src/components/ExternalIcon.astro new file mode 100644 index 00000000..70a9a7e6 --- /dev/null +++ b/docs/src/components/ExternalIcon.astro @@ -0,0 +1,50 @@ +--- +// The ↗ "this link leaves the site" glyph. +// +// One definition, because it had drifted into three: an inline SVG in +// Hero.astro (for Starlight's `icon: external`), a second copy in +// CloudCta.astro, and a bare "↗" character in LiveDemo.astro — which is worse +// than a duplicate, since a screen reader reads that character aloud as "north +// east arrow" in the middle of the link text. +// +// Sized in em rather than px so it tracks whatever it sits beside; 0.8667em is +// the 13px the button call sites drew, expressed against their 0.9375rem label. +// aria-hidden throughout: the link's own text carries the meaning. +interface Props { + class?: string; + /** + * Stroke weight. 2.5 matches the hero/button labels it was drawn for; drop it + * for large or light text, where 2.5 reads as a blob. + */ + weight?: number; +} +const { class: className, weight = 2.5 } = Astro.props; +--- + + + + diff --git a/docs/src/components/Footer.astro b/docs/src/components/Footer.astro index bfa1609f..d525d944 100644 --- a/docs/src/components/Footer.astro +++ b/docs/src/components/Footer.astro @@ -6,10 +6,19 @@ import EditLink from "virtual:starlight/components/EditLink"; import LastUpdated from "virtual:starlight/components/LastUpdated"; import Pagination from "virtual:starlight/components/Pagination"; +import { + REL_EXTERNAL, + REL_KEEP_REFERRER, + cloudLink, + waveRfLink, +} from "../config/outbound"; +import { pageText } from "../config/trademarks"; +import CloudCta from "./CloudCta.astro"; import Logo from "./Logo.astro"; import MermaidZoom from "./MermaidZoom.astro"; import ReadingProgress from "./ReadingProgress.astro"; import ScrollHints from "./ScrollHints.astro"; +import Trademarks from "./Trademarks.astro"; const year = new Date().getFullYear(); @@ -19,14 +28,34 @@ const year = new Date().getFullYear(); // - no sidebar → full-width, multi-column "marketing" footer (room to breathe) // - has sidebar → slim, content-aligned footer (a big band crammed next to a // fixed sidebar reads as broken; a slim one reads as native) -const { hasSidebar } = Astro.locals.starlightRoute; +const { hasSidebar, entry } = Astro.locals.starlightRoute; const repo = "https://github.com/Wave-RF/WaveHouse"; + +// Pages opt into the WaveHouse Cloud callout with `cloudCta` frontmatter (see +// src/content.config.ts). Rendering it here — first in the footer, i.e. right +// after the page content and before the edit-link/prev-next chrome — is what +// keeps the plain-.md ops pages able to carry it without becoming .mdx. +const cloudCta = entry.data.cloudCta; +const cloudCtaProps = cloudCta === true ? {} : cloudCta; + +// The page slug names the placement, so PostHog can tell "the deployment-page +// CTA converts, the architecture one doesn't" without any per-page wiring. +const cloudCtaPlacement = `docs-${entry.id || "index"}`; + +// Everything the page turns into visible words, for the per-page trademark +// notices. pageText() picks the frontmatter fields that actually render (hero +// tagline, title, this page's Cloud CTA copy) and leaves out the ones that +// don't — `description` is -only, sidebar labels are nav chrome. Naming +// a mark in the footer that the reader never sees on the page would make the +// notice wrong in the other direction. +const trademarkSource = pageText(entry.data, entry.body ?? ""); --- { hasSidebar ? ( @@ -73,11 +115,16 @@ const repo = "https://github.com/Wave-RF/WaveHouse";