diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2737e73..cace9460 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,7 +217,7 @@ jobs: flags: typescript name: typescript disable_search: true - fail_ci_if_error: true + fail_ci_if_error: false - name: Warn when Codecov token is missing for TypeScript if: env.CODECOV_TOKEN_CONFIGURED != 'true' @@ -236,7 +236,7 @@ jobs: flags: python name: python disable_search: true - fail_ci_if_error: true + fail_ci_if_error: false - name: Warn when Codecov token is missing for Python if: env.CODECOV_TOKEN_CONFIGURED != 'true' diff --git a/.github/workflows/star-history.yml b/.github/workflows/star-history.yml new file mode 100644 index 00000000..b7d90c43 --- /dev/null +++ b/.github/workflows/star-history.yml @@ -0,0 +1,72 @@ +name: Refresh star history chart + +on: + schedule: + # Twice daily at 06:00 and 18:00 UTC. + - cron: "0 6,18 * * *" + workflow_dispatch: + +permissions: + contents: write + +env: + DO_NOT_TRACK: "1" + KTX_TELEMETRY_DISABLED: "1" + NEXT_TELEMETRY_DISABLED: "1" + +concurrency: + group: star-history-refresh + cancel-in-progress: true + +jobs: + refresh: + name: Regenerate assets/star-history.svg + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # RELEASE_PAT can push to the protected main branch; the default + # GITHUB_TOKEN is rejected by the branch-protection hook (GH006). + token: ${{ secrets.RELEASE_PAT }} + + - name: Fetch fresh star-history SVG + run: | + set -euo pipefail + # cachebust forces star-history to regenerate instead of serving its + # own server-side cache; --location follows the slug-normalizing 301. + url="https://api.star-history.com/svg?repos=Kaelio/ktx&type=Date&cachebust=${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + curl --fail --location --silent --show-error \ + --retry 3 --retry-delay 5 --max-time 60 \ + -o assets/star-history.svg.new "$url" + # Guard against error pages / truncated responses before overwriting. + if ! grep -q "" assets/star-history.svg.new; then + echo "Downloaded file is not a valid SVG; aborting." >&2 + exit 1 + fi + if [ "$(wc -c < assets/star-history.svg.new)" -lt 1000 ]; then + echo "Downloaded SVG is suspiciously small; aborting." >&2 + exit 1 + fi + # The star-history API returns the SVG without a trailing newline, + # which end-of-file-fixer rewrites whenever pre-commit runs + # --all-files on a PR. Because the refresh commit below uses [skip ci], + # the hook never runs against it here, so an un-normalized file + # silently breaks the pre-commit check on every open PR. Normalize to + # exactly one trailing newline before committing. + printf '%s\n' "$(cat assets/star-history.svg.new)" > assets/star-history.svg + rm -f assets/star-history.svg.new + + - name: Commit if changed + run: | + set -euo pipefail + if git diff --quiet -- assets/star-history.svg; then + echo "Star-history chart unchanged; nothing to commit." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add assets/star-history.svg + # [skip ci] keeps this housekeeping commit from triggering KTX CI. + git commit -m "chore: refresh star history chart [skip ci]" + git push diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 41d2f048..e5817e2c 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -22,7 +22,7 @@ jobs: github.event.issue.author_association != 'COLLABORATOR' steps: - name: Apply needs-triage label - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | await github.rest.issues.addLabels({ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc2f483d..681cf0ab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,18 @@ repos: - id: check-case-conflict - id: mixed-line-ending + - repo: https://github.com/tombi-toml/tombi-pre-commit + rev: v1.1.0 + hooks: + - id: tombi-format + args: ["--offline"] + # uv.lock is generated and owned by uv, which writes its own canonical + # TOML layout. tombi reformats that layout differently, so once uv + # regenerates the lock (e.g. after a dependency or version change) + # tombi rewrites it and the hook fails on the modified file. Keep uv + # authoritative for its lockfile; tombi still formats hand-edited TOML. + exclude: ^uv\.lock$ + - repo: https://github.com/asottile/pyupgrade rev: v3.21.2 hooks: diff --git a/AGENTS.md b/AGENTS.md index 64ec2d4a..ffe324bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,25 @@ When rules conflict, follow this order: 4. Code quality: types, readable boundaries, focused modules 5. Performance where it matters +## Opinionated Product Defaults + +- **MUST**: Prefer one canonical behavior over configurable alternatives. A new + flag, config field, environment variable, mode, strategy option, adapter hook, + or fallback path is a product feature and must be justified by an explicit + user request or a real correctness requirement. +- **MUST NOT**: Add speculative flexibility for imagined users, migrations, + review preferences, local workflows, or "just in case" scenarios. If the + requested behavior can work with one solid default, implement that default. +- **MUST NOT**: Add boolean switches that create two runtime paths unless both + paths are essential and the user explicitly asked for the choice. Boolean + policy knobs are especially suspect because they double the state space and + test surface. +- **MUST**: When a design seems to need a new option, first try to remove the + need by choosing the stronger default, tightening the invariant, or failing + clearly. Ask the user before adding the option if it still seems necessary. +- **MUST**: Delete obsolete branches, tests, docs, and config when removing a + behavior. Do not preserve dormant compatibility paths. + ## Repository Shape **ktx** is a pnpm + uv workspace. @@ -159,6 +178,91 @@ and naming asymmetries are bugs in waiting — see [`docs/code-design.md`](docs/code-design.md). Treat the `MUST` / `MUST NOT` rules there with the same weight as the ones in this file. +## Design Reasoning Defaults + +When proposing a design, an approach, or any non-trivial change, apply these +defaults and run the self-check before presenting it. They encode the +corrections users most often have to make; reaching these conclusions +autonomously — without being asked the leading question — is the bar. + +- **MUST**: Optimize for the best outcome, not for an unstated constraint. Do not + silently adopt "smallest change", "least effort", "cheapest", or "least user + intervention" as the goal unless the user said so. Default to the most correct, + durable solution, and present cost / effort / scope as information for the user + to weigh — not as a ceiling you impose on their behalf. +- **MUST**: Separate one-time cost from recurring cost before discarding an + option. A fixed cost paid once (a setup-time computation, an extra LLM call + during setup, a contract change) to make every later run cheaper or more + correct is usually worth it. Do not reject it with recurring-cost reasoning; + quantify both sides. (Example smell: "don't add an LLM call to a cost-cutting + feature" — wrong when the call is one-time and the savings recur.) +- **MUST**: Treat a user's example as a representative of a class, not as the + spec. Design for the general population the example stands for, then stress-test + against deliberately different instances — another warehouse, dialect, stack + layout, or input shape — before committing. If a design only works because of an + incidental property of the example (e.g. "the noise happened to be in a separate + schema *on this demo*"), it is curve-fitting; generalize it or state the + assumption explicitly. +- **MUST**: Prefer deriving from the system's own state over enumerating cases. + Favor an allowlist computed from declared/observed state (config, scanned + catalog, query log, the user's own inputs) over a denylist of known-bad + specifics (particular tables, schemas, tools, or vendors). A hardcoded or + hand-maintained list of external specifics is a smell: it rots and fails on the + next stack. The only acceptable static patterns are genuinely universal + invariants (e.g. DB-engine system catalogs) and ktx's own self-emitted + signatures. +- **MUST**: Give each capability one implementation and route every caller + through it. When some behavior — running a query, resolving a credential or + config reference, authenticating, selecting a dialect, loading config — + already has a working implementation that some call sites use, make new or + divergent call sites depend on that path instead of standing up a second one. + Parallel implementations of one capability drift apart silently: a fix, a + newly supported input, or an added case lands on one path and not the other, + so one entry point (a CLI command, an MCP tool, an ingest stage) succeeds + while another fails on the same input. When two paths already do the same + job, collapse onto the shared one and delete the duplicate instead of + keeping both. When fixing a defect that lives on one path, fix the shared + implementation; do not patch the symptom on a forked branch, which preserves + the divergence you set out to remove. +- **SHOULD**: Before inventing an abstraction or hand-rolling structural logic, + search for what already exists and reuse it — the codebase's canonical + representation (a structured ref/key type) instead of a parallel string scheme, + and a mandated/available tool (e.g. `sqlglot` for SQL structure; see + [SQL and Structured Parsing](#sql-and-structured-parsing)) instead of + hand-parsing. Normalize ambiguous input to the canonical form at the boundary; + do not carry the ambiguity downstream. This is the single-source-of-truth / DRY + item from the Priority Hierarchy applied at design time. + +Before presenting a design, answer these explicitly: + +1. Am I optimizing for a goal the user actually stated, or one I assumed? +2. Does this generalize beyond the example in front of me? Name a real case where + it would break. +3. Am I enumerating known-bad cases when I could derive scope from the system's + own declared/observed state? +4. Is there an existing canonical representation or mandated tool I should reuse + instead of building or parsing my own? +5. Am I discarding the better option on a weak or misapplied constraint + (one-time vs recurring cost, "more surface area", "more work now")? +6. Does another entry point already perform this operation through a shared + implementation? If so, am I routing through that path instead of forking a + parallel one — and if I'm fixing a bug, am I fixing the shared layer rather + than one branch? +7. Am I adding a user-visible option or alternate runtime path that the user did + not ask for? If yes, can one opinionated default solve the problem instead? +8. Does this option multiply behavior by caller path, config value, or local + state? If yes, remove it unless it is explicitly required. + +A user question that nudges toward an alternative ("would X help?", "should I +always do Y?", "will you hardcode Z?") is a signal that a better option exists. +Investigate the implied direction and reason it through *before* defending the +original proposal — and prefer to have asked yourself the question first. + +Example: If generated context changes should be saved, choose one save policy +and route ingest, setup, memory, indexing, and docs through it. Do not add an +`auto_commit`-style switch unless the user explicitly asks for staged-only runs +and accepts the extra runtime path. + ## TypeScript Standards - Use Node 22+ and pnpm workspace commands. @@ -278,7 +382,8 @@ use `PascalCase` without the suffix. ## Telemetry -**ktx** ships anonymous PostHog telemetry. When adding commands or events: +**ktx** ships PostHog usage telemetry. Catalog telemetry events use strict +schemas. When adding commands or events: - **MUST NOT**: Add fields that carry user data — file paths, hostnames, environment values, SQL text, schema/table/column names, error messages, @@ -295,6 +400,24 @@ use `PascalCase` without the suffix. of collected data changes. Adding another event with no new field types needs no docs change. +### Error reports + +**ktx** also sends PostHog Error Tracking `$exception` events when telemetry is +enabled. This channel is separate from the strict catalog event schema and is +used only for exception diagnostics. + +`$exception` events may include stack frames, error class names, raw error +messages, cause chains, `source`, `handled`, `fatal`, runtime version fields, +OS/runtime fields, and the hashed `projectId` when known. Stack frames may +include local file paths and the local username when those appear in paths. + +`$exception` events must never intentionally include secrets, credentials, +database URLs, auth headers, raw argv, raw environment values, SQL text, +schema/table/column names as explicit properties, customer row data, user prompt +text, or raw MCP arguments. Reporters must redact call-site-provided secret +snapshots and common static credential patterns before the SDK serializes the +exception. + ## Documentation and Specs - Keep public documentation in `README.md`, package READMEs, example READMEs, @@ -323,6 +446,26 @@ use `PascalCase` without the suffix. source-code identifier, package/API name, or other literal value that must match the implementation. +### Product Category Naming + +- **MUST**: Use **context layer** as the primary public category for **ktx**. + Preferred phrase: `context layer for data agents`. +- **MUST**: Use **context engine** only as the secondary mechanism term for the + active system that builds, reconciles, validates, searches, and serves the + context layer. +- **MUST**: Keep **semantic layer** as the narrower term for executable metric + definitions, semantic sources, joins, measures, and SQL compilation. +- **MUST NOT**: Replace every `semantic layer` occurrence with `context layer`; + the semantic layer is one pillar inside the broader context layer. + +Preferred pattern: + +```md +**ktx** is an open-source context layer for data agents. Its context engine +ingests warehouse metadata, BI definitions, query history, docs, and approved +metrics, then turns them into reviewable files agents can search and execute. +``` + ### Terminology For canonical vocabulary used across docs, code, comments, CLI strings, and @@ -330,8 +473,9 @@ error messages — including the disambiguation rule for the overloaded word `source` (semantic / primary / context / source of truth) — see [`docs/terminology.md`](docs/terminology.md). Follow that file when choosing between near-synonyms (e.g. `connector` vs `adapter`, `data agent` vs -`database agent`, `fast ingest` vs `schema ingest`). Product-name rules in -this section take precedence over anything in that file when they conflict. +`database agent`, `context-source ingest` vs `source ingest`). Product-name +rules in this section take precedence over anything in that file when they +conflict. ### Updating `docs-site/` After Code Changes @@ -355,6 +499,23 @@ that do not change user-facing behavior. When you do update docs, follow the warrants docs but you are out of scope, call it out in your final summary rather than silently skipping it. +#### Monospace ligatures in `docs-site/` + +- **MUST**: Disable monospace ligatures on every surface that uses the + `var(--font-mono)` family (Geist Mono). Geist Mono fuses `--` into an + em-dash glyph that visually eats the adjacent space, so prompts like + `npx skills add Kaelio/ktx --skill ktx` render as + `Kaelio/ktx--skill ktx`. +- **MUST**: When adding a new container that renders user-visible monospace + text outside `` / `
` (e.g. a styled `
` + for a copyable prompt), verify the global ligature-off rule in + `docs-site/app/global.css` covers its selector. Either use Tailwind's + `font-mono` utility (already covered) or extend the rule to match the new + class — do not silently rely on Geist Mono's defaults. +- **SHOULD**: Prefer `` / `
` (or a `font-mono` wrapper) for any
+  string that contains CLI flags, paths, or other tokens with `--`, `->`,
+  `>=`, `!=`, `==`, `//` so ligatures never alter intent.
+
 ## LLM and Prompt Development
 
 When creating or modifying agent prompts, system prompts, tool descriptions, or
diff --git a/README.md b/README.md
index 2e034677..3f704f04 100644
--- a/README.md
+++ b/README.md
@@ -13,16 +13,20 @@
   Documentation
   Join the ktx Slack community
   License
-  Y Combinator P25
+  Y Combinator P25
 

Quickstart · CLI Reference · - Agent Setup · + Agent Setup · Slack

+

+ Built and maintained by Kaelio +

+ --- **ktx** is a self-improving context layer that teaches agents how to query your @@ -30,13 +34,25 @@ warehouse accurately - from approved metric definitions, joinable columns, and business knowledge it builds and maintains for you. > [!NOTE] -> Run **ktx** with your own LLM API keys or a **Claude Pro/Max** subscription. -> No extra usage billing from **ktx**. +> Run **ktx** with your own LLM API keys or a local agent sign-in — a +> **Claude Pro/Max** subscription through Claude Code, or your local Codex +> authentication. No extra usage billing from **ktx**.

- ktx ingestion flow from source systems through validation to wiki and semantic-layer outputs + + Watch the ktx launch video (1:56) +

+

+ Ingestion: ktx ingests databases, BI tools, modeling code, and docs through its context engine (source connectors, context builder, reconciliation, validation) into wiki Markdown and semantic-layer YAML +

+ +

+ Serving: an agent queries ktx through MCP, which searches the wiki and semantic layer, returns approved metrics, and compiles them into read-only SQL run against the warehouse +

+ + ## Why ktx General-purpose agents struggle on data tasks. They re-explore your warehouse @@ -119,15 +135,22 @@ Agent integration ready: yes (codex:project) > your project directory: > > ```text -> Follow instructions from -> https://docs.kaelio.com/ktx/docs/agents-setup.md -> to install and configure ktx +> Run npx skills add Kaelio/ktx --skill ktx and use the ktx skill to install +> and configure ktx in this project. > ``` > [!IMPORTANT] > If `ktx status` prints `ktx mcp start --project-dir ...`, run it before > opening your agent client. +## Upgrading + +Re-run the global install with the `@latest` tag: + +```bash +npm install -g @kaelio/ktx@latest +``` + ## First commands | Command | Purpose | @@ -165,8 +188,9 @@ then the current directory. Pass `--project-dir ` when scripting. No. **ktx** runs locally. The only data leaving your machine is what you send to the LLM provider you configured. - **Which LLM backends are supported?** - Anthropic API, Google Vertex AI, AI Gateway, and the local Claude Code - session through the Claude Agent SDK. See + Anthropic API, Google Vertex AI, AI Gateway, the local Claude Code session + through the Claude Agent SDK, and your local Codex authentication through the + Codex SDK. See [LLM configuration](https://docs.kaelio.com/ktx/docs/guides/llm-configuration). - **How is ktx different from a dbt or MetricFlow semantic layer?** **ktx** *ingests* those layers and combines them with raw-table @@ -185,7 +209,7 @@ then the current directory. Pass `--project-dir ` when scripting. - [The Context Layer](https://docs.kaelio.com/ktx/docs/concepts/the-context-layer) - [Building Context](https://docs.kaelio.com/ktx/docs/guides/building-context) - [CLI Reference](https://docs.kaelio.com/ktx/docs/cli-reference/ktx) -- [Agent Quickstart](https://docs.kaelio.com/ktx/docs/ai-resources/agent-quickstart) +- [AI Resources](https://docs.kaelio.com/ktx/docs/community/ai-resources) - [Community & Support](https://docs.kaelio.com/ktx/docs/community/support) ## Community @@ -235,11 +259,17 @@ uv run pytest -q ## Telemetry -**ktx** collects anonymous usage telemetry from interactive CLI runs to -improve setup, command reliability, and data-agent workflows. No file paths, -hostnames, SQL, schema names, error messages, or argv are recorded. See -[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the -event catalog and opt-out options. +**ktx** collects privacy-conscious usage telemetry to understand installs and +improve setup, command reliability, and data-agent workflows. Catalog telemetry +events do not record file paths, hostnames, SQL, schema names, table names, +column names, error messages, raw environment values, or argv. Error reports use +PostHog Error Tracking and can include stack frames and raw error messages, +which may contain local file paths or the local username in those paths. +**ktx** redacts secrets, credentials, database URLs, auth headers, argv, raw +environment values, SQL text, row data, and user-typed prompt or MCP argument +text from the explicit `$exception` payload. See +[Telemetry](https://docs.kaelio.com/ktx/docs/community/telemetry) for the event +catalog and opt-out options. ## License @@ -249,6 +279,6 @@ event catalog and opt-out options.

- ktx Star History Chart + ktx Star History Chart

diff --git a/assets/ktx-lockup.svg b/assets/ktx-lockup.svg index f1bcd2dd..2e45f8a6 100644 --- a/assets/ktx-lockup.svg +++ b/assets/ktx-lockup.svg @@ -19,14 +19,9 @@ - - ktx + + + + diff --git a/assets/launch-video-thumb.png b/assets/launch-video-thumb.png new file mode 100644 index 00000000..c7505732 Binary files /dev/null and b/assets/launch-video-thumb.png differ diff --git a/assets/star-history.svg b/assets/star-history.svg new file mode 100644 index 00000000..8fa7bc08 --- /dev/null +++ b/assets/star-history.svg @@ -0,0 +1 @@ +star-history.comMay 17May 24May 31Jun 07 200400600800kaelio/ktxStar HistoryDateGitHub Stars diff --git a/docs-site/app/diagram-studio/page.tsx b/docs-site/app/diagram-studio/page.tsx new file mode 100644 index 00000000..205ebd7a --- /dev/null +++ b/docs-site/app/diagram-studio/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from "next"; + +import { DiagramStudio } from "@/components/diagram-studio/studio"; + +export const metadata: Metadata = { + title: "Diagram studio", + robots: { index: false, follow: false }, +}; + +export default function DiagramStudioPage() { + return ; +} diff --git a/docs-site/app/docs/layout.tsx b/docs-site/app/docs/layout.tsx index ff7d69a9..5f684ea0 100644 --- a/docs-site/app/docs/layout.tsx +++ b/docs-site/app/docs/layout.tsx @@ -2,10 +2,21 @@ import { source } from "@/lib/source"; import { DocsLayout } from "fumadocs-ui/layouts/docs"; import type { ReactNode } from "react"; import { baseOptions } from "@/app/layout.config"; +import { GitHubStars } from "@/components/github-stars"; export default function Layout({ children }: { children: ReactNode }) { return ( - + + +
+ ), + }} + > {children} ); diff --git a/docs-site/app/global.css b/docs-site/app/global.css index a4cebc55..d6d9ada6 100644 --- a/docs-site/app/global.css +++ b/docs-site/app/global.css @@ -166,12 +166,16 @@ pre { } /* Disable monospace ligatures so `--flag` keeps a visible space and double - dashes don't fuse into an em-dash glyph. */ + dashes don't fuse into an em-dash glyph. Covers every monospace surface: + raw /
, the ktx-code wrapper, Tailwind's `font-mono` utility,
+   and anything that opts in via the `var(--font-mono)` family directly. */
 code,
 pre,
 pre code,
 .ktx-code,
-.ktx-code code {
+.ktx-code code,
+.font-mono,
+[style*="--font-mono"] {
   font-variant-ligatures: none !important;
   font-feature-settings: "liga" 0, "calt" 0 !important;
 }
@@ -865,6 +869,147 @@ body::after {
   50% { opacity: 0.65; transform: scale(0.9); }
 }
 
+/* ═══════════════════════════════════════════
+   GitHub star widget (navbar)
+   Split pill: GitHub mark + "Star" │ gold star + count.
+   ═══════════════════════════════════════════ */
+.ktx-stars {
+  display: inline-flex;
+  align-items: stretch;
+  height: 32px;
+  border-radius: 999px;
+  border: 1px solid var(--color-fd-border);
+  background: color-mix(in oklch, var(--color-fd-card) 72%, transparent);
+  backdrop-filter: blur(8px);
+  -webkit-backdrop-filter: blur(8px);
+  font-family: var(--font-display), var(--font-sans), sans-serif;
+  font-size: 13px;
+  line-height: 1;
+  color: var(--color-fd-foreground);
+  text-decoration: none;
+  overflow: hidden;
+  box-shadow: 0 1px 2px rgba(27, 27, 24, 0.04);
+  transition:
+    transform 0.3s var(--ktx-ease),
+    box-shadow 0.3s var(--ktx-ease),
+    border-color 0.3s ease;
+  animation: ktx-stars-in 0.5s var(--ktx-ease) both;
+}
+
+@keyframes ktx-stars-in {
+  from { opacity: 0; transform: translateY(-4px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.ktx-stars:hover {
+  transform: translateY(-1px);
+  border-color: color-mix(in oklch, var(--color-fd-primary) 45%, var(--color-fd-border));
+  box-shadow:
+    0 6px 18px -8px rgba(14, 116, 144, 0.28),
+    0 1px 2px rgba(27, 27, 24, 0.05);
+}
+
+.ktx-stars:focus-visible {
+  outline: 2px solid var(--color-fd-ring);
+  outline-offset: 2px;
+}
+
+.dark .ktx-stars {
+  background: color-mix(in oklch, var(--color-fd-card) 60%, transparent);
+  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
+}
+
+.dark .ktx-stars:hover {
+  border-color: rgba(34, 211, 238, 0.4);
+  box-shadow:
+    0 6px 18px -8px rgba(34, 211, 238, 0.3),
+    0 1px 2px rgba(0, 0, 0, 0.3);
+}
+
+.ktx-stars-seg {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  padding: 0 11px;
+}
+
+.ktx-stars-seg--count {
+  border-left: 1px solid var(--color-fd-border);
+  background: color-mix(in oklch, var(--color-fd-primary) 6%, transparent);
+  transition: background 0.3s var(--ktx-ease);
+}
+
+.ktx-stars:hover .ktx-stars-seg--count {
+  background: color-mix(in oklch, var(--color-fd-primary) 12%, transparent);
+}
+
+.ktx-stars-gh {
+  width: 15px;
+  height: 15px;
+  opacity: 0.85;
+}
+
+.ktx-stars-text {
+  font-weight: 500;
+  letter-spacing: -0.01em;
+}
+
+.ktx-stars-star {
+  width: 14px;
+  height: 14px;
+  fill: #f5b301;
+  transition: transform 0.3s var(--ktx-ease), filter 0.3s var(--ktx-ease);
+}
+
+.ktx-stars:hover .ktx-stars-star {
+  transform: scale(1.18) rotate(-8deg);
+  filter: drop-shadow(0 1px 4px rgba(245, 179, 1, 0.55));
+}
+
+.ktx-stars-count {
+  font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  color: var(--color-fd-foreground);
+}
+
+/* Skeleton shown only on the rare cold (uncached) fetch */
+.ktx-stars--skeleton {
+  animation: none;
+}
+
+.ktx-stars-skeleton-bar {
+  display: inline-block;
+  width: 26px;
+  height: 11px;
+  border-radius: 4px;
+  background: linear-gradient(
+    90deg,
+    var(--color-fd-muted) 25%,
+    color-mix(in oklch, var(--color-fd-muted-foreground) 28%, var(--color-fd-muted)) 50%,
+    var(--color-fd-muted) 75%
+  );
+  background-size: 200% 100%;
+  animation: ktx-stars-shimmer 1.4s ease-in-out infinite;
+}
+
+@keyframes ktx-stars-shimmer {
+  from { background-position: 200% 0; }
+  to { background-position: -200% 0; }
+}
+
+/* Compact on phones: drop the "Star" word, keep mark + count */
+@media (max-width: 640px) {
+  .ktx-stars-text { display: none; }
+  .ktx-stars-seg { padding: 0 9px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .ktx-stars { animation: none; transition: none; }
+  .ktx-stars:hover { transform: none; }
+  .ktx-stars:hover .ktx-stars-star { transform: none; }
+  .ktx-stars-skeleton-bar { animation: none; }
+}
+
 /* Dot grid */
 .dot-grid {
   background-image: radial-gradient(
diff --git a/docs-site/app/layout.config.tsx b/docs-site/app/layout.config.tsx
index 3245ab09..4e91b559 100644
--- a/docs-site/app/layout.config.tsx
+++ b/docs-site/app/layout.config.tsx
@@ -1,22 +1,13 @@
 import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
-import { GitHubIcon } from "@/components/github-icon";
 import { Logo } from "@/components/logo";
 import { SlackIcon } from "@/components/slack-icon";
 
 export const baseOptions: BaseLayoutProps = {
   nav: {
-    title: ,
+    title: Logo,
     transparentMode: "top",
   },
   links: [
-    {
-      type: "icon",
-      label: "GitHub",
-      icon: ,
-      text: "GitHub",
-      url: "https://github.com/kaelio/ktx",
-      external: true,
-    },
     {
       type: "icon",
       label: "Join the ktx Slack community",
diff --git a/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts b/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts
index 87dcbd42..1372d556 100644
--- a/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts
+++ b/docs-site/app/llms.mdx/docs/[[...slug]]/route.ts
@@ -3,11 +3,6 @@ import {
   getLlmDocsPages,
   getPageMarkdown,
 } from "@/lib/llm-docs";
-import {
-  agentSetupSlug,
-  isAgentSetupSlug,
-  readAgentSetupMarkdown,
-} from "@/lib/agent-setup-markdown";
 
 export const dynamic = "force-static";
 
@@ -16,14 +11,6 @@ export async function GET(
   props: { params: Promise<{ slug?: string[] }> },
 ) {
   const params = await props.params;
-  if (isAgentSetupSlug(params.slug)) {
-    return new Response(await readAgentSetupMarkdown(), {
-      headers: {
-        "Content-Type": "text/markdown; charset=utf-8",
-      },
-    });
-  }
-
   const page = getLlmDocsPage(params.slug);
   if (!page) {
     return new Response("Documentation page not found.\n", {
@@ -42,8 +29,5 @@ export async function GET(
 }
 
 export function generateStaticParams() {
-  return [
-    ...getLlmDocsPages().map((page) => ({ slug: page.slug })),
-    { slug: [...agentSetupSlug] },
-  ];
+  return getLlmDocsPages().map((page) => ({ slug: page.slug }));
 }
diff --git a/docs-site/components/diagram-studio/flows.ts b/docs-site/components/diagram-studio/flows.ts
new file mode 100644
index 00000000..e63cc512
--- /dev/null
+++ b/docs-site/components/diagram-studio/flows.ts
@@ -0,0 +1,328 @@
+import { type Edge, MarkerType, type Node } from "@xyflow/react";
+
+import { C } from "./nodes";
+
+const EDGE_COLOR = "#b3bcc4";
+const MARKER_COLOR = "#9aa6ad";
+
+const labelStyle = {
+  fontFamily: "var(--font-inter), system-ui, sans-serif",
+  fontSize: 15,
+  fontWeight: 600,
+  fill: C.inkMuted,
+};
+const labelBgStyle = { fill: "#ffffff", stroke: C.chipBorder, strokeWidth: 1 };
+const labelBg = {
+  labelBgPadding: [8, 4] as [number, number],
+  labelBgBorderRadius: 6,
+  labelStyle,
+  labelBgStyle,
+};
+
+const marker = { type: MarkerType.ArrowClosed, color: MARKER_COLOR, width: 16, height: 16 };
+const edgeStyle = { stroke: EDGE_COLOR, strokeWidth: 2 };
+
+/* ============================== INGESTION =============================== */
+
+const SRC_W = 300;
+const SRC_H = 138;
+const SRC_GAP = 24;
+const srcY = (i: number) => i * (SRC_H + SRC_GAP);
+
+export const ingestionNodes: Node[] = [
+  {
+    id: "title",
+    type: "title",
+    position: { x: 0, y: -96 },
+    data: {
+      width: 560,
+      eyebrow: "1 · Ingestion",
+      title: "ktx builds your context layer",
+    },
+  },
+  {
+    id: "db",
+    type: "card",
+    position: { x: 0, y: srcY(0) },
+    data: {
+      width: SRC_W,
+      height: SRC_H,
+      accent: C.teal,
+      rows: [
+        { kind: "title", text: "Databases" },
+        { kind: "desc", text: "Schemas, keys, query history" },
+        { kind: "muted", text: "Postgres · Snowflake · BigQuery · …" },
+      ],
+      handles: [{ side: "right", type: "source", id: "out" }],
+    },
+  },
+  {
+    id: "bi",
+    type: "card",
+    position: { x: 0, y: srcY(1) },
+    data: {
+      width: SRC_W,
+      height: SRC_H,
+      accent: C.orange,
+      rows: [
+        { kind: "title", text: "BI tools" },
+        { kind: "desc", text: "Dashboards, explores, usage" },
+        { kind: "muted", text: "Metabase · Looker · …" },
+      ],
+      handles: [{ side: "right", type: "source", id: "out" }],
+    },
+  },
+  {
+    id: "model",
+    type: "card",
+    position: { x: 0, y: srcY(2) },
+    data: {
+      width: SRC_W,
+      height: SRC_H,
+      accent: C.amber,
+      rows: [
+        { kind: "title", text: "Modeling code" },
+        { kind: "desc", text: "Metrics, models, joins, entities" },
+        { kind: "muted", text: "dbt · LookML · MetricFlow · …" },
+      ],
+      handles: [{ side: "right", type: "source", id: "out" }],
+    },
+  },
+  {
+    id: "docs",
+    type: "card",
+    position: { x: 0, y: srcY(3) },
+    data: {
+      width: SRC_W,
+      height: SRC_H,
+      accent: C.emerald,
+      rows: [
+        { kind: "title", text: "Docs & notes" },
+        { kind: "desc", text: "Policies, definitions, notes" },
+        { kind: "muted", text: "Notion · any text · …" },
+      ],
+      handles: [{ side: "right", type: "source", id: "out" }],
+    },
+  },
+  {
+    id: "engine",
+    type: "engine",
+    position: { x: 420, y: 52 },
+    data: {
+      width: 380,
+      height: 520,
+      steps: [
+        { n: 1, title: "Source connectors", desc: "Read each source in its shape" },
+        { n: 2, title: "Context builder", desc: "Evidence into proposed updates" },
+        { n: 3, title: "Reconciliation", desc: "Merge with existing context" },
+        { n: 4, title: "Validation", desc: "Check references & semantics" },
+      ],
+      handles: [
+        { side: "left", type: "target", id: "in" },
+        { side: "right", type: "source", id: "out" },
+      ],
+    },
+  },
+  {
+    id: "wiki",
+    type: "card",
+    position: { x: 900, y: 66 },
+    data: {
+      width: 320,
+      height: 220,
+      accent: C.emerald,
+      rows: [
+        { kind: "mono", text: "wiki/*.md", color: C.emerald },
+        { kind: "title", text: "Wiki" },
+        { kind: "chips", items: ["free-form", "auto-maintained"] },
+        { kind: "desc", text: "Definitions, caveats, policies," },
+        { kind: "desc", text: "and notes agents can search." },
+      ],
+      handles: [{ side: "left", type: "target", id: "in" }],
+    },
+  },
+  {
+    id: "sl",
+    type: "card",
+    position: { x: 900, y: 338 },
+    data: {
+      width: 320,
+      height: 220,
+      accent: C.teal,
+      rows: [
+        { kind: "mono", text: "semantic-layer/*.yaml", color: C.teal },
+        { kind: "title", text: "Semantic layer" },
+        { kind: "chips", items: ["executable", "auto-maintained"] },
+        { kind: "desc", text: "Metrics, joins, dimensions, and" },
+        { kind: "desc", text: "filters ktx compiles into SQL." },
+      ],
+      handles: [{ side: "left", type: "target", id: "in" }],
+    },
+  },
+];
+
+const ingestEdge = (source: string, target: string): Edge => ({
+  id: `${source}-${target}`,
+  source,
+  target,
+  sourceHandle: "out",
+  targetHandle: "in",
+  type: "default",
+  style: edgeStyle,
+  markerEnd: marker,
+});
+
+export const ingestionEdges: Edge[] = [
+  ingestEdge("db", "engine"),
+  ingestEdge("bi", "engine"),
+  ingestEdge("model", "engine"),
+  ingestEdge("docs", "engine"),
+  ingestEdge("engine", "wiki"),
+  ingestEdge("engine", "sl"),
+];
+
+/* =============================== RUNTIME ================================ */
+
+export const runtimeNodes: Node[] = [
+  {
+    id: "title",
+    type: "title",
+    position: { x: 0, y: -84 },
+    data: {
+      width: 560,
+      eyebrow: "2 · Serving",
+      title: "agents query it through MCP",
+    },
+  },
+  {
+    id: "agent",
+    type: "card",
+    position: { x: 0, y: 115 },
+    data: {
+      width: 280,
+      height: 190,
+      accent: C.neutral,
+      align: "center",
+      rows: [
+        { kind: "title", text: "Your agent" },
+        { kind: "muted", text: "Claude Code · Cursor" },
+        { kind: "muted", text: "Codex · OpenCode" },
+      ],
+      handles: [
+        { side: "right", type: "source", id: "ask", top: "42%" },
+        { side: "right", type: "target", id: "answer", top: "62%" },
+      ],
+    },
+  },
+  {
+    id: "hub",
+    type: "hub",
+    position: { x: 420, y: 85 },
+    data: {
+      width: 360,
+      height: 250,
+      rows: [
+        "Search wiki + semantic layer",
+        "Return approved metrics",
+        "Compile metrics → SQL",
+      ],
+      handles: [
+        { side: "left", type: "target", id: "ask", top: "42%" },
+        { side: "left", type: "source", id: "answer", top: "62%" },
+        { side: "right", type: "source", id: "to-context", top: "30%" },
+        { side: "right", type: "source", id: "to-warehouse", top: "72%" },
+      ],
+    },
+  },
+  {
+    id: "context",
+    type: "card",
+    position: { x: 920, y: 15 },
+    data: {
+      width: 300,
+      height: 150,
+      accent: C.teal,
+      rows: [
+        { kind: "title", text: "Context layer" },
+        { kind: "mono", text: "wiki/*.md", color: C.emerald },
+        { kind: "mono", text: "semantic-layer/*.yaml", color: C.teal },
+      ],
+      handles: [{ side: "left", type: "target", id: "in" }],
+    },
+  },
+  {
+    id: "warehouse",
+    type: "card",
+    position: { x: 920, y: 255 },
+    data: {
+      width: 300,
+      height: 150,
+      accent: C.slate,
+      rows: [
+        { kind: "title", text: "Warehouse" },
+        {
+          kind: "badge",
+          text: "read-only",
+          bg: "#ecf6f8",
+          border: "#bfe3ea",
+          color: C.teal,
+        },
+        { kind: "desc", text: "Runs the compiled SQL" },
+      ],
+      handles: [{ side: "left", type: "target", id: "in" }],
+    },
+  },
+];
+
+export const runtimeEdges: Edge[] = [
+  {
+    id: "ask",
+    source: "agent",
+    sourceHandle: "ask",
+    target: "hub",
+    targetHandle: "ask",
+    type: "default",
+    label: "ask",
+    ...labelBg,
+    style: edgeStyle,
+    markerEnd: marker,
+  },
+  {
+    id: "answer",
+    source: "hub",
+    sourceHandle: "answer",
+    target: "agent",
+    targetHandle: "answer",
+    type: "default",
+    label: "answer",
+    ...labelBg,
+    style: edgeStyle,
+    markerEnd: marker,
+  },
+  {
+    id: "search",
+    source: "hub",
+    sourceHandle: "to-context",
+    target: "context",
+    targetHandle: "in",
+    type: "smoothstep",
+    label: "search + read",
+    ...labelBg,
+    style: edgeStyle,
+    markerStart: marker,
+    markerEnd: marker,
+  },
+  {
+    id: "readonly",
+    source: "hub",
+    sourceHandle: "to-warehouse",
+    target: "warehouse",
+    targetHandle: "in",
+    type: "smoothstep",
+    label: "read-only",
+    ...labelBg,
+    style: edgeStyle,
+    markerStart: marker,
+    markerEnd: marker,
+  },
+];
diff --git a/docs-site/components/diagram-studio/mascot.tsx b/docs-site/components/diagram-studio/mascot.tsx
new file mode 100644
index 00000000..467f6ee5
--- /dev/null
+++ b/docs-site/components/diagram-studio/mascot.tsx
@@ -0,0 +1,57 @@
+/**
+ * Inlined ktx mascot, ported from assets/ktx-mascot.svg.
+ *
+ * - `light` renders the dark-bodied mascot for light surfaces.
+ * - `dark` renders the cream-bodied mascot for dark surfaces (e.g. the ktx
+ *   hub panel), mirroring brand/ktx-mascot-dark.svg.
+ */
+export function KtxMascot({
+  variant = "light",
+  size = 56,
+}: {
+  variant?: "light" | "dark";
+  size?: number;
+}) {
+  const body = variant === "dark" ? "#F5F1EA" : "#1B3139";
+  const eye = variant === "dark" ? "#1B3139" : "#F5F1EA";
+  return (
+    
+      
+        
+        
+        
+      
+      
+      
+      
+      
+    
+  );
+}
diff --git a/docs-site/components/diagram-studio/nodes.tsx b/docs-site/components/diagram-studio/nodes.tsx
new file mode 100644
index 00000000..f648a905
--- /dev/null
+++ b/docs-site/components/diagram-studio/nodes.tsx
@@ -0,0 +1,493 @@
+"use client";
+
+import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
+
+import { KtxMascot } from "./mascot";
+
+/** Fixed palette mirrored from the approved SVG diagrams so the exported PNG
+ *  is theme-independent (one image that reads on light and dark GitHub). */
+export const C = {
+  ink: "#1b1b18",
+  inkSoft: "#57534e",
+  inkMuted: "#8c857f",
+  cardBorder: "#e2dfd9",
+  engineBg: "#15323a",
+  engineBorder: "#23474f",
+  cyan: "#55dced",
+  stepNum: "#06262c",
+  stepTitle: "#f3f1ec",
+  stepDesc: "#9fb6bc",
+  hubRow: "#eef4f5",
+  chipBg: "#faf9f6",
+  chipBorder: "#e7e5e4",
+  teal: "#0e7490",
+  emerald: "#059669",
+  orange: "#f97316",
+  amber: "#d97706",
+  slate: "#334155",
+  neutral: "#94a3b8",
+} as const;
+
+const DISPLAY = "var(--font-display), system-ui, sans-serif";
+const BODY = "var(--font-inter), system-ui, sans-serif";
+const MONO = "var(--font-mono), ui-monospace, monospace";
+
+const CARD_SHADOW = "0 3px 12px rgba(27, 49, 57, 0.10)";
+const ENGINE_SHADOW = "0 6px 22px rgba(2, 12, 15, 0.30)";
+
+/** ktx logo mascot size, shared by the engine and hub headers. */
+const LOGO_SIZE = 56;
+
+type HandleSpec = {
+  side: "left" | "right";
+  type: "source" | "target";
+  id: string;
+  top?: string;
+};
+
+function Handles({ specs }: { specs?: HandleSpec[] }) {
+  if (!specs) return null;
+  return (
+    <>
+      {specs.map((h) => (
+        
+      ))}
+    
+  );
+}
+
+/* ------------------------------- Card node ------------------------------- */
+
+type CardRow =
+  | { kind: "title"; text: string }
+  | { kind: "mono"; text: string; color: string }
+  | { kind: "desc"; text: string }
+  | { kind: "muted"; text: string }
+  | { kind: "chips"; items: string[] }
+  | { kind: "badge"; text: string; bg: string; border: string; color: string };
+
+type CardData = {
+  width: number;
+  height: number;
+  accent: string;
+  align?: "center";
+  rows: CardRow[];
+  handles?: HandleSpec[];
+};
+
+function gapFor(kind: CardRow["kind"], prev?: CardRow["kind"]): number {
+  if (!prev) return 0;
+  if (kind === "desc" && prev === "desc") return 3;
+  if (kind === "mono" && prev === "mono") return 2;
+  if (kind === "title") return 6;
+  return 10;
+}
+
+function CardRowView({ row }: { row: CardRow }) {
+  switch (row.kind) {
+    case "title":
+      return (
+        
+          {row.text}
+        
+      );
+    case "mono":
+      return (
+        
+          {row.text}
+        
+      );
+    case "desc":
+      return (
+        
+          {row.text}
+        
+      );
+    case "muted":
+      return (
+        
+          {row.text}
+        
+      );
+    case "chips":
+      return (
+        
+ {row.items.map((c) => ( + + {c} + + ))} +
+ ); + case "badge": + return ( + + {row.text} + + ); + } +} + +function CardNode({ data }: NodeProps>) { + const center = data.align === "center"; + return ( +
+ + + {data.rows.map((row, i) => ( +
+ +
+ ))} +
+ ); +} + +/* ------------------------------ Engine node ------------------------------ */ + +type EngineStep = { n: number; title: string; desc: string }; + +type EngineData = { + width: number; + height: number; + steps: EngineStep[]; + handles?: HandleSpec[]; +}; + +function EngineNode({ data }: NodeProps>) { + return ( +
+ + +
+ + + ktx + +
+
+ {data.steps.map((s) => ( +
+ + {s.n} + +
+ + {s.title} + + + {s.desc} + +
+
+ ))} +
+
+ ); +} + +/* -------------------------------- Hub node ------------------------------- */ + +type HubData = { + width: number; + height: number; + rows: string[]; + handles?: HandleSpec[]; +}; + +function HubNode({ data }: NodeProps>) { + return ( +
+ + +
+ + + ktx + +
+
+ {data.rows.map((r) => ( +
+ + + {r} + +
+ ))} +
+
+ ); +} + +/* ------------------------------- Title node ------------------------------ */ + +type TitleData = { width: number; eyebrow: string; title: string }; + +function TitleNode({ data }: NodeProps>) { + return ( +
+ + {data.eyebrow} + + + {data.title} + +
+ ); +} + +export const nodeTypes = { + card: CardNode, + engine: EngineNode, + hub: HubNode, + title: TitleNode, +}; diff --git a/docs-site/components/diagram-studio/studio.tsx b/docs-site/components/diagram-studio/studio.tsx new file mode 100644 index 00000000..7b96ae7b --- /dev/null +++ b/docs-site/components/diagram-studio/studio.tsx @@ -0,0 +1,242 @@ +"use client"; + +import "@xyflow/react/dist/style.css"; + +import { useCallback, useRef, useState } from "react"; +import { + Background, + BackgroundVariant, + type Edge, + getNodesBounds, + type Node, + ReactFlow, + ReactFlowProvider, + useEdgesState, + useNodesState, + useReactFlow, +} from "@xyflow/react"; +import { toPng } from "html-to-image"; + +import { + ingestionEdges, + ingestionNodes, + runtimeEdges, + runtimeNodes, +} from "./flows"; +import { nodeTypes } from "./nodes"; + +const EXPORT_PADDING = 48; +const EXPORT_PIXEL_RATIO = 2; + +function DiagramCanvasInner({ + initialNodes, + initialEdges, + fileName, + height, + dark, +}: { + initialNodes: Node[]; + initialEdges: Edge[]; + fileName: string; + height: number; + dark: boolean; +}) { + const wrapperRef = useRef(null); + const [nodes, , onNodesChange] = useNodesState(initialNodes); + const [edges, , onEdgesChange] = useEdgesState(initialEdges); + const { getNodes } = useReactFlow(); + const [busy, setBusy] = useState(false); + + const download = useCallback(async () => { + const viewport = wrapperRef.current?.querySelector( + ".react-flow__viewport", + ); + if (!viewport) return; + setBusy(true); + try { + await document.fonts.ready; + const bounds = getNodesBounds(getNodes()); + const outW = Math.ceil(bounds.width + EXPORT_PADDING * 2); + const outH = Math.ceil(bounds.height + EXPORT_PADDING * 2); + const tx = EXPORT_PADDING - bounds.x; + const ty = EXPORT_PADDING - bounds.y; + const dataUrl = await toPng(viewport, { + width: outW, + height: outH, + pixelRatio: EXPORT_PIXEL_RATIO, + // transparent background so one PNG works on light and dark GitHub + style: { + width: `${outW}px`, + height: `${outH}px`, + transform: `translate(${tx}px, ${ty}px) scale(1)`, + }, + }); + const link = document.createElement("a"); + link.download = fileName; + link.href = dataUrl; + link.click(); + } finally { + setBusy(false); + } + }, [fileName, getNodes]); + + return ( +
+
+ +
+
+ + + +
+
+ ); +} + +function btnStyle(disabled: boolean): React.CSSProperties { + return { + fontFamily: "var(--font-inter), system-ui, sans-serif", + fontSize: 13, + fontWeight: 600, + padding: "7px 14px", + borderRadius: 8, + border: "1px solid #0e7490", + background: disabled ? "#9bbdc6" : "#0e7490", + color: "#ffffff", + cursor: disabled ? "default" : "pointer", + }; +} + +function DiagramCanvas(props: { + initialNodes: Node[]; + initialEdges: Edge[]; + fileName: string; + height: number; + dark: boolean; +}) { + return ( + + + + ); +} + +export function DiagramStudio() { + const [dark, setDark] = useState(false); + return ( +
+
+

+ ktx diagram studio +

+

+ Static diagrams. Export is a transparent 2× PNG framed to the node + bounds — the dark-background toggle is only for previewing. +

+ +
+ +
+

1 · Ingestion — building the context layer

+ +
+ +
+

2 · Serving — answering agents at runtime

+ +
+
+ ); +} + +const sectionTitle: React.CSSProperties = { + fontFamily: "var(--font-display), system-ui, sans-serif", + fontSize: 18, + fontWeight: 600, + color: "#1b1b18", + marginBottom: 12, +}; diff --git a/docs-site/components/github-stars.tsx b/docs-site/components/github-stars.tsx new file mode 100644 index 00000000..d3b328a3 --- /dev/null +++ b/docs-site/components/github-stars.tsx @@ -0,0 +1,93 @@ +import { Suspense } from "react"; +import { GitHubIcon } from "@/components/github-icon"; + +const REPO = "kaelio/ktx"; +const REPO_URL = `https://github.com/${REPO}`; +const API_URL = `https://api.github.com/repos/${REPO}`; + +async function fetchStarCount(): Promise { + try { + const res = await fetch(API_URL, { + headers: { Accept: "application/vnd.github+json" }, + // Revalidate hourly. GitHub's unauthenticated REST limit is 60 req/h per + // IP, so a single cached server-side fetch keeps the count fresh while + // never exposing visitors to rate limits or layout shift. + next: { revalidate: 3600 }, + }); + if (!res.ok) return null; + const data = (await res.json()) as { stargazers_count?: unknown }; + return typeof data.stargazers_count === "number" + ? data.stargazers_count + : null; + } catch { + return null; + } +} + +/** Compact, GitHub-style count: 847 → "847", 1234 → "1.2k", 12345 → "12.3k". */ +function formatStars(count: number): string { + if (count < 1000) return count.toLocaleString("en-US"); + const thousands = count / 1000; + const rounded = + thousands >= 100 ? Math.round(thousands) : Math.round(thousands * 10) / 10; + return `${rounded}k`; +} + +function StarGlyph() { + return ( + + ); +} + +async function StarsContent() { + const count = await fetchStarCount(); + const label = + count === null + ? "Star ktx on GitHub" + : `Star ktx on GitHub — ${count.toLocaleString("en-US")} stars`; + + return ( + + + + Star + + {count !== null && ( + + + {formatStars(count)} + + )} + + ); +} + +function StarsSkeleton() { + return ( +