diff --git a/web/.gitignore b/web/.gitignore index 40543b3..930f513 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -15,6 +15,9 @@ /.next/ /out/ +# pagefind search index — generated by the postbuild script +/public/pagefind/ + # production /build diff --git a/web/README.md b/web/README.md index 81262c3..5ef4ad3 100644 --- a/web/README.md +++ b/web/README.md @@ -15,11 +15,13 @@ extracted into its own repository later without rewrites. ## Pages -- `/` — landing (hero with animated REPL, features, architecture, roadmap, SDK switcher, SQL surface, desktop showcase, blog series, footer) +- `/` — landing (hero with animated REPL, features, architecture, roadmap, SDK switcher, SQL surface, desktop showcase, blog series, FAQ with `FAQPage` JSON-LD, footer) - `/playground` — in-browser SQL playground: the full engine compiled to WebAssembly, with a CodeMirror editor, sample datasets, HNSW vector search, and OPFS session persistence. The WASM bundle is a pinned copy of `sdk/wasm/pkg/` vendored into `public/playground/pkg/`. See [`../examples/wasm-playground/README.md`](../examples/wasm-playground/README.md). -- `/docs` — Getting Started page (sticky sidebar nav + on-page TOC) +- `/docs` — Getting Started page (sticky sidebar nav + on-page TOC, Pagefind search box, heading anchor links, related-links footer, helpful-vote widget) +- `/docs.md` — the same docs content served as raw `text/markdown` for AI crawlers that prefer markdown over rendered HTML +- `/llms.txt` + `/llms-full.txt` — LLM-discoverability surfaces per the [llms.txt convention](https://llmstxt.org/); see "LLM surfaces" below - `/blog` — index of long-form posts pulled from `content/blog/*.mdx` -- `/blog/[slug]` — per-post detail page (MDX rendered server-side, `Article` JSON-LD, breadcrumb JSON-LD, dynamic OG image, prev/next navigation) +- `/blog/[slug]` — per-post detail page (MDX rendered server-side, `Article` JSON-LD, breadcrumb JSON-LD, dynamic OG image, on-page ToC, heading anchors, related posts, helpful-vote widget, prev/next navigation) - `/blog/tags/[tag]` — tag pages (one per unique frontmatter tag) - `/blog/rss.xml` — RSS 2.0 feed @@ -51,14 +53,26 @@ Each public route ships full search/social metadata. The pieces: the apple icon are rasterized from the same play-glyph mark used in [`src/lib/og.tsx`](src/lib/og.tsx) and `.brand-mark`; regenerate them from `icon.svg` (e.g. with `sharp`) if the mark ever changes. -- **`/sitemap.xml` + `/robots.txt`** — Next 15 metadata routes - ([`src/app/sitemap.ts`](src/app/sitemap.ts), - [`src/app/robots.ts`](src/app/robots.ts)). Add a route to the `ROUTES` - list when shipping a new page. -- **JSON-LD structured data** — `SoftwareApplication` schema on the landing - page, `BreadcrumbList` on `/docs`, `Blog` on `/blog`, and +- **`/sitemap.xml`** — Next 15 metadata route + ([`src/app/sitemap.ts`](src/app/sitemap.ts)). Add a route to the + `STATIC_ROUTES` list when shipping a new page. +- **`/robots.txt`** — a hand-rolled route handler + ([`src/app/robots.txt/route.ts`](src/app/robots.txt/route.ts)) rather + than the typed metadata route, so it can reference the sitemap *and* the + `llms.txt` surfaces (Next's `MetadataRoute.Robots` can't emit comments). +- **JSON-LD structured data** — `SoftwareApplication` + `FAQPage` schema on + the landing page, `BreadcrumbList` on `/docs`, `Blog` on `/blog`, and `BlogPosting` + `BreadcrumbList` on each `/blog/`. Validate via Google's [Rich Results Test](https://search.google.com/test/rich-results). + The FAQ lives in [`src/components/faq.tsx`](src/components/faq.tsx) — the + visible `

`/`

` pairs and the JSON-LD are generated from the same + array, so they can't drift apart. +- **Heading anchors** — every docs `h2` (via the local `H2` helper in + [`src/app/docs/page.tsx`](src/app/docs/page.tsx)) and every blog heading + (via `rehype-slug` + `rehype-autolink-headings` in + [`src/components/blog-mdx.tsx`](src/components/blog-mdx.tsx)) carries a + hover-visible `#` link, so sections are shareable and quotable with + anchors. - **Search Console verification** — fill in the placeholder tokens in `metadata.verification` ([`src/app/layout.tsx`](src/app/layout.tsx)) once Google Search Console + Bing Webmaster Tools issue them. @@ -72,6 +86,49 @@ export lives in [`seo/keywords.md`](seo/keywords.md). When rewriting a page's headline or meta description, update the corresponding entry in that sheet so future rewrites stay coordinated. +## Docs search (Pagefind) + +`/docs` ships a client-side search box backed by +[Pagefind](https://pagefind.app/) — static, serverless, no account needed. + +- The index is generated by the `postbuild` script (`package.json`): + `pagefind --site .next/server/app --output-path public/pagefind`. It + indexes the **prerendered HTML** that `next build` leaves in + `.next/server/app`, so it runs after every production build (Vercel + included — npm runs `postbuild` automatically after `build`). +- Only elements under a `data-pagefind-body` attribute are indexed — the + docs main column and blog articles opt in; everything else (landing, + playground, nav, footers) stays out of the index. `data-pagefind-ignore` + carves out the CTA/related/vote footers. +- The UI is the custom [`src/components/docs-search.tsx`](src/components/docs-search.tsx) + (Pagefind's JS API, not its default UI) so it matches the site's dark + styling. Raw result URLs point at the `.html` files Pagefind saw + (`/docs.html`) and are mapped back to routes in `cleanUrl`. +- `public/pagefind/` is gitignored — it's a build artifact. In `next dev` + there is no index; the box degrades to a hint to run a build. + +## LLM surfaces + +Three build-time-generated plain-text routes make the site legible to AI +crawlers (and are referenced as comments from `robots.txt`): + +- **`/llms.txt`** ([`src/app/llms.txt/route.ts`](src/app/llms.txt/route.ts)) — + curated index per [llmstxt.org](https://llmstxt.org/): project name + + tagline, then sections of absolute links with one-line summaries (docs, + playground, every blog post, GitHub/docs.rs/registries). +- **`/llms-full.txt`** ([`src/app/llms-full.txt/route.ts`](src/app/llms-full.txt/route.ts)) — + the full docs markdown + every blog post concatenated as one markdown + file. +- **`/docs.md`** ([`src/app/docs.md/route.ts`](src/app/docs.md/route.ts)) — + the docs page as raw `text/markdown`. + +All three are `force-static` (generated at build time) and built by +[`src/lib/llms.ts`](src/lib/llms.ts). The markdown source for the docs +content is [`content/docs/getting-started.md`](content/docs/getting-started.md) +— **it mirrors `src/app/docs/page.tsx`**; when you change a docs section, +update the matching section there too. Blog content needs no mirroring (the +MDX sources are the truth). + ## Local development ```sh @@ -93,7 +150,8 @@ npm run lint # next lint (ESLint) ``` web/ ├── content/ -│ └── blog/ # MDX posts (one .mdx file per post; frontmatter at top) +│ ├── blog/ # MDX posts (one .mdx file per post; frontmatter at top) +│ └── docs/ # markdown mirror of /docs — feeds /docs.md + /llms-full.txt ├── seo/ │ └── keywords.md # keyword research + per-page primary/secondary registry (SQLR-33) ├── src/ @@ -105,10 +163,15 @@ web/ │ │ ├── docs/page.tsx # /docs │ │ ├── blog/ # /blog index, [slug] detail, tags/[tag], rss.xml │ │ ├── sitemap.ts # /sitemap.xml — enumerates static + per-post + per-tag URLs -│ │ └── robots.ts # /robots.txt +│ │ ├── robots.txt/ # /robots.txt route handler (sitemap + llms.txt refs) +│ │ ├── llms.txt/ # /llms.txt — curated LLM index (llmstxt.org) +│ │ ├── llms-full.txt/ # /llms-full.txt — full docs+blog markdown concat +│ │ └── docs.md/ # /docs.md — docs page as raw markdown │ ├── components/ # one .tsx per landing section (hero, features, roadmap, …) │ └── lib/ +│ ├── analytics.ts # useTrack() — PostHog capture, no-ops without the key │ ├── blog.ts # MDX loader: frontmatter parsing, post enumeration, tag helpers +│ ├── llms.ts # builders for /llms.txt, /llms-full.txt, /docs.md │ ├── og.tsx # shared OpenGraph frame │ ├── site.ts # SITE constants (version, repo URL, social links) │ └── utils.ts # shadcn cn() helper @@ -277,6 +340,20 @@ PostHog is wired up in two places: If the env var is absent the provider is omitted and the middleware falls through to `NextResponse.next()`. +Beyond pageviews/autocapture, two custom events (SQLR-36) flow through the +`useTrack()` hook in [`src/lib/analytics.ts`](src/lib/analytics.ts), which +no-ops when the key is absent: + +- **`docs-search-query`** — fired from the docs search box once typing + settles (~1.2 s debounce); properties: `query`, `results` (count). Zero- + result queries are the signal for missing docs. +- **`docs-helpful-vote`** — fired by the "Was this page helpful?" widget on + `/docs` and every blog post; properties: `path`, `helpful` (boolean). No + PII either way. + +Worth a weekly skim in PostHog: most-visited docs pages, searches with +`results = 0`, and pages accumulating `helpful = false` votes. + ### Privacy / compliance follow-ups The current wiring uses PostHog defaults — autocapture + cookies — and diff --git a/web/content/blog/shipping-concurrent-writes-mvcc-v010.mdx b/web/content/blog/shipping-concurrent-writes-mvcc-v010.mdx index 729f844..20c9d35 100644 --- a/web/content/blog/shipping-concurrent-writes-mvcc-v010.mdx +++ b/web/content/blog/shipping-concurrent-writes-mvcc-v010.mdx @@ -229,7 +229,7 @@ distinguished from page frames by the sentinel `page_num = u32::MAX` The frame body encodes the commit timestamp plus a record stream of the resolved write-set: -``` +```text ┌────────┬────────┬─────────────────────────────────────────────────┐ │ offset │ length │ content │ ├────────┼────────┼─────────────────────────────────────────────────┤ diff --git a/web/content/docs/getting-started.md b/web/content/docs/getting-started.md new file mode 100644 index 0000000..2aff55a --- /dev/null +++ b/web/content/docs/getting-started.md @@ -0,0 +1,371 @@ +# SQLRite docs — getting started with the embedded Rust database + +> SQLRite is an embedded SQL + vector database in Rust. This page is a +> ten-minute tour from `cargo install` to a persistent on-disk `.sqlrite` +> file — transactions, JOINs, HNSW vector search, BM25 full-text, and the +> MCP server. Every SDK wraps the same engine. + + + +## Install + +SQLRite ships as a CLI binary, a Rust library, an MCP stdio server, and five +language SDKs. Pick whichever matches your project: + +```sh +# CLI / REPL — drop into a SQL prompt +cargo install sqlrite-engine + +# MCP stdio server +cargo install sqlrite-mcp + +# Rust library — imported as `use sqlrite::…` +cargo add sqlrite-engine + +# Python · Node · Go +pip install sqlrite +npm install @joaoh82/sqlrite +go get github.com/joaoh82/rust_sqlite/sdk/go +``` + +Prebuilt installers for the desktop GUI (macOS .dmg, Windows .msi, Linux +AppImage / .deb / .rpm) are attached to every release on GitHub. Installers +are unsigned until Phase 6.1 — see the README for first-launch steps. + +## Your first database + +Create a file-backed database and run some SQL. Everything works against an +in-memory or on-disk database — the only difference is whether you pass a +path. + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + age INTEGER +); +INSERT INTO users (name, age) VALUES ('alice', 30); +SELECT * FROM users; +``` + +## Using the REPL + +The REPL is built on rustyline and supports history, syntax highlighting, +bracket matching, and multi-line input. Useful meta commands: + +- `.help` — list every meta command +- `.open app.sqlrite` — open or create a file-backed database; auto-save flips on from this point +- `.save app.sqlrite` — explicit flush (rarely needed once `.open` is in play) +- `.tables` — list every table in the current database +- `.ask` — natural-language → SQL via the configured LLM backend (requires `SQLRITE_LLM_API_KEY`) +- `.exit` — leave the prompt + +Pass `--readonly` to open the database under a shared lock — multiple +read-only sessions can coexist on the same file. + +## Persistence & the WAL + +SQLRite stores each database as one `.sqlrite` file plus a sidecar +`.sqlrite-wal`. Pages are 4 KiB; rows live in cell-based pages with a +slot directory; oversized rows spill into an overflow chain. + +Commits append a frame per dirty page to the WAL plus a final commit frame +carrying the new page-0 header. The main file stays frozen between +checkpoints — auto-checkpointing fires past 100 frames. + +Crash safety: torn or partial trailing WAL frames are silently truncated at +the boundary; the decoded page-0 frame overrides any stale main-file header +on reopen. + +## Transactions + +SQLRite supports real `BEGIN` / `COMMIT` / `ROLLBACK` with snapshot +isolation. Single level — no savepoints yet. + +```sql +BEGIN; +UPDATE users SET age = age + 1 WHERE name = 'alice'; +DELETE FROM users WHERE age < 18; +ROLLBACK; -- everything since BEGIN is discarded +``` + +## JOINs + +All four SQL-standard JOIN flavors are supported with explicit `ON` +conditions: `INNER JOIN`, `LEFT [OUTER] JOIN`, `RIGHT [OUTER] JOIN`, and +`FULL [OUTER] JOIN`. Aliases work; multi-join chains left-fold; self-joins +require an alias on at least one side. + +```sql +SELECT c.name, o.total +FROM customers AS c +LEFT OUTER JOIN orders AS o + ON c.id = o.customer_id +WHERE o.id IS NULL; -- anti-join: customers with no orders +``` + +The executor uses a plain nested-loop driver. `ON`, `USING (col)`, +`NATURAL`, and `CROSS JOIN` are all supported; comma-separated FROMs +(`FROM a, b`) are not — use an explicit `JOIN` / `CROSS JOIN`. Aggregates, +`GROUP BY`, `DISTINCT`, and `HAVING` compose over join results. + +## GROUP BY & aggregates + +`COUNT(*)`, `COUNT(col)`, `COUNT(DISTINCT col)`, `SUM`, `AVG`, `MIN`, `MAX` +with optional `GROUP BY` on bare column names. Integer `SUM` stays integer +until a `REAL` arrives or `i64` overflows; `AVG` returns `REAL` (or `NULL` +on empty groups); `MIN` / `MAX` skip NULLs. + +```sql +SELECT dept, COUNT(*), AVG(salary) +FROM employees +WHERE active = TRUE +GROUP BY dept +HAVING COUNT(*) > 1 +ORDER BY COUNT(*) DESC; +``` + +`DISTINCT` applies after projection (and after aggregation, when both +apply). `LIKE` / `NOT LIKE` / `ILIKE` use SQLite-style ASCII case folding. +`IN (literal-list)` uses three-valued logic. `HAVING` filters groups after +aggregation and requires `GROUP BY`. + +## ALTER TABLE / DROP / VACUUM + +Schema evolution is one operation per statement (SQLite parity): + +```sql +ALTER TABLE users RENAME TO accounts; +ALTER TABLE accounts RENAME COLUMN name TO display_name; +ALTER TABLE accounts ADD COLUMN verified BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE accounts DROP COLUMN legacy_field; + +DROP TABLE IF EXISTS stale_logs; +DROP INDEX IF EXISTS idx_old_search; +``` + +Released pages go onto a persisted free-list — subsequent `CREATE TABLE` / +`INSERT` reuses them instead of growing the file. Auto-VACUUM kicks in when +the free-list crosses 25% of `page_count`. Manual: `VACUUM;`. + +## Prepared statements + +Every executable statement accepts `?` placeholders anywhere a value +literal is allowed. The Rust API: + +```rust +use sqlrite::{Connection, Value}; + +let mut conn = Connection::open("app.sqlrite")?; +let mut ins = conn.prepare_cached( + "INSERT INTO users (name, age) VALUES (?, ?)", +)?; +ins.execute_with_params(&[Value::Text("alice".into()), Value::Integer(30)])?; +ins.execute_with_params(&[Value::Text("bob".into()), Value::Integer(25)])?; + +let stmt = conn.prepare_cached("SELECT name FROM users WHERE age > ?")?; +let rows = stmt + .query_with_params(&[Value::Integer(26)])? + .collect_all()?; +``` + +`prepare_cached` keeps a per-connection LRU plan cache (default cap 16; +tune via `set_prepared_cache_capacity`). `Value::Vector(Vec)` binds +where a bracket-array literal would normally appear — so prepared k-NN +queries still take the HNSW shortcut. Named placeholders (`:foo`, `$1`) +aren't supported yet. + +## PRAGMA + +`PRAGMA ;` reads, `PRAGMA = ;` writes. The first wired +pragma is `auto_vacuum`: + +```sql +PRAGMA auto_vacuum; -- read; renders a single-row result +PRAGMA auto_vacuum = 0.5; -- arm the trigger at 50% +PRAGMA auto_vacuum = 0; -- arm at 0% (compact on any released page) +PRAGMA auto_vacuum = OFF; -- disable; equivalent: NONE, 'OFF', 'NONE' +``` + +Out-of-range values, `NaN`, ±∞, and unknown identifiers are rejected with +typed errors. The setting is per-`Connection` runtime state and isn't +persisted in the file header. + +## Vector search + +SQLRite supports a `VECTOR(N)` column type with cosine, dot-product, and L2 +distance. Build an HNSW index for sub-linear k-NN queries. + +```sql +CREATE TABLE docs (id INTEGER PRIMARY KEY, body TEXT, embedding VECTOR(384)); +CREATE INDEX docs_emb ON docs(embedding) USING HNSW; + +SELECT id, vec_distance_cosine(embedding, ?) AS dist +FROM docs +ORDER BY dist ASC LIMIT 10; +``` + +## Full-text search + +Phase 8 ships an FTS5-style inverted index with BM25 scoring. `fts_match()` +filters and `bm25_score()` ranks; the optimizer recognizes the canonical +pattern and probes the FTS index directly. + +```sql +CREATE INDEX docs_body ON docs(body) USING FTS; + +SELECT id, body, bm25_score(body, 'rust database') AS score +FROM docs +WHERE fts_match(body, 'rust database') +ORDER BY score DESC LIMIT 10; +``` + +Compose with vector distance for hybrid retrieval — see +`examples/hybrid-retrieval` in the repository. + +## Desktop app + +The desktop client is a Svelte 5 + Tauri 2.0 GUI. Three-pane layout: header +(file pickers), sidebar (tables + schema), and a query editor with line +numbers, `⌘/` comment toggle, and selection-aware Run. Download a prebuilt +installer from the latest GitHub release, or run from source: + +```sh +cd desktop +npm install +npm run tauri dev +``` + +## MCP server + +`sqlrite-mcp` exposes a SQLRite database as a Model Context Protocol stdio +server. Eight tools out of the box: `list_tables`, `describe_table`, +`query`, `execute`, `schema_dump`, `vector_search`, `bm25_search`, and +`ask`. Wire it into Claude Code, Cursor, or any MCP client. + +```sh +sqlrite-mcp /path/to/app.sqlrite +sqlrite-mcp --read-only /path/to/app.sqlrite +``` + +## Rust crate + +```rust +use sqlrite::Connection; + +fn main() -> sqlrite::Result<()> { + let mut conn = Connection::open("app.sqlrite")?; + conn.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)")?; + conn.execute("INSERT INTO users (name) VALUES ('alice')")?; + for row in conn.query("SELECT id, name FROM users")? { + let id: i64 = row.get(0)?; + let name: String = row.get(1)?; + println!("{id}: {name}"); + } + Ok(()) +} +``` + +## Python + +```python +import sqlrite + +with sqlrite.connect("app.sqlrite") as conn: + cur = conn.cursor() + cur.execute("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)") + cur.execute("INSERT INTO users (name) VALUES ('alice')") + for row in cur.execute("SELECT id, name FROM users").fetchall(): + print(row) +``` + +## Node.js + +```js +import { Database } from "@joaoh82/sqlrite"; + +const db = new Database("app.sqlrite"); +db.exec(`CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)`); +db.prepare("INSERT INTO users (name) VALUES (?)").run("alice"); +console.log(db.prepare("SELECT id, name FROM users").all()); +``` + +## Go + +```go +import ( + "database/sql" + _ "github.com/joaoh82/rust_sqlite/sdk/go" +) + +db, _ := sql.Open("sqlrite", "app.sqlrite") +db.Exec("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, name TEXT)") +db.Exec("INSERT INTO users (name) VALUES (?)", "alice") +rows, _ := db.Query("SELECT id, name FROM users") +``` + +## C FFI + +The C ABI is stable and ships with a cbindgen-generated `sqlrite.h`. Opaque +pointer types, thread-local last-error, split `sqlrite_execute` (DDL/DML) +vs `sqlrite_query` / `sqlrite_step` (SELECT iteration). + +## WASM + +The engine compiles to a ~1.8 MB / 500 KB-gzipped WebAssembly module. Three +`wasm-pack` targets (web, bundler, nodejs). The whole database can live in +a browser tab. + +```js +import init, { Database } from "@joaoh82/sqlrite-wasm"; + +await init(); +const db = new Database(); +db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)"); +``` + +## Supported SQL + +The complete reference lives in `docs/supported-sql.md` in the repo. Quick +summary: + +- **DDL:** `CREATE TABLE` with `PRIMARY KEY` / `UNIQUE` / `NOT NULL` / `DEFAULT `; `CREATE [UNIQUE] INDEX` with `IF NOT EXISTS`, `USING HNSW`, and `USING FTS`; `ALTER TABLE` (RENAME TO / RENAME COLUMN / ADD COLUMN / DROP COLUMN); `DROP TABLE` and `DROP INDEX` with `IF EXISTS`; `VACUUM` +- **DML:** `INSERT` (multi-row VALUES), `SELECT` (projection / `DISTINCT` / `WHERE` / `GROUP BY` / `ORDER BY` / `LIMIT`), `UPDATE`, `DELETE` +- **JOINs:** `INNER`, `LEFT OUTER`, `RIGHT OUTER`, `FULL OUTER` with explicit `ON` +- **Aggregates:** `COUNT(*)`, `COUNT(DISTINCT col)`, `SUM`, `AVG`, `MIN`, `MAX` +- **Predicates:** comparisons, `AND / OR / NOT`, arithmetic, `||`, `IS NULL` / `IS NOT NULL`, `LIKE / NOT LIKE / ILIKE`, `IN (literal-list)` / `NOT IN` +- **Transactions:** `BEGIN` / `COMMIT` / `ROLLBACK` with snapshot isolation; auto-rollback on COMMIT disk failure +- **Prepared statements:** positional `?` binding via `prepare_cached` + `execute_with_params` / `query_with_params`; per-connection LRU plan cache +- **Pragmas:** `PRAGMA auto_vacuum` (read/write); extensible dispatcher +- **Types:** INTEGER, TEXT, REAL, BOOLEAN, NULL, `VECTOR(N)`, `JSON` +- **Functions:** `vec_distance_cosine / dot / l2`, `fts_match`, `bm25_score`, `json_extract`, `json_type`, `json_array_length`, `json_object_keys` + +## Errors & limits + +Every malformed input path returns a typed `SQLRiteError` instead of +panicking. Common error categories: + +- **Parse** — bad SQL syntax, with column hints from `sqlparser` +- **Schema** — duplicate columns, missing tables, unknown identifiers +- **Type** — `'foo'` being inserted into an `INTEGER` column +- **Constraint** — UNIQUE / PRIMARY KEY violations, NOT NULL with no default +- **I/O** — file already locked, WAL truncation, disk full mid-commit + +Single-writer rule: multiple read-only openers coexist; any writer excludes +all readers (POSIX flock semantics — readers OR a writer, never both at +once). + +## Contributing + +SQLRite welcomes pull requests. For larger changes open an issue first. The +codebase is documented phase-by-phase in `docs/` — start at +`docs/_index.md`. + +- Build & test: `cargo test` +- Lint: `cargo fmt && cargo clippy` +- Run the example: `cargo run --example quickstart` diff --git a/web/package-lock.json b/web/package-lock.json index e723626..514d297 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -16,13 +16,16 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.2", + "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "lucide-react": "^0.469.0", "next": "^15.5.18", "next-mdx-remote": "^6.0.0", "react": "19.0.0", "react-dom": "19.0.0", + "rehype-autolink-headings": "^7.1.0", "rehype-pretty-code": "^0.14.3", + "rehype-slug": "^6.0.0", "shiki": "^4.0.2", "tailwind-merge": "^2.6.0" }, @@ -33,6 +36,7 @@ "@types/react-dom": "^19.0.3", "eslint": "^9.20.0", "eslint-config-next": "^15.5.18", + "pagefind": "^1.5.2", "postcss": "^8.5.1", "tailwindcss": "^4.0.6", "typescript": "^5.7.3" @@ -1464,6 +1468,104 @@ "node": ">=14" } }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@posthog/core": { "version": "1.28.7", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.28.7.tgz", @@ -4522,6 +4624,12 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4754,6 +4862,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-heading-rank": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-heading-rank/-/hast-util-heading-rank-3.0.0.tgz", + "integrity": "sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-parse-selector": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", @@ -7141,6 +7275,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "dev": true, + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7592,6 +7745,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/rehype-autolink-headings": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/rehype-autolink-headings/-/rehype-autolink-headings-7.1.0.tgz", + "integrity": "sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rehype-parse": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", @@ -7642,6 +7813,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-slug": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-slug/-/rehype-slug-6.0.0.tgz", + "integrity": "sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "github-slugger": "^2.0.0", + "hast-util-heading-rank": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-mdx": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", diff --git a/web/package.json b/web/package.json index 46f35fb..f5cb9db 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev", "build": "next build", + "postbuild": "pagefind --site .next/server/app --output-path public/pagefind", "start": "next start", "lint": "next lint", "typecheck": "tsc --noEmit" @@ -18,13 +19,16 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "codemirror": "^6.0.2", + "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", "lucide-react": "^0.469.0", "next": "^15.5.18", "next-mdx-remote": "^6.0.0", "react": "19.0.0", "react-dom": "19.0.0", + "rehype-autolink-headings": "^7.1.0", "rehype-pretty-code": "^0.14.3", + "rehype-slug": "^6.0.0", "shiki": "^4.0.2", "tailwind-merge": "^2.6.0" }, @@ -35,6 +39,7 @@ "@types/react-dom": "^19.0.3", "eslint": "^9.20.0", "eslint-config-next": "^15.5.18", + "pagefind": "^1.5.2", "postcss": "^8.5.1", "tailwindcss": "^4.0.6", "typescript": "^5.7.3" diff --git a/web/src/app/blog/[slug]/page.tsx b/web/src/app/blog/[slug]/page.tsx index 8e5c0c3..8a1afe6 100644 --- a/web/src/app/blog/[slug]/page.tsx +++ b/web/src/app/blog/[slug]/page.tsx @@ -2,17 +2,24 @@ import Link from "next/link"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { Footer } from "@/components/footer"; +import { HelpfulVote } from "@/components/helpful-vote"; import { Nav } from "@/components/nav"; import { BlogMDX } from "@/components/blog-mdx"; import { SITE } from "@/lib/site"; import { + extractToc, formatDate, getAllPostSlugs, getAllPosts, getPostBySlug, + getRelatedPosts, tagToSlug, + wordCount, } from "@/lib/blog"; +// Only long-form posts get an on-page ToC; short notes don't need one. +const TOC_WORD_THRESHOLD = 600; + type RouteParams = { params: Promise<{ slug: string }> }; export function generateStaticParams() { @@ -61,6 +68,11 @@ export default async function BlogPostPage({ params }: RouteParams) { if (!post) notFound(); const url = `${SITE.url}/blog/${post.slug}`; + const toc = + wordCount(post.content) > TOC_WORD_THRESHOLD + ? extractToc(post.content) + : []; + const related = getRelatedPosts(post.slug); const allPosts = getAllPosts(); const idx = allPosts.findIndex((p) => p.slug === post.slug); // Posts sort newest-first, so idx-1 is newer and idx+1 is older. @@ -126,7 +138,10 @@ export default async function BlogPostPage({ params }: RouteParams) { />