diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..6b47e05
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.DS_Store
+*.log
+
+# GitHub Actions workflow is ready locally but cannot be pushed by the
+# current automation token (missing `workflows` scope). A maintainer with
+# workflow permissions should commit .github/workflows/pages.yml and then
+# remove these ignore lines.
+.github/workflows/
diff --git a/.nojekyll b/.nojekyll
new file mode 100644
index 0000000..e69de29
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f984fc6
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 GitAPITaker contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 2eb96b0..c988673 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,296 @@
-# gitapi-dev
+# GitAPITaker
+
+**Take a Git hosting URL. Inspect the API behind it.**
+
+GitAPITaker is a privacy-first, keyboard-driven developer tool that resolves Git hosting URLs
+(`https://github.com/flessan`) into their provider REST API endpoints (`https://api.github.com/users/flessan`),
+performs the request **directly from your browser**, and shows you everything about the exchange —
+with an honest LIVE / CACHED / STALE state on every response.
+
+It is a **static frontend application**. There is no backend, no API proxy, no relay, no telemetry,
+no analytics and no application-owned database. It deploys to GitHub Pages as-is.
+
+```
+input URL → provider detection → URL parser → resource identification
+ → provider resolver → API endpoint builder → request layer
+ → response inspector
+```
+
+Every stage is an independent, testable module — and in v0.2 the UI shows them to you literally:
+the **resolution pipeline** (`DETECT → PARSE → RESOLVE → FETCH`) renders the actual outcome of each
+stage for every inspection, including which stage failed and why.
+
+## What's new in v0.3 (Material You 3 redesign)
+
+The interface was rebuilt around **Material Design 3 (Material You)** patterns for a simpler,
+friendlier, easier-to-digest experience — implemented in pure CSS with the official M3 baseline
+tonal palette. No component libraries, no CDN requests, no web fonts: the privacy model is
+untouched.
+
+- **Navigation rail** with icons on desktop, **bottom navigation** on mobile.
+- **M3 components**: filled/tonal/outlined/text buttons (pill-shaped with state layers),
+ elevated/outlined cards, chips, filled text field, primary tabs, rounded dialogs, and a
+ snackbar for copy/theme feedback.
+- **Simplified hierarchy**: one inspect card up top, pipeline as colored step chips, a status
+ card beside the response, generous spacing and the M3 type scale.
+- Everything from v0.2 remains: pipeline tracker, pagination, JSON search & path copy, quick
+ actions, change detection, diff, guard transparency, themes, shortcuts.
+
+## What's new in v0.2 (UI/UX redesign + new features)
+
+**Redesign — “lab instrument” identity.** A terminal-style command strip with prompt glyph, a
+two-pane inspector (metadata rail + response area), box-drawn section headings, mono-forward
+type, amber-on-charcoal palette (light theme supported), precise focus states, zero emoji,
+zero web fonts. The theme toggle (or t **Setup note:** the workflow file is written and present at `.github/workflows/pages.yml`,
+> but the automation that authored this repository did not have the GitHub `workflows`
+> permission, so the file could not be pushed. A maintainer with workflow permissions should
+> `git add -f .github/workflows/pages.yml`, commit it, and remove the matching lines from
+> `.gitignore`. Until then, enable Pages with “Deploy from a branch” on `main` (whole
+> repository, root) for an equivalent static deploy.
+
+## Community
+
+Community discussions run on **GitHub Discussions via Giscus** — no custom forum backend.
+One-time setup (repository owner): enable Discussions, install the Giscus app, then fill
+`repoId`/`categoryId` in `src/community/config.js` (instructions are in that file). Provider
+cards link to contextual “discuss this provider” discussion templates.
+
+---
+
+## Adding a provider
+
+Contributors add a provider by writing one adapter module — no core changes.
+
+1. Create `src/providers/forgejo.js` (or `bitbucket.js`, …) exporting an object with:
+ - `id`, `name`, `docsUrl`, `defaultWebBase`, `defaultApiBase`
+ - `requestHeaders` — headers GitAPITaker sets on requests for this provider
+ - `apiInfo` — version/media-type facts shown in the REQUEST view
+ - `capabilities.resources` — the table shown on the Providers page (metadata, not UI code)
+ - `match(url)` — built-in host matching (self-hosted kinds set `capabilities.selfHosted`)
+ - `parse(url, ctx)` — pure function returning a `ParsedResource`; throw `ResolverError`
+ with actionable hints for anything unsupported
+ - `resolve(parsed, ctx)` — pure function returning a `ResolvedEndpoint`
+ (`{providerId, method, url, headers, docUrl, label, notes}`)
+ - `related(parsed, ctx)` — endpoint-explorer items derived from capability metadata
+ - `describe(parsed)` — one-line human label
+2. Register it in `src/providers/registry.js` (`registerProvider(forgejo)`).
+3. Add tests: detection, parsing, resolution, edge cases, related resources.
+
+Notes for likely candidates:
+
+- **Forgejo** already works today through the Gitea adapter when registered as a custom instance
+ (`kind: 'gitea'`); a dedicated adapter would only add Forgejo-specific routes.
+- **Bitbucket Cloud** needs its own adapter: `api.bitbucket.org/2.0` uses workspace/repository
+ slugs and paginated collection endpoints that differ structurally from the current providers —
+ a good test case for the adapter contract.
+- If a provider cannot serve browsers cross-origin, document that in `capabilities.limitations`;
+ GitAPITaker reports CORS failures honestly instead of proxying around them.
+
+## Contributing
+
+1. Run `npm test`; keep the suite green and add tests for new mapping rules.
+2. Keep provider knowledge inside adapters; keep core and UI provider-agnostic.
+3. Never fabricate request/response data, never add tracking, never add a build step without a
+ very strong reason.
+4. Open a PR describing the mapping rules you added and any provider quirks you discovered.
+
+## Known limitations
+
+- `github.com/{name}` is ambiguous (user vs org); users endpoint is tried first and the 404
+ interpretation suggests `/orgs/{name}`.
+- GitLab user lookup returns an array; related-user endpoints need the numeric id from it.
+- `tree`/`blob` URLs mix ref and path; the first segment after the marker is treated as the ref
+ (heuristic, labeled in the REQUEST view).
+- Browsers expose only CORS-allowed response headers; the HEADERS view states this explicitly.
+- Self-hosted instances without CORS enabled cannot be called from any browser app.
+- GET requests only.
+
+## License
+
+MIT
diff --git a/favicon.svg b/favicon.svg
new file mode 100644
index 0000000..9c4a295
--- /dev/null
+++ b/favicon.svg
@@ -0,0 +1,5 @@
+
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..ec6da31
--- /dev/null
+++ b/index.html
@@ -0,0 +1,344 @@
+
+
+
+
+
+ GitAPITaker — inspect the API behind any Git hosting URL
+
+
+
+
+
+
+ Skip to main content
+
+
+
+
+
+
+
+
+
+
+ GitAPITaker
+ Git hosting API inspector
+
+
+
+
+
+
+
+
+
+
+
+
+
Inspector
+
Take a Git hosting URL. Inspect the API behind it.
+
+
+
+
+
+
+
+
+
+
+
Nothing inspected yet
+
Paste a Git hosting URL above or pick an example. You’ll see each resolution step, the real provider response, its headers and the exact request — nothing is faked.
+
+
+
+
+
+
+
+
+
Contacting the provider directly from your browser…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
History
+
Previously inspected resources — stored in this browser only, never transmitted.
+
+
+
+
+
+
+
+
+
+
+
+
Cache Inspector
+
Everything below lives in your browser’s localStorage. Cached responses keep all four views and stay inspectable offline. Cached is always labeled cached — never shown as fresh.
+
+
+
+
+
+
+
+
+
+
+
+
Providers & Documentation
+
Official documentation links plus concise mapping tables rendered from each provider adapter’s metadata.
+
+
+
+
+
Adding another provider
+
New providers are added as self-contained adapters: one module in src/providers/
+ implementing host matching, URL parsing, endpoint resolution and related-resource metadata, registered in
+ src/providers/registry.js. The full walkthrough — including Forgejo and Bitbucket
+ notes — is in the README.
+
+
+
+
+
+
+
Community
+
Community discussions run on GitHub Discussions via
+ Giscus — GitAPITaker stays a
+ static site with no forum backend. Talk about provider mappings, API behavior differences, bugs, feature
+ requests, unsupported resources, self-hosted compatibility, and new Git hosting platforms.
+
+
+
Provider-specific mapping questions: use the “Discuss … mappings” link on each provider card under Providers.
+
Bugs and feature requests: open an issue or discussion on the repository.
+
Please never paste tokens, private URLs or confidential data into public discussions.
+
+
+
+
+
+
+
+
About & Security
+
+
+
+
What GitAPITaker is
+
A privacy-first, keyboard-driven developer tool that resolves Git hosting URLs into their provider REST API
+ endpoints and lets you inspect the resulting responses — JSON, RAW body, headers and the exact request — with
+ total transparency about what is live and what is cached. The resolution pipeline at the top of every
+ inspection shows exactly what was detected, parsed, resolved and fetched.
+
+
Architecture
+
+
Static frontend only. Deployable on GitHub Pages. There is no GitAPITaker backend, API proxy, request relay, telemetry service, analytics system or application-owned database.
+
Direct requests. Every API call goes from your browser straight to the provider (GitHub, GitLab, Gitea, or a self-hosted instance you register). GitAPITaker never sits in the middle.
+
Provider adapters. Detection, URL parsing and endpoint resolution are separate stages; each provider adapter owns its own mapping rules, versioning quirks and capability metadata.
+
+
+
Privacy
+
+
No analytics, no telemetry, no tracking pixels, no third-party behavioral tracking, no request-logging servers. No web fonts are loaded — system font stacks only.
+
The URLs you inspect, your history, your cache, your theme preference and your settings are stored only in this browser’s localStorage. GitAPITaker never transmits them anywhere.
+
Share links contain only the target resource URL — never responses, tokens or cache data.
+
Requests use credentials: "omit": no cookies are sent to provider APIs.
+
The community widget (Giscus) is loaded only on the Community page and communicates with GitHub, not with GitAPITaker infrastructure.
+
+
Important honesty note: the provider you inspect does receive your request — it is the actual
+ API destination. Provider rate limits, terms and privacy policies apply to those requests.
+
+
Caching
+
Responses are cached in localStorage with full context: endpoint, status, headers, exact body, request
+ metadata, fetch time and TTL (5 minutes by default). Cache keys include provider, method and full endpoint URL
+ so unrelated requests never collide. A response is always labeled LIVE, CACHED
+ or STALE — text labels, not color alone. A cached response is never displayed as fresh, and a
+ 200 OK from cache always carries the CACHED/STALE badge and the original fetch time.
+
+
Request Guard
+
The Request Guard is a local safety mechanism: after a live request, repeated identical requests within
+ 10 seconds are suppressed and served from the local cache. The UI tells you when this happens — how many
+ repeats were suppressed this session, and when a live request is allowed again. r
+ (or the Refresh button) forces a live request. It exists to keep the interface responsive and to protect
+ third-party APIs from accidental hammering. It is not designed to bypass provider rate
+ limits, and GitAPITaker never rotates identities, proxies requests, or otherwise circumvents provider
+ protections. Provider rate limits still fully apply to every live request.
+
+
Offline mode
+
When a provider cannot be reached (or you are offline), previously cached responses remain fully inspectable
+ — all four views work from the cached record. Such responses are always labeled STALE with the original fetch
+ timestamp.
+
+
Authentication (future)
+
GitAPITaker v0.1 performs unauthenticated requests only. If authentication is added later, tokens will
+ never be placed in URLs, query parameters, share links, history or cache records; the default will be
+ memory-only storage, and browser-local storage risks will be spelled out before any opt-in persistence.
+
+
Known limitations
+
+
github.com/{name} is ambiguous (user vs organization); the users endpoint is tried first and the 404 interpretation offers a one-click “try as organization” action.
+
GitLab user lookup returns an array (/users?username=); related resources for a single GitLab user need the numeric id from that response.
+
tree/blob URLs mix a git ref and a path; the first segment is treated as the ref (heuristic, labeled in the REQUEST view).
+
Browsers only expose response headers allowed by CORS; the HEADERS view says so explicitly.
+
Some self-hosted instances disable CORS for their API; browser requests to them will fail and the error interpretation explains this.
+
GitAPITaker performs GET requests only.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..37c3996
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,130 @@
+{
+ "name": "gitapitaker",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "gitapitaker",
+ "version": "0.1.0",
+ "license": "MIT",
+ "devDependencies": {
+ "happy-dom": "^20.11.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "26.2.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz",
+ "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.3.0"
+ }
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/happy-dom": {
+ "version": "20.11.2",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.2.tgz",
+ "integrity": "sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
+ "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
+ "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0774ea0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "gitapitaker",
+ "version": "0.3.0",
+ "private": true,
+ "type": "module",
+ "description": "GitAPITaker — a privacy-first, keyboard-driven static tool that resolves Git hosting URLs into provider REST API endpoints and inspects the responses directly in the browser.",
+ "scripts": {
+ "start": "node tools/serve.mjs",
+ "test": "node --test 'tests/*.test.js'"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "license": "MIT",
+ "devDependencies": {
+ "happy-dom": "^20.11.2"
+ }
+}
diff --git a/src/app.js b/src/app.js
new file mode 100644
index 0000000..761e082
--- /dev/null
+++ b/src/app.js
@@ -0,0 +1,626 @@
+/**
+ * GitAPITaker application shell.
+ *
+ * Wires the provider-agnostic core (resolver, request, cache, guard,
+ * history) to the presentation layer. All domain logic lives in src/core
+ * and src/providers; this file only orchestrates.
+ *
+ * Product philosophy: direct, transparent, private, keyboard-first,
+ * extensible, honest about the network.
+ */
+
+import { resolveInput, endpointFromExplorerItem } from './core/resolver.js';
+import { executeEndpoint, tryParseJson } from './core/request.js';
+import { ResolverError, interpretHttpStatus, interpretFetchFailure } from './core/errors.js';
+import { cacheKey, readEntry, storeLiveResponse, entryState, listSnapshots, clearAll as clearCacheAll } from './core/cache.js';
+import { createGuard } from './core/guard.js';
+import { addHistory, clearHistory } from './core/history.js';
+import { buildCurlCommand } from './core/curl.js';
+import { buildShareUrl } from './core/share.js';
+import { diffJson } from './core/diff.js';
+import { detectPagination } from './core/pagination.js';
+import { getProvider } from './providers/registry.js';
+import { formatAge, formatTimestamp } from './core/format.js';
+
+import { el, clear, copyText } from './ui/dom.js';
+import { announce } from './ui/announce.js';
+import { createRouter, navigate } from './ui/router.js';
+import { createPalette } from './ui/palette.js';
+import { createHelp } from './ui/help.js';
+import { rovingList } from './ui/keyboard.js';
+import { getTheme, setTheme, nextTheme, applyTheme } from './ui/theme.js';
+import { showSnackbar } from './ui/snackbar.js';
+import {
+ initInspector, focusInput, getInputValue, setInputValue,
+ showPending, showResult, showResolverError, showEmptyState, showNetworkError,
+ mountExplorer, selectResponseTab, hasResult,
+} from './ui/inspector.js';
+import { initHistory, renderHistoryView } from './ui/history-view.js';
+import { renderCacheView, initCacheView } from './ui/cache-view.js';
+import { renderProvidersView } from './ui/providers-view.js';
+import { renderCommunityView } from './ui/community.js';
+
+const guard = createGuard();
+
+/** Current inspection context (or null). */
+let current = null;
+
+/* ------------------------------------------------------------------ */
+/* Pipeline stages */
+/* ------------------------------------------------------------------ */
+
+function describeParams(parsed) {
+ if (!parsed) return '';
+ const bits = Object.entries(parsed.params).slice(0, 3).map(([k, v]) => `${k}=${v}`);
+ return bits.join(' · ');
+}
+
+/** Stages for a successful web-URL resolution. */
+function stagesFromResolution(resolution) {
+ const { url, detection, parsed, endpoint } = resolution;
+ return [
+ { label: 'detect', value: `${url.hostname} → ${detection.provider.id}${detection.ctx.instanceLabel ? ` · ${detection.ctx.instanceLabel}` : ''}`, state: 'ok' },
+ { label: 'parse', value: `${parsed.resourceType}${describeParams(parsed) ? ` · ${describeParams(parsed)}` : ''}`, state: 'ok' },
+ { label: 'resolve', value: `${endpoint.method} ${endpoint.url}`, state: 'ok' },
+ ];
+}
+
+/** Stages for direct endpoint inspections (explorer / history / cache). */
+function stagesFromEndpoint(endpoint, source) {
+ return [
+ { label: 'source', value: source, state: 'ok' },
+ { label: 'resolve', value: `${endpoint.method || 'GET'} ${endpoint.url}`, state: 'ok' },
+ ];
+}
+
+/* ------------------------------------------------------------------ */
+/* Inspection flow */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Inspect user input (web URL or shorthand).
+ * @param {string} rawInput
+ * @param {{force?: boolean}} [opts]
+ */
+async function inspectInput(rawInput, opts = {}) {
+ let resolution;
+ try {
+ resolution = resolveInput(rawInput);
+ } catch (err) {
+ if (err instanceof ResolverError) {
+ current = null;
+ showResolverError(err);
+ return;
+ }
+ throw err;
+ }
+ const { provider, detection, parsed, endpoint, url } = resolution;
+ setInputValue(url.toString());
+ await inspectEndpoint(endpoint, {
+ detection, parsed, force: opts.force,
+ webUrl: url.toString(), source: 'url',
+ providerName: provider.name,
+ instanceLabel: detection.ctx.instanceLabel,
+ stages: stagesFromResolution(resolution),
+ });
+}
+
+/**
+ * Inspect a concrete endpoint (from resolution, the explorer, history or
+ * the cache inspector). Applies the Request Guard and caching rules.
+ */
+async function inspectEndpoint(endpoint, opts = {}) {
+ const {
+ detection = null, parsed = null, force = false,
+ webUrl = null, source = 'explorer',
+ providerName = getProvider(endpoint.providerId)?.name ?? endpoint.providerId,
+ instanceLabel = undefined,
+ stages = stagesFromEndpoint(endpoint, source),
+ } = opts;
+
+ const key = cacheKey(endpoint.providerId, endpoint.method, endpoint.url);
+ current = { endpoint, detection, parsed, webUrl, cacheKey: key, providerName, instanceLabel, stages };
+
+ const decision = guard.decide(key, { force });
+ if (decision.action === 'cache') {
+ const entry = readEntry(key);
+ if (entry) {
+ guard.recordSuppressed(key);
+ showCached(entry, {
+ state: entryState(entry) === 'fresh' ? 'cached' : 'stale',
+ guardNote: buildGuardNote(key),
+ reason: 'suppressed',
+ stages,
+ });
+ addHistory({
+ providerId: endpoint.providerId, resourceType: parsed?.resourceType ?? endpoint.resourceType,
+ webUrl, endpoint: endpoint.url, method: endpoint.method,
+ status: entry.status, stateLabel: entryState(entry) === 'fresh' ? 'CACHED' : 'STALE',
+ });
+ return;
+ }
+ // Guard wanted cache but none exists — a live request is the only honest option.
+ }
+
+ showPending(endpoint, stages);
+ const result = await executeEndpoint(endpoint);
+
+ if (result.ok) {
+ const record = result.record;
+ const previous = readEntry(key);
+ const { stored } = storeLiveResponse(key, record, { webUrl, resourceType: parsed?.resourceType ?? endpoint.resourceType });
+ guard.recordLive(key);
+ addHistory({
+ providerId: endpoint.providerId, resourceType: parsed?.resourceType ?? endpoint.resourceType,
+ webUrl, endpoint: endpoint.url, method: endpoint.method,
+ status: record.status, stateLabel: 'LIVE',
+ });
+ const interpretation = record.status >= 400
+ ? interpretHttpStatus(record.status, endpoint.providerId, record.headers, parsed)
+ : null;
+ showResult({
+ endpoint, providerName, data: record, state: 'live', stages,
+ meta: {
+ interpretation, webUrl, instanceLabel, source,
+ pagination: detectPagination({ providerId: endpoint.providerId, url: endpoint.url, headers: record.headers }),
+ onPaginate: (url) => paginateTo(url),
+ changeNote: computeChangeNote(previous, record),
+ stored: stored ? undefined : 'storage-unavailable',
+ },
+ });
+ if (!stored) {
+ announce('Warning: browser storage is unavailable; this response cannot be cached locally.', { assertive: true });
+ }
+ mountExplorer(detection, parsed, {
+ onSelect: (item) => inspectEndpoint(endpointFromExplorerItem(item, detection), {
+ detection, source: 'explorer', providerName, instanceLabel,
+ }),
+ });
+ } else {
+ const failure = interpretFetchFailure(result.error);
+ const entry = readEntry(key);
+ addHistory({
+ providerId: endpoint.providerId, resourceType: parsed?.resourceType ?? endpoint.resourceType,
+ webUrl, endpoint: endpoint.url, method: endpoint.method, stateLabel: 'NETWORK-ERROR',
+ });
+ if (entry) {
+ showCached(entry, {
+ state: 'stale',
+ guardNote: null,
+ reason: 'offline',
+ errorInterp: failure,
+ stages,
+ });
+ } else {
+ showNetworkError({ endpoint, providerName, failure, stages });
+ current = null;
+ }
+ }
+}
+
+/**
+ * Compare a fresh record with the previously cached body and summarize —
+ * powers the "response changed since last capture" note.
+ * @returns {null | {findings: number}}
+ */
+function computeChangeNote(previous, record) {
+ if (!previous || !record.live) return null;
+ if (previous.bodyText === record.bodyText && previous.status === record.status) return null;
+ const a = tryParseJson(previous.bodyText);
+ const b = tryParseJson(record.bodyText);
+ if (a.isJson && b.isJson) return { findings: diffJson(a.value, b.value).length };
+ return { findings: -1 };
+}
+
+/** Follow a pagination link with the current provider's headers. */
+function paginateTo(url) {
+ if (!current) return;
+ const provider = getProvider(current.endpoint.providerId);
+ inspectEndpoint({
+ providerId: current.endpoint.providerId,
+ method: 'GET',
+ url,
+ headers: provider?.requestHeaders ?? { Accept: 'application/json' },
+ resourceType: current.parsed?.resourceType,
+ parsed: current.parsed ?? undefined,
+ }, {
+ detection: current.detection, parsed: current.parsed,
+ webUrl: current.webUrl, source: 'pagination',
+ providerName: current.providerName, instanceLabel: current.instanceLabel,
+ stages: [
+ { label: 'source', value: 'pagination link (provider-supplied)', state: 'ok' },
+ { label: 'resolve', value: `GET ${url}`, state: 'ok' },
+ ],
+ });
+}
+
+function buildGuardNote(key) {
+ const info = guard.describe(key);
+ const nextIn = info.lastLiveAt
+ ? Math.max(0, Math.ceil((info.lastLiveAt + info.cooldownMs - Date.now()) / 1000))
+ : 0;
+ return `Request Guard: repeat request suppressed and served from local cache. `
+ + `${info.suppressed} repeat${info.suppressed === 1 ? '' : 's'} suppressed this session. `
+ + (nextIn > 0 ? `A live request is allowed again in ~${nextIn} s.` : 'A live request is allowed now.');
+}
+
+/** Render a cache entry as CACHED/STALE — never as live. */
+function showCached(entry, { state, guardNote, reason, errorInterp = null, stages = null }) {
+ const providerName = getProvider(entry.providerId)?.name ?? entry.providerId;
+ const endpoint = {
+ providerId: entry.providerId,
+ method: entry.method,
+ url: entry.endpoint,
+ headers: entry.requestHeaders ?? {},
+ resourceType: entry.resourceType,
+ apiBase: undefined,
+ };
+ current = {
+ endpoint, detection: null, parsed: null, webUrl: entry.webUrl ?? null,
+ cacheKey: entry.key, providerName,
+ stages: stages ?? stagesFromEndpoint(endpoint, 'cache'),
+ };
+
+ const meta = {
+ guardNote: guardNote ?? null,
+ interpretation: errorInterp ?? (entry.status >= 400 ? interpretHttpStatus(entry.status, entry.providerId, entry.headers) : null),
+ webUrl: entry.webUrl,
+ reason,
+ source: reason === 'offline'
+ ? 'Offline fallback (provider unreachable; local cache shown)'
+ : reason === 'suppressed'
+ ? 'Served by Request Guard from local cache'
+ : 'Loaded from local cache',
+ pagination: detectPagination({ providerId: entry.providerId, url: entry.endpoint, headers: entry.headers }),
+ onPaginate: (url) => paginateTo(url),
+ };
+ showResult({
+ endpoint, providerName,
+ data: {
+ status: entry.status, statusText: entry.statusText, headers: entry.headers,
+ bodyText: entry.bodyText, sizeBytes: entry.sizeBytes, fetchedAt: entry.fetchedAt,
+ },
+ state,
+ stages: current.stages,
+ meta,
+ });
+ mountExplorer(current.detection, current.parsed, { onSelect: () => {} });
+ announce(reason === 'offline'
+ ? 'Provider unreachable. Showing a stale cached copy; all four views remain available.'
+ : `Request suppressed by the Request Guard. Showing ${state} cached response from ${formatAge(entry.fetchedAt)}.`);
+}
+
+function refreshCurrent() {
+ if (!current) {
+ const value = getInputValue();
+ if (value.trim()) inspectInput(value, { force: true });
+ else focusInput();
+ return;
+ }
+ inspectEndpoint(current.endpoint, {
+ detection: current.detection, parsed: current.parsed, force: true,
+ webUrl: current.webUrl, source: 'url',
+ providerName: current.providerName, instanceLabel: current.instanceLabel,
+ stages: current.stages,
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* Response Diff */
+/* ------------------------------------------------------------------ */
+
+function openDiff() {
+ const dialog = document.getElementById('diff-dialog');
+ const body = dialog.querySelector('#diff-body');
+ clear(body);
+
+ if (!current) {
+ body.append(el('p', { className: 'empty-note' }, 'Inspect something first — diff compares two responses of one endpoint.'));
+ dialog.showModal();
+ return;
+ }
+ const entry = readEntry(current.cacheKey);
+ const snapshots = listSnapshots(current.cacheKey);
+ if (!entry || snapshots.length === 0) {
+ body.append(el('p', { className: 'empty-note' },
+ 'No older snapshot exists for this endpoint yet. ',
+ 'Every time a live response replaces an existing cache entry, the previous one is archived here for comparison.'));
+ dialog.showModal();
+ return;
+ }
+
+ const newer = { label: `Newer — fetched ${formatTimestamp(entry.fetchedAt)} (${formatAge(entry.fetchedAt)})`, entry };
+ const choices = [...snapshots].reverse(); // newest snapshot first
+ let older = choices[0];
+
+ const chooser = el('div', { className: 'diff-chooser', role: 'radiogroup', 'aria-label': 'Choose the older response to compare' });
+ const list = el('div', { className: 'diff-snapshots' });
+ choices.forEach((snap, i) => {
+ const btn = el('button', {
+ type: 'button', role: 'radio', className: 'm3-btn outlined btn-sm',
+ 'aria-checked': String(i === 0), tabindex: i === 0 ? '0' : '-1',
+ 'aria-label': `Older response fetched ${formatTimestamp(snap.fetchedAt)}, HTTP ${snap.status}`,
+ }, `Older · ${formatTimestamp(snap.fetchedAt)} · HTTP ${snap.status}`);
+ btn.addEventListener('click', () => {
+ older = snap;
+ list.querySelectorAll('button').forEach((b) => { b.setAttribute('aria-checked', String(b === btn)); b.tabIndex = b === btn ? 0 : -1; });
+ render();
+ });
+ list.append(btn);
+ });
+ chooser.append(el('p', {}, 'Compare against:'), list);
+
+ const result = el('div', { className: 'diff-result' });
+ body.append(chooser, result);
+
+ function render() {
+ clear(result);
+ const olderParsed = tryParseJson(older.bodyText);
+ const newerParsed = tryParseJson(newer.entry.bodyText);
+ result.append(el('p', { className: 'view-note' },
+ `Older response: fetched ${formatTimestamp(older.fetchedAt)} (HTTP ${older.status}). `,
+ `Newer response: fetched ${formatTimestamp(newer.entry.fetchedAt)} (HTTP ${newer.entry.status}). `,
+ 'Original responses are never modified.'));
+ if (!olderParsed.isJson || !newerParsed.isJson) {
+ result.append(el('p', { className: 'empty-note' },
+ 'Structural diff needs JSON on both sides. Use the RAW view to compare non-JSON bodies visually.'));
+ return;
+ }
+ const findings = diffJson(olderParsed.value, newerParsed.value);
+ if (findings.length === 0) {
+ result.append(el('p', { className: 'empty-note' }, 'No differences found — the two JSON bodies are identical.'));
+ return;
+ }
+ const added = findings.filter((f) => f.type === 'added').length;
+ const removed = findings.filter((f) => f.type === 'removed').length;
+ const changed = findings.filter((f) => f.type === 'changed').length;
+ result.append(el('p', { className: 'mono' }, `${findings.length} finding(s): ${added} added · ${removed} removed · ${changed} changed (arrays compared by index).`));
+ const table = el('table', { className: 'kv-table diff-table' },
+ el('thead', {}, el('tr', {},
+ el('th', { scope: 'col' }, 'Path'), el('th', { scope: 'col' }, 'Change'),
+ el('th', { scope: 'col' }, 'Older'), el('th', { scope: 'col' }, 'Newer'))),
+ );
+ const tbody = el('tbody');
+ for (const f of findings.slice(0, 200)) {
+ tbody.append(el('tr', {},
+ el('td', { className: 'mono' }, f.path),
+ el('td', {}, el('span', { className: `m3-chip diff-${f.type}` }, f.type)),
+ el('td', { className: 'mono' }, f.type === 'added' ? '—' : String(JSON.stringify(f.before))),
+ el('td', { className: 'mono' }, f.type === 'removed' ? '—' : String(JSON.stringify(f.after))),
+ ));
+ }
+ table.append(tbody);
+ result.append(table);
+ if (findings.length > 200) result.append(el('p', { className: 'view-note' }, `Showing first 200 of ${findings.length} findings.`));
+ }
+
+ render();
+ dialog.showModal();
+ rovingList(dialog, '.diff-snapshots button', { onActivate: (node) => node.click() });
+}
+
+/* ------------------------------------------------------------------ */
+/* Pages, palette, shortcuts */
+/* ------------------------------------------------------------------ */
+
+const PAGE_SECTIONS = {
+ inspector: 'page-inspector',
+ history: 'page-history',
+ cache: 'page-cache',
+ providers: 'page-providers',
+ community: 'page-community',
+ about: 'page-about',
+};
+
+let currentPage = 'inspector';
+
+function showPage(page, inspectTarget = null) {
+ currentPage = page;
+ for (const [name, id] of Object.entries(PAGE_SECTIONS)) {
+ document.getElementById(id).hidden = name !== page;
+ }
+ document.querySelectorAll('[data-nav]').forEach((link) => {
+ if (link.dataset.nav === page) link.setAttribute('aria-current', 'page');
+ else link.removeAttribute('aria-current');
+ });
+
+ if (page === 'history') renderHistoryView();
+ if (page === 'cache') renderCacheView(cacheHooks);
+ if (page === 'providers') renderProvidersView();
+ if (page === 'community') renderCommunityView();
+ if (page === 'about') { /* static content */ }
+
+ const heading = document.querySelector(`#${PAGE_SECTIONS[page]} h1`);
+ if (page !== 'inspector' || !inspectTarget) heading?.focus({ preventScroll: false });
+
+ if (page === 'inspector' && inspectTarget) {
+ setInputValue(inspectTarget);
+ inspectInput(inspectTarget);
+ } else if (page === 'inspector' && !current && document.getElementById('resolver-error').hidden) {
+ showEmptyState();
+ }
+}
+
+const cacheHooks = {
+ onInspectEntry: (entry) => {
+ navigate('inspector');
+ showCached(entry, { state: entryState(entry) === 'fresh' ? 'cached' : 'stale', guardNote: null, reason: 'manual' });
+ },
+ onRefreshEntry: (entry) => {
+ navigate('inspector');
+ inspectEndpoint({
+ providerId: entry.providerId, method: entry.method, url: entry.endpoint,
+ headers: entry.requestHeaders ?? {}, resourceType: entry.resourceType,
+ }, { force: true, webUrl: entry.webUrl, source: 'Cache inspector → refresh' });
+ },
+};
+
+function getPaletteActions() {
+ return [
+ { id: 'focus-input', label: 'Focus URL input', hint: '/', keywords: 'input url address', run: () => { showPage('inspector'); navigate('inspector'); focusInput(); } },
+ { id: 'inspect', label: 'Inspect current URL', hint: 'Enter', keywords: 'run go resolve', run: () => inspectInput(getInputValue()) },
+ { id: 'refresh', label: 'Force live request (bypass Request Guard)', hint: 'r', keywords: 'reload refresh live', run: refreshCurrent },
+ { id: 'tab-json', label: 'View: JSON', hint: '1', keywords: 'tree parsed', run: () => selectResponseTab('json') },
+ { id: 'tab-raw', label: 'View: RAW', hint: '2', keywords: 'body original', run: () => selectResponseTab('raw') },
+ { id: 'tab-headers', label: 'View: HEADERS', hint: '3', keywords: 'response headers', run: () => selectResponseTab('headers') },
+ { id: 'tab-request', label: 'View: REQUEST', hint: '4', keywords: 'request curl', run: () => selectResponseTab('request') },
+ { id: 'copy-body', label: 'Copy response body', keywords: 'clipboard copy body', run: copyBody },
+ { id: 'copy-curl', label: 'Copy as cURL', hint: 'y', keywords: 'curl command copy', run: copyCurl },
+ { id: 'copy-share', label: 'Copy share link', hint: 's', keywords: 'share url link copy', run: copyShare },
+ { id: 'diff', label: 'Response diff (compare with older snapshot)', hint: 'd', keywords: 'diff compare changes', run: openDiff },
+ { id: 'theme', label: 'Cycle color theme (auto/dark/light)', hint: 't', keywords: 'theme color dark light appearance', run: cycleTheme },
+ { id: 'page-history', label: 'Go to: History', keywords: 'history past inspections', run: () => navigate('history') },
+ { id: 'page-cache', label: 'Go to: Cache inspector', keywords: 'cache storage entries', run: () => navigate('cache') },
+ { id: 'page-providers', label: 'Go to: Providers & instances', keywords: 'providers docs instances self-hosted', run: () => navigate('providers') },
+ { id: 'page-community', label: 'Go to: Community', keywords: 'community discussions giscus', run: () => navigate('community') },
+ { id: 'page-about', label: 'Go to: About & Security', keywords: 'about security privacy', run: () => navigate('about') },
+ { id: 'clear-cache', label: 'Clear local cache', keywords: 'clear delete cache', run: () => { cacheClear(); } },
+ { id: 'clear-history', label: 'Clear history', keywords: 'clear delete history', run: () => { historyClear(); } },
+ { id: 'help', label: 'Keyboard shortcuts', hint: '?', keywords: 'help shortcuts keys', run: () => help.open() },
+ ];
+}
+
+async function copyBody() {
+ const entry = current ? readEntry(current.cacheKey) : null;
+ if (!entry) { announce('Nothing to copy yet — inspect a URL first.'); return; }
+ const ok = await copyText(entry.bodyText);
+ showSnackbar(ok ? 'Response body copied' : 'Copy failed');
+ announce(ok ? 'Response body copied.' : 'Copy failed.');
+}
+
+async function copyCurl() {
+ if (!current) { announce('Nothing to copy yet — inspect a URL first.'); return; }
+ const ok = await copyText(buildCurlCommand(current.endpoint));
+ showSnackbar(ok ? 'cURL copied — no credentials included' : 'Copy failed');
+ announce(ok ? 'cURL command copied. It contains no credentials.' : 'Copy failed.');
+}
+
+async function copyShare() {
+ const target = current?.webUrl ?? current?.endpoint?.url ?? getInputValue();
+ if (!target) { announce('Nothing to share yet — inspect a URL first.'); return; }
+ const ok = await copyText(buildShareUrl(target));
+ showSnackbar(ok ? 'Share link copied — target URL only' : 'Copy failed');
+ announce(ok ? 'Share link copied. It contains only the target URL, never the response.' : 'Copy failed.');
+}
+
+function cacheClear() {
+ clearCacheAll();
+ announce('Local cache cleared.');
+ if (currentPage === 'cache') renderCacheView(cacheHooks);
+}
+
+function historyClear() {
+ clearHistory();
+ announce('History cleared.');
+ if (currentPage === 'history') renderHistoryView();
+}
+
+/* ------------------------------------------------------------------ */
+/* Theme */
+/* ------------------------------------------------------------------ */
+
+function updateThemeButton() {
+ const btn = document.getElementById('theme-toggle');
+ const label = btn?.querySelector('.theme-label');
+ if (label) label.textContent = getTheme();
+}
+
+function cycleTheme() {
+ const next = nextTheme(getTheme());
+ setTheme(next);
+ updateThemeButton();
+ const word = next === 'auto' ? 'auto (follow system)' : next;
+ showSnackbar(`Theme: ${word}`);
+ announce(`Color theme set to ${word}.`);
+}
+
+/* ------------------------------------------------------------------ */
+/* Boot */
+/* ------------------------------------------------------------------ */
+
+let palette;
+let help;
+
+function boot() {
+ applyTheme(getTheme());
+ updateThemeButton();
+ document.getElementById('theme-toggle')?.addEventListener('click', cycleTheme);
+
+ initInspector({ onInspect: (value) => inspectInput(value) });
+ initHistory({
+ onReopen: (entry) => {
+ navigate('inspector');
+ if (entry.webUrl) inspectInput(entry.webUrl);
+ else {
+ const provider = getProvider(entry.providerId);
+ inspectEndpoint({
+ providerId: entry.providerId,
+ method: entry.method ?? 'GET',
+ url: entry.endpoint,
+ headers: provider?.requestHeaders ?? { Accept: 'application/json' },
+ }, {
+ force: false, source: 'History', providerName: provider?.name ?? entry.providerId,
+ });
+ }
+ },
+ });
+ initCacheView();
+
+ palette = createPalette({ getActions: getPaletteActions });
+ help = createHelp();
+
+ document.getElementById('palette-open').addEventListener('click', () => palette.open());
+ document.getElementById('diff-close').addEventListener('click', () => document.getElementById('diff-dialog').close());
+ document.getElementById('diff-dialog').addEventListener('keydown', (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); document.getElementById('diff-dialog').close(); }
+ });
+
+ document.addEventListener('gitapitaker:refresh', () => refreshCurrent());
+ document.addEventListener('gitapitaker:diff', () => openDiff());
+ document.addEventListener('gitapitaker:cache-changed', () => { if (currentPage === 'cache') renderCacheView(cacheHooks); });
+ document.addEventListener('gitapitaker:inspect', (e) => inspectInput(e.detail?.input ?? ''));
+ document.addEventListener('gitapitaker:goto', (e) => { if (e.detail?.page) navigate(e.detail.page); });
+
+ document.addEventListener('keydown', onGlobalKeydown);
+
+ createRouter({
+ onChange: ({ page, inspectTarget }) => showPage(page, inspectTarget),
+ }).emit();
+
+ if (!location.hash) showEmptyState();
+}
+
+function onGlobalKeydown(event) {
+ const target = event.target;
+ const typing = target instanceof HTMLElement && (
+ target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable
+ );
+ const dialogOpen = document.querySelector('dialog[open]');
+
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
+ event.preventDefault();
+ if (dialogOpen) dialogOpen.close();
+ palette.open();
+ return;
+ }
+ if (typing || dialogOpen) return;
+
+ if (event.key === '/') { event.preventDefault(); navigate('inspector'); focusInput(); return; }
+ if (event.key === '?') { event.preventDefault(); help.open(); return; }
+ if (event.key === 't') { event.preventDefault(); cycleTheme(); return; }
+ if (currentPage !== 'inspector') return;
+
+ switch (event.key) {
+ case '1': if (hasResult()) { event.preventDefault(); selectResponseTab('json'); } break;
+ case '2': if (hasResult()) { event.preventDefault(); selectResponseTab('raw'); } break;
+ case '3': if (hasResult()) { event.preventDefault(); selectResponseTab('headers'); } break;
+ case '4': if (hasResult()) { event.preventDefault(); selectResponseTab('request'); } break;
+ case 'r': if (current) { event.preventDefault(); refreshCurrent(); } break;
+ case 'y': if (current) { event.preventDefault(); copyCurl(); } break;
+ case 's': if (current) { event.preventDefault(); copyShare(); } break;
+ case 'd': if (current) { event.preventDefault(); openDiff(); } break;
+ default: break;
+ }
+}
+
+boot();
diff --git a/src/community/config.js b/src/community/config.js
new file mode 100644
index 0000000..6cd14b6
--- /dev/null
+++ b/src/community/config.js
@@ -0,0 +1,42 @@
+/**
+ * Giscus / GitHub Discussions configuration.
+ *
+ * GitAPITaker is fully static — the community backend IS GitHub Discussions,
+ * embedded through the official giscus.app client. No forum backend exists
+ * in this project.
+ *
+ * SETUP (one-time, by the repository owner):
+ * 1. Enable Discussions on https://github.com/34labs/gitapi-dev
+ * 2. Install the GitHub App: https://github.com/apps/giscus
+ * 3. Create at least these discussion categories:
+ * - "Providers" (Announcement or Discussion category)
+ * - "Q&A" (Announcement category with "Q&A" enabled)
+ * 4. Visit https://giscus.app, fill in the repository, and copy the
+ * repository id and category ids into the constants below.
+ *
+ * Until the ids are filled in, the community page shows these instructions
+ * instead of embedding a broken widget — nothing is faked.
+ */
+
+export const GISCUS_CONFIG = {
+ repo: '34labs/gitapi-dev',
+ repoId: '', // e.g. 'R_kgDO...' (from giscus.app)
+ category: 'Providers',
+ categoryId: '', // e.g. 'DIC_kwDO...' (from giscus.app)
+ mapping: 'specific', // one discussion per app section, keyed by term
+ strict: '1',
+ reactionsEnabled: '1',
+ emitMetadata: '0',
+ inputPosition: 'top',
+ theme: 'preferred_color_scheme',
+ lang: 'en',
+};
+
+export function isGiscusConfigured() {
+ return Boolean(GISCUS_CONFIG.repoId && GISCUS_CONFIG.categoryId);
+}
+
+/** Per-section discussion term used with mapping=specific. */
+export function giscusTerm(section) {
+ return `gitapitaker-community:${section}`;
+}
diff --git a/src/core/cache.js b/src/core/cache.js
new file mode 100644
index 0000000..f27e3d6
--- /dev/null
+++ b/src/core/cache.js
@@ -0,0 +1,164 @@
+/**
+ * Defensive local cache (localStorage).
+ *
+ * Purpose: keep the UI responsive and protect third-party APIs from
+ * repeated identical requests. It is NEVER used to pretend a response is
+ * fresh — every cached response is surfaced as CACHED or STALE.
+ *
+ * Records keep the full context needed to re-render all four inspector
+ * views (JSON / RAW / HEADERS / REQUEST) offline.
+ */
+
+import { readJson, writeJson, getStorage } from './storage.js';
+
+export const CACHE_PREFIX = 'gitapitaker.cache.v1.';
+export const SNAPSHOT_PREFIX = 'gitapitaker.snapshots.v1.';
+/** Freshness window: entries older than this are STALE (still inspectable). */
+export const DEFAULT_TTL_MS = 5 * 60 * 1000;
+/** Snapshots retained per cache key (for Response Diff). */
+export const MAX_SNAPSHOTS = 5;
+
+/**
+ * Deterministic cache key. Includes provider + method + full endpoint URL so
+ * unrelated requests can never collide. (The endpoint URL already embeds any
+ * custom API base, so self-hosted instances are covered too.)
+ *
+ * @param {string} providerId
+ * @param {string} method
+ * @param {string} endpointUrl
+ */
+export function cacheKey(providerId, method, endpointUrl) {
+ return `${CACHE_PREFIX}${providerId}:${method}:${fnv1a(`${providerId}|${method}|${endpointUrl}`)}`;
+}
+
+/** FNV-1a 32-bit hash — short, deterministic, collision-safe enough for keys. */
+export function fnv1a(str) {
+ let h = 0x811c9dc5;
+ for (let i = 0; i < str.length; i += 1) {
+ h ^= str.charCodeAt(i);
+ h = Math.imul(h, 0x01000193);
+ }
+ return (h >>> 0).toString(16).padStart(8, '0');
+}
+
+/** @param {string} key @returns {import('./types.js').CacheEntry | null} */
+export function readEntry(key) {
+ const value = readJson(key);
+ if (!value || typeof value !== 'object' || typeof value.bodyText !== 'string') return null;
+ return value;
+}
+
+/**
+ * Persist a response record as a cache entry.
+ * @param {string} key
+ * @param {import('./types.js').ResponseRecord} record
+ * @param {{webUrl?: string, resourceType?: string}} [meta]
+ * @returns {import('./types.js').CacheEntry}
+ */
+export function entryFromRecord(key, record, meta = {}) {
+ return {
+ key,
+ providerId: record.providerId,
+ method: record.method,
+ endpoint: record.url,
+ webUrl: meta.webUrl,
+ resourceType: meta.resourceType,
+ status: record.status,
+ statusText: record.statusText,
+ headers: record.headers,
+ bodyText: record.bodyText,
+ sizeBytes: record.sizeBytes,
+ requestHeaders: record.requestHeaders,
+ fetchedAt: record.fetchedAt,
+ ttlMs: DEFAULT_TTL_MS,
+ };
+}
+
+/** @param {string} key @param {import('./types.js').CacheEntry} entry @returns {boolean} stored */
+export function writeEntry(key, entry) {
+ return writeJson(key, entry);
+}
+
+/** @param {string} key */
+export function deleteEntry(key) {
+ getStorage().remove(key);
+ getStorage().remove(snapshotKeyOf(key));
+}
+
+/** @param {number} [now] @returns {{entry: import('./types.js').CacheEntry, state: 'fresh'|'stale', ageMs: number}[]} */
+export function listEntries(now = Date.now()) {
+ const out = [];
+ for (const k of getStorage().keys()) {
+ if (!k.startsWith(CACHE_PREFIX)) continue;
+ const entry = readEntry(k);
+ if (!entry) continue;
+ out.push({ entry, state: entryState(entry, now), ageMs: Math.max(0, now - entry.fetchedAt) });
+ }
+ out.sort((a, b) => b.entry.fetchedAt - a.entry.fetchedAt);
+ return out;
+}
+
+/** Delete every cache entry and snapshot list. */
+export function clearAll() {
+ getStorage().clearPrefix(CACHE_PREFIX);
+ getStorage().clearPrefix(SNAPSHOT_PREFIX);
+}
+
+/**
+ * Freshness of one entry.
+ * @param {import('./types.js').CacheEntry} entry
+ * @param {number} [now]
+ */
+export function entryState(entry, now = Date.now()) {
+ const ageMs = Math.max(0, now - entry.fetchedAt);
+ const ttl = entry.ttlMs ?? DEFAULT_TTL_MS;
+ return ageMs <= ttl ? 'fresh' : 'stale';
+}
+
+/**
+ * Store a live response: archives the previous entry (if any) into the
+ * snapshot ring so Response Diff can compare older vs newer truthfully.
+ *
+ * @param {string} key
+ * @param {import('./types.js').ResponseRecord} record
+ * @param {{webUrl?: string, resourceType?: string}} [meta]
+ * @returns {{entry: import('./types.js').CacheEntry, stored: boolean, archivedPrevious: boolean}}
+ */
+export function storeLiveResponse(key, record, meta = {}) {
+ const previous = readEntry(key);
+ let archivedPrevious = false;
+ if (previous) {
+ const snapshots = listSnapshots(key);
+ snapshots.push(previous);
+ while (snapshots.length > MAX_SNAPSHOTS) snapshots.shift();
+ archivedPrevious = writeJson(snapshotKeyOf(key), snapshots);
+ }
+ const entry = entryFromRecord(key, record, meta);
+ const stored = writeEntry(key, entry);
+ return { entry, stored, archivedPrevious };
+}
+
+/** @param {string} key */
+export function snapshotKeyOf(cacheKey) {
+ return SNAPSHOT_PREFIX + cacheKey.slice(CACHE_PREFIX.length);
+}
+
+/** @param {string} key @returns {import('./types.js').CacheEntry[]} oldest → newest */
+export function listSnapshots(key) {
+ const value = readJson(snapshotKeyOf(key));
+ return Array.isArray(value) ? value : [];
+}
+
+/**
+ * Approximate storage usage of the cache (for the Cache Inspector).
+ * @returns {number} bytes of JSON stored under cache+snapshot prefixes
+ */
+export function approximateUsageBytes() {
+ let total = 0;
+ for (const k of getStorage().keys()) {
+ if (!k.startsWith(CACHE_PREFIX) && !k.startsWith(SNAPSHOT_PREFIX)) continue;
+ const v = getStorage().get(k);
+ if (v) total += k.length + v.length;
+ }
+ return total;
+}
diff --git a/src/core/curl.js b/src/core/curl.js
new file mode 100644
index 0000000..5c013c9
--- /dev/null
+++ b/src/core/curl.js
@@ -0,0 +1,25 @@
+/**
+ * Copy-as-cURL.
+ *
+ * The generated command represents exactly what GitAPITaker sends: method,
+ * final URL and the headers the app actually sets. No credentials are ever
+ * included (v0.1 performs unauthenticated requests only).
+ */
+
+/**
+ * @param {import('./types.js').ResolvedEndpoint} endpoint
+ * @returns {string}
+ */
+export function buildCurlCommand(endpoint) {
+ const parts = ['curl', '-sS', '-X', endpoint.method || 'GET'];
+ for (const [name, value] of Object.entries(endpoint.headers ?? {})) {
+ parts.push('-H', shellQuote(`${name}: ${value}`));
+ }
+ parts.push(shellQuote(endpoint.url));
+ return parts.join(' ');
+}
+
+/** POSIX single-quote escaping. */
+export function shellQuote(value) {
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
+}
diff --git a/src/core/diff.js b/src/core/diff.js
new file mode 100644
index 0000000..e03c65e
--- /dev/null
+++ b/src/core/diff.js
@@ -0,0 +1,73 @@
+/**
+ * Response Diff — structural JSON comparison.
+ *
+ * Pure function: never mutates its inputs. Arrays are compared by index
+ * (documented in the UI). Output is capped so huge payloads stay usable.
+ */
+
+const MAX_FINDINGS = 500;
+const MAX_DEPTH = 24;
+
+/**
+ * @param {*} a Older value (parsed JSON).
+ * @param {*} b Newer value (parsed JSON).
+ * @returns {import('./types.js').DiffFinding[]}
+ */
+export function diffJson(a, b) {
+ const findings = [];
+ walk('$', a, b, 0, findings);
+ return findings;
+}
+
+function walk(path, a, b, depth, findings) {
+ if (findings.length >= MAX_FINDINGS) return;
+ if (depth > MAX_DEPTH) {
+ if (!Object.is(a, b)) findings.push({ path, type: 'changed', before: summarize(a), after: summarize(b) });
+ return;
+ }
+ if (Object.is(a, b)) return;
+
+ const aIsObj = isPlainObject(a);
+ const bIsObj = isPlainObject(b);
+ if (aIsObj && bIsObj) {
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
+ for (const key of [...keys].sort()) {
+ const sub = `${path}.${key}`;
+ if (!(key in a)) findings.push({ path: sub, type: 'added', after: summarize(b[key]) });
+ else if (!(key in b)) findings.push({ path: sub, type: 'removed', before: summarize(a[key]) });
+ else walk(sub, a[key], b[key], depth + 1, findings);
+ if (findings.length >= MAX_FINDINGS) return;
+ }
+ return;
+ }
+
+ if (Array.isArray(a) && Array.isArray(b)) {
+ const len = Math.max(a.length, b.length);
+ for (let i = 0; i < len; i += 1) {
+ const sub = `${path}.${i}`;
+ if (i >= a.length) findings.push({ path: sub, type: 'added', after: summarize(b[i]) });
+ else if (i >= b.length) findings.push({ path: sub, type: 'removed', before: summarize(a[i]) });
+ else walk(sub, a[i], b[i], depth + 1, findings);
+ if (findings.length >= MAX_FINDINGS) return;
+ }
+ if (a.length !== b.length) {
+ findings.push({ path: `${path}.length`, type: 'changed', before: a.length, after: b.length });
+ }
+ return;
+ }
+
+ findings.push({ path, type: 'changed', before: summarize(a), after: summarize(b) });
+}
+
+function isPlainObject(v) {
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
+}
+
+/** Compact representation for findings display. */
+export function summarize(value) {
+ if (value === undefined) return undefined;
+ if (typeof value === 'string') return value.length > 160 ? `${value.slice(0, 160)}…` : value;
+ if (isPlainObject(value)) return `{…} ${Object.keys(value).length} keys`;
+ if (Array.isArray(value)) return `[…] ${value.length} items`;
+ return value;
+}
diff --git a/src/core/errors.js b/src/core/errors.js
new file mode 100644
index 0000000..2e83d61
--- /dev/null
+++ b/src/core/errors.js
@@ -0,0 +1,180 @@
+/**
+ * Error model for GitAPITaker.
+ *
+ * Three families of errors are always kept distinct:
+ * 1. Resolver errors — GitAPITaker could not map the input to an endpoint.
+ * 2. Provider errors — the provider answered with an HTTP error status.
+ * The provider's own body is shown unchanged; anything we add is clearly
+ * labeled as an *interpretation*.
+ * 3. Browser/network errors — the request never reached an HTTP response
+ * (DNS failure, offline, CORS block, timeout, abort).
+ */
+
+/** Error codes produced by the resolver/parser layer. */
+export const ResolverErrorCode = Object.freeze({
+ EMPTY_INPUT: 'empty-input',
+ MALFORMED_URL: 'malformed-url',
+ UNSUPPORTED_SCHEME: 'unsupported-scheme',
+ UNSUPPORTED_PROVIDER: 'unsupported-provider',
+ UNSUPPORTED_RESOURCE: 'unsupported-resource',
+ MISSING_INFO: 'missing-info',
+ INVALID_INSTANCE: 'invalid-instance',
+});
+
+/**
+ * Structured error thrown by URL normalization, provider detection,
+ * parsing and endpoint resolution. Never used for provider HTTP errors.
+ */
+export class ResolverError extends Error {
+ /**
+ * @param {string} code One of ResolverErrorCode.
+ * @param {string} message
+ * @param {string[]} [hints]
+ * @param {Array<{label: string, input?: string, goto?: string}>} [quickActions]
+ */
+ constructor(code, message, hints = [], quickActions = []) {
+ super(message);
+ this.name = 'ResolverError';
+ this.code = code;
+ this.hints = hints;
+ /** Clickable follow-ups rendered by the UI (inspect `input` or navigate `goto`). */
+ this.quickActions = quickActions;
+ /** Pipeline stage where the failure occurred ('input'|'detect'|'parse'|'resolve'). */
+ this.stage = undefined;
+ /** Partial pipeline context collected before the failure. */
+ this.context = {};
+ }
+}
+
+/**
+ * Interpret a provider HTTP error status. The result is always labeled as
+ * GitAPITaker's interpretation in the UI — it never replaces or overrides
+ * the provider's own response body.
+ *
+ * @param {number} status
+ * @param {string} providerId
+ * @param {Array<[string, string]>} [headers] Observed response headers.
+ * @param {import('./types.js').ParsedResource} [parsed]
+ * @returns {{title: string, causes: string[], actions: string[]} | null}
+ */
+export function interpretHttpStatus(status, providerId, headers = [], parsed = undefined) {
+ const headerMap = new Map(headers.map(([k, v]) => [k.toLowerCase(), v]));
+ const rateRemaining = headerMap.get('x-ratelimit-remaining');
+ const rateReset = headerMap.get('x-ratelimit-reset');
+ const resetHint = rateReset && /^\d+$/.test(rateReset)
+ ? `Rate limit window resets at ${new Date(Number(rateReset) * 1000).toISOString()}.`
+ : null;
+
+ if (status === 429 || (status === 403 && rateRemaining === '0')) {
+ return {
+ title: `HTTP ${status}: rate limited by ${providerId}`,
+ causes: [
+ 'Too many requests were made to this provider API from your network.',
+ 'This is enforced by the provider itself — GitAPITaker cannot and will not bypass it.',
+ ],
+ actions: [
+ resetHint ?? 'Wait for the provider rate-limit window to reset, then try again.',
+ 'Use the cached copy (Cache inspector) instead of re-requesting while limited.',
+ ],
+ };
+ }
+
+ switch (status) {
+ case 400:
+ return {
+ title: `HTTP 400: ${providerId} rejected the request as malformed`,
+ causes: ['The endpoint or one of its parameters is not valid for this provider.'],
+ actions: ['Check the resolved endpoint in the REQUEST view against the provider documentation.'],
+ };
+ case 401:
+ return {
+ title: `HTTP 401: ${providerId} requires authentication for this resource`,
+ causes: [
+ 'The resource is private, or this endpoint requires a token.',
+ 'GitAPITaker v0.1 performs unauthenticated requests only.',
+ ],
+ actions: ['Verify the resource is public.', 'Authentication support is planned; see About/Security.'],
+ };
+ case 403:
+ return {
+ title: `HTTP 403: ${providerId} refused the request`,
+ causes: [
+ 'The resource may be private.',
+ 'A provider-side rate limit or abuse detection may be active.',
+ 'The endpoint may require authentication or additional scopes.',
+ ],
+ actions: ['Inspect the HEADERS view for rate-limit or policy hints returned by the provider.'],
+ };
+ case 404: {
+ const causes = ['The resource does not exist at this endpoint.', 'The resource is private and hidden without authentication.'];
+ const actions = ['Double-check the original URL and spelling.'];
+ const quickActions = [];
+ if (providerId === 'github' && parsed?.resourceType === 'user') {
+ causes.push(`"${parsed.params.login}" may be an organization rather than a user. GitHub keeps separate endpoints: /users/{login} and /orgs/{org}.`);
+ actions.push(`Try the org endpoint: https://api.github.com/orgs/${parsed.params.login}`);
+ quickActions.push({ label: `Try as organization: /orgs/${parsed.params.login}`, input: `https://github.com/orgs/${parsed.params.login}` });
+ }
+ if (providerId === 'gitlab' && parsed?.resourceType === 'project') {
+ causes.push('GitLab project lookups use the full URL-encoded namespace path; a moved or renamed project changes the path.');
+ }
+ return { title: `HTTP 404: ${providerId} did not find this resource`, causes, actions, quickActions };
+ }
+ case 409:
+ return {
+ title: `HTTP 409: conflict reported by ${providerId}`,
+ causes: ['The resource state conflicts with the request (often empty repositories or conflicting refs).'],
+ actions: ['The provider body above usually names the conflicting resource.'],
+ };
+ case 422:
+ return {
+ title: `HTTP 422: ${providerId} rejected the request semantics`,
+ causes: ['The request was understood but failed provider-side validation.'],
+ actions: ['Read the provider error body for the specific validation failure.'],
+ };
+ default:
+ if (status >= 500) {
+ return {
+ title: `HTTP ${status}: ${providerId} server error`,
+ causes: ['The provider API itself failed. This is not a GitAPITaker or local network problem.'],
+ actions: ['Retry later.', 'Check the provider status page if it persists.'],
+ };
+ }
+ return null;
+ }
+}
+
+/**
+ * Classify a failed fetch (no HTTP response was produced).
+ * @param {Error} err
+ * @param {{online?: boolean}} [env]
+ * @returns {{title: string, causes: string[], actions: string[]}}
+ */
+export function interpretFetchFailure(err, env = {}) {
+ const name = err?.name ?? '';
+ const online = env.online ?? (typeof navigator === 'undefined' ? true : navigator.onLine);
+ if (name === 'AbortError') {
+ return {
+ title: 'Request timed out or was aborted',
+ causes: ['The provider did not answer within the configured timeout, or the request was cancelled.'],
+ actions: ['Try again.', 'If a cached copy exists it can be inspected offline from the Cache inspector.'],
+ };
+ }
+ if (!online) {
+ return {
+ title: 'Browser appears to be offline',
+ causes: ['No network connection is available, so the provider could not be contacted.'],
+ actions: ['Previously cached responses remain inspectable — open the Cache inspector.'],
+ };
+ }
+ return {
+ title: 'Network or CORS failure — the provider never answered',
+ causes: [
+ 'DNS failure, unreachable host, or the connection was blocked.',
+ 'The provider (or self-hosted instance) may not allow cross-origin browser requests (CORS). Public github.com / gitlab.com / gitea.com APIs do; some self-hosted instances do not.',
+ ],
+ actions: [
+ 'Open DevTools → Network for the underlying browser error.',
+ 'For self-hosted instances, ask the administrator to enable CORS for the API, or inspect a cached copy.',
+ ],
+ };
+}
diff --git a/src/core/format.js b/src/core/format.js
new file mode 100644
index 0000000..ff78262
--- /dev/null
+++ b/src/core/format.js
@@ -0,0 +1,42 @@
+/** Small deterministic formatting helpers shared by UI and tests. */
+
+/** @param {number} bytes */
+export function formatBytes(bytes) {
+ if (!Number.isFinite(bytes) || bytes < 0) return '—';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+}
+
+/** @param {number} ms */
+export function formatDuration(ms) {
+ if (!Number.isFinite(ms) || ms < 0) return '—';
+ if (ms < 1000) return `${Math.round(ms)} ms`;
+ return `${(ms / 1000).toFixed(2)} s`;
+}
+
+/** @param {number} epochMs */
+export function formatTimestamp(epochMs) {
+ if (!Number.isFinite(epochMs)) return '—';
+ return new Date(epochMs).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
+}
+
+/** Human relative age, e.g. "12 s ago", "4 min ago". @param {number} epochMs @param {number} [now] */
+export function formatAge(epochMs, now = Date.now()) {
+ const s = Math.max(0, Math.round((now - epochMs) / 1000));
+ if (s < 5) return 'just now';
+ if (s < 60) return `${s} s ago`;
+ const m = Math.round(s / 60);
+ if (m < 60) return `${m} min ago`;
+ const h = Math.round(m / 60);
+ if (h < 24) return `${h} h ago`;
+ return `${Math.round(h / 24)} d ago`;
+}
+
+/** Truncate for compact lists (never used for RAW view bodies). */
+export function truncateMiddle(s, max = 72) {
+ const str = String(s);
+ if (str.length <= max) return str;
+ const half = Math.floor((max - 1) / 2);
+ return `${str.slice(0, half)}…${str.slice(str.length - half)}`;
+}
diff --git a/src/core/guard.js b/src/core/guard.js
new file mode 100644
index 0000000..2531831
--- /dev/null
+++ b/src/core/guard.js
@@ -0,0 +1,71 @@
+/**
+ * Request Guard.
+ *
+ * A local, transparent safety mechanism that suppresses rapid repeated
+ * requests for the same endpoint and serves them from the local cache
+ * instead. It is NOT an attempt to bypass provider rate limits — provider
+ * limits still apply to every live request that does go out.
+ *
+ * State is session-local (in memory): the guard protects against bursts in
+ * the current visit; the cache provides cross-session protection.
+ */
+
+/** Default cooldown: repeated identical requests within this window are suppressed. */
+export const DEFAULT_COOLDOWN_MS = 10_000;
+
+/**
+ * @param {{now?: () => number, cooldownMs?: number}} [opts]
+ */
+export function createGuard(opts = {}) {
+ const now = opts.now ?? (() => Date.now());
+ const cooldownMs = opts.cooldownMs ?? DEFAULT_COOLDOWN_MS;
+ /** @type {Map} */
+ const state = new Map();
+
+ return {
+ cooldownMs,
+
+ /**
+ * Decide whether a request for `key` should go live or be served from cache.
+ * @param {string} key
+ * @param {{force?: boolean}} [opts]
+ * @returns {import('./types.js').GuardDecision}
+ */
+ decide(key, { force = false } = {}) {
+ const entry = state.get(key);
+ const t = now();
+ if (force) {
+ return { action: 'live', reason: 'forced', suppressedCount: entry?.suppressed ?? 0 };
+ }
+ if (entry && t - entry.lastLiveAt < cooldownMs) {
+ return {
+ action: 'cache',
+ reason: 'cooldown',
+ suppressedCount: entry.suppressed + 1,
+ nextLiveAt: entry.lastLiveAt + cooldownMs,
+ };
+ }
+ return { action: 'live', reason: entry ? 'cooldown-expired' : 'first-request', suppressedCount: entry?.suppressed ?? 0 };
+ },
+
+ /** Record that a live request went out for `key`. */
+ recordLive(key) {
+ state.set(key, { lastLiveAt: now(), suppressed: state.get(key)?.suppressed ?? 0 });
+ },
+
+ /** Record that a repeat request for `key` was suppressed. */
+ recordSuppressed(key) {
+ const entry = state.get(key);
+ if (entry) entry.suppressed += 1;
+ },
+
+ /** Inspection helper for the UI. */
+ describe(key) {
+ const entry = state.get(key);
+ if (!entry) return { suppressed: 0, lastLiveAt: null, cooldownMs };
+ return { suppressed: entry.suppressed, lastLiveAt: entry.lastLiveAt, cooldownMs };
+ },
+
+ reset() { state.clear(); },
+ };
+}
diff --git a/src/core/history.js b/src/core/history.js
new file mode 100644
index 0000000..17b5191
--- /dev/null
+++ b/src/core/history.js
@@ -0,0 +1,44 @@
+/**
+ * Request history — local to the browser, never transmitted anywhere.
+ * Stores small metadata records only (never bodies or headers).
+ */
+
+import { readJson, writeJson } from './storage.js';
+
+const KEY = 'gitapitaker.history.v1';
+const MAX_ENTRIES = 100;
+
+/** @returns {import('./types.js').HistoryEntry[]} newest first */
+export function listHistory() {
+ const value = readJson(KEY);
+ return Array.isArray(value) ? value : [];
+}
+
+/**
+ * Add (or refresh) a history entry. Entries are de-duplicated by endpoint.
+ * @param {Partial} fields
+ */
+export function addHistory(fields) {
+ const entries = listHistory().filter((e) => e.endpoint !== fields.endpoint);
+ entries.unshift({
+ id: `h-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
+ at: Date.now(),
+ providerId: fields.providerId ?? 'unknown',
+ resourceType: fields.resourceType,
+ webUrl: fields.webUrl,
+ endpoint: fields.endpoint,
+ method: fields.method ?? 'GET',
+ status: fields.status,
+ stateLabel: fields.stateLabel,
+ });
+ writeJson(KEY, entries.slice(0, MAX_ENTRIES));
+}
+
+/** @param {string} id */
+export function removeHistory(id) {
+ writeJson(KEY, listHistory().filter((e) => e.id !== id));
+}
+
+export function clearHistory() {
+ writeJson(KEY, []);
+}
diff --git a/src/core/jsonsearch.js b/src/core/jsonsearch.js
new file mode 100644
index 0000000..0c36ee0
--- /dev/null
+++ b/src/core/jsonsearch.js
@@ -0,0 +1,58 @@
+/**
+ * JSON search — pure matching logic used by the JSON viewer.
+ * Paths use JSONPath-ish notation: $.key, $.a.b, $.list[0].name
+ */
+
+/**
+ * Find all paths whose key or primitive value contains the query
+ * (case-insensitive substring match).
+ *
+ * @param {*} value Parsed JSON.
+ * @param {string} query
+ * @param {{limit?: number}} [opts]
+ * @returns {{paths: string[], count: number}}
+ */
+export function findMatches(value, query, opts = {}) {
+ const limit = opts.limit ?? 1000;
+ const q = String(query ?? '').trim().toLowerCase();
+ const paths = [];
+ if (!q) return { paths, count: 0 };
+ walk('$', value, q, paths, limit);
+ return { paths, count: paths.length };
+}
+
+/**
+ * True when any match path is inside (or equal to) the given node path.
+ * @param {string[]} matchPaths
+ * @param {string} nodePath
+ */
+export function subtreeHasMatch(matchPaths, nodePath) {
+ return matchPaths.some((p) =>
+ p === nodePath
+ || p.startsWith(`${nodePath}.`)
+ || p.startsWith(`${nodePath}[`));
+}
+
+function walk(path, v, q, paths, limit) {
+ if (paths.length >= limit) return;
+ if (v !== null && typeof v === 'object') {
+ if (Array.isArray(v)) {
+ for (let i = 0; i < v.length; i += 1) {
+ walk(`${path}[${i}]`, v[i], q, paths, limit);
+ if (paths.length >= limit) return;
+ }
+ return;
+ }
+ for (const [key, val] of Object.entries(v)) {
+ const childPath = `${path}.${key}`;
+ if (key.toLowerCase().includes(q)) {
+ paths.push(childPath);
+ if (paths.length >= limit) return;
+ }
+ walk(childPath, val, q, paths, limit);
+ }
+ return;
+ }
+ const hay = typeof v === 'string' ? v.toLowerCase() : String(v).toLowerCase();
+ if (hay.includes(q)) paths.push(path);
+}
diff --git a/src/core/pagination.js b/src/core/pagination.js
new file mode 100644
index 0000000..149a0f6
--- /dev/null
+++ b/src/core/pagination.js
@@ -0,0 +1,86 @@
+/**
+ * Pagination detection — provider-aware, header-driven, UI-agnostic.
+ *
+ * GitAPITaker never fabricates page counts: it only reports what the
+ * provider's response headers actually say.
+ * - GitHub/Gitea: RFC5988 `Link` header (rel="next"/"prev"/"last").
+ * - GitLab: `x-page`, `x-next-page`, `x-prev-page`, `x-total`, `x-per-page`.
+ */
+
+/**
+ * Parse an RFC5988 Link header into {rel: url} pairs.
+ * @param {string | null | undefined} header
+ * @returns {Record}
+ */
+export function parseLinkHeader(header) {
+ const out = {};
+ if (!header) return out;
+ for (const part of String(header).split(',')) {
+ const m = part.match(/<([^>]+)>\s*;\s*rel="([^"]+)"/);
+ if (m) out[m[2]] = m[1];
+ }
+ return out;
+}
+
+/**
+ * Detect pagination signals in a response.
+ * @param {{providerId: string, url: string, headers: Array<[string, string]>}} args
+ * @returns {null | {
+ * mode: 'link' | 'headers',
+ * nextUrl: string | null,
+ * prevUrl: string | null,
+ * lastUrl?: string | null,
+ * current?: number,
+ * total?: number,
+ * perPage?: number,
+ * }}
+ */
+export function detectPagination({ providerId, url, headers }) {
+ const headerMap = new Map((headers ?? []).map(([k, v]) => [k.toLowerCase(), v]));
+
+ if (providerId === 'gitlab') {
+ const page = headerMap.get('x-page');
+ if (!page) return null;
+ const nextPage = headerMap.get('x-next-page');
+ const prevPage = headerMap.get('x-prev-page');
+ const total = headerMap.get('x-total');
+ const perPage = headerMap.get('x-per-page');
+ const withPage = (p) => {
+ const u = new URL(url);
+ u.searchParams.set('page', String(p));
+ return u.toString();
+ };
+ return {
+ mode: 'headers',
+ current: Number(page),
+ total: total ? Number(total) : undefined,
+ perPage: perPage ? Number(perPage) : undefined,
+ nextUrl: nextPage && Number(nextPage) > 0 ? withPage(Number(nextPage)) : null,
+ prevUrl: prevPage && Number(prevPage) >= 1 ? withPage(Number(prevPage)) : null,
+ };
+ }
+
+ const link = parseLinkHeader(headerMap.get('link'));
+ if (!link.next && !link.prev) return null;
+ return {
+ mode: 'link',
+ nextUrl: link.next ?? null,
+ prevUrl: link.prev ?? null,
+ lastUrl: link.last ?? null,
+ };
+}
+
+/**
+ * Human description of a pagination state (only what is actually known).
+ * @param {ReturnType} pagination
+ */
+export function describePagination(pagination) {
+ if (!pagination) return '';
+ if (pagination.mode === 'headers') {
+ const bits = [`page ${pagination.current}`];
+ if (pagination.total !== undefined && Number.isFinite(pagination.total)) bits.push(`${pagination.total} items total`);
+ if (pagination.perPage) bits.push(`${pagination.perPage}/page`);
+ return bits.join(' · ');
+ }
+ return 'provider-supplied page links (Link header)';
+}
diff --git a/src/core/request.js b/src/core/request.js
new file mode 100644
index 0000000..82bfaf2
--- /dev/null
+++ b/src/core/request.js
@@ -0,0 +1,89 @@
+/**
+ * Request layer.
+ *
+ * Every request goes DIRECTLY from the user's browser to the provider API.
+ * There is no proxy, relay or GitAPITaker server in the middle. Nothing here
+ * fabricates data: only values actually observed from fetch() are recorded.
+ *
+ * The layer is injectable (fetchImpl, timers) so it is fully testable
+ * without live provider APIs.
+ */
+
+/** @type {number} hard timeout per request */
+export const REQUEST_TIMEOUT_MS = 30_000;
+
+/**
+ * Execute a resolved endpoint.
+ *
+ * @param {import('./types.js').ResolvedEndpoint} endpoint
+ * @param {{fetchImpl?: typeof fetch, timeoutMs?: number, now?: () => number}} [opts]
+ * @returns {Promise<{ok: true, record: import('./types.js').ResponseRecord} | {ok: false, error: Error}>}
+ */
+export async function executeEndpoint(endpoint, opts = {}) {
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
+ const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
+ const now = opts.now ?? (() => Date.now());
+ const timeOrigin = typeof performance !== 'undefined' ? () => performance.now() : () => Date.now();
+
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
+ const started = timeOrigin();
+
+ try {
+ const response = await fetchImpl(endpoint.url, {
+ method: endpoint.method,
+ headers: endpoint.headers ?? {},
+ signal: controller?.signal,
+ // never send cookies/credentials to third-party APIs
+ credentials: 'omit',
+ redirect: 'follow',
+ cache: 'no-store',
+ });
+
+ const bodyText = await response.text();
+ const durationMs = timeOrigin() - started;
+ const headers = [];
+ response.headers.forEach((value, key) => headers.push([key, value]));
+
+ /** @type {import('./types.js').ResponseRecord} */
+ const record = {
+ live: true,
+ method: endpoint.method,
+ url: endpoint.url,
+ providerId: endpoint.providerId,
+ status: response.status,
+ statusText: response.statusText || '',
+ headers,
+ bodyText,
+ sizeBytes: byteLength(bodyText),
+ durationMs,
+ fetchedAt: now(),
+ requestHeaders: { ...(endpoint.headers ?? {}) },
+ contentType: response.headers.get('content-type') ?? undefined,
+ };
+ return { ok: true, record };
+ } catch (err) {
+ return { ok: false, error: err };
+ } finally {
+ if (timer) clearTimeout(timer);
+ }
+}
+
+/** UTF-8 byte length of a body string. */
+export function byteLength(text) {
+ if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(text).length;
+ return Buffer.byteLength(text, 'utf8'); // Node (tests)
+}
+
+/**
+ * Try to parse a body as JSON. Returns {isJson, value} — never throws.
+ * Empty bodies parse as non-JSON.
+ */
+export function tryParseJson(bodyText) {
+ if (typeof bodyText !== 'string' || bodyText.trim() === '') return { isJson: false, value: undefined };
+ try {
+ return { isJson: true, value: JSON.parse(bodyText) };
+ } catch {
+ return { isJson: false, value: undefined };
+ }
+}
diff --git a/src/core/resolver.js b/src/core/resolver.js
new file mode 100644
index 0000000..f9b3725
--- /dev/null
+++ b/src/core/resolver.js
@@ -0,0 +1,110 @@
+/**
+ * Resolver pipeline: the only place that orchestrates
+ * input -> normalize -> detect provider -> parse -> resolve endpoint.
+ *
+ * Pure and DOM-free; safe to test in Node.
+ */
+
+import { normalizeInput } from './url.js';
+import { detectProvider, unsupportedProviderError } from '../providers/registry.js';
+import { listInstances } from '../providers/instances.js';
+import { ResolverError, ResolverErrorCode } from './errors.js';
+
+/**
+ * Resolve user input to a concrete API endpoint.
+ *
+ * ResolverErrors thrown from here carry pipeline annotations:
+ * `err.stage` ('input'|'detect'|'parse'|'resolve') and `err.context`
+ * with whatever the pipeline had determined before failing — the UI
+ * uses these to render an honest stage-by-stage failure view.
+ *
+ * @param {string} input
+ * @param {{instances?: import('./types.js').InstanceConfig[]}} [opts]
+ * @returns {{url: URL, provider: object, detection: object, parsed: import('./types.js').ParsedResource, endpoint: import('./types.js').ResolvedEndpoint}}
+ * @throws {ResolverError}
+ */
+export function resolveInput(input, opts = {}) {
+ const truncated = String(input ?? '').trim().slice(0, 64) || '(empty)';
+
+ let url;
+ try {
+ url = normalizeInput(input);
+ } catch (err) {
+ if (err instanceof ResolverError) {
+ err.stage = 'input';
+ err.context = { input: truncated };
+ }
+ throw err;
+ }
+
+ const instances = opts.instances ?? safeListInstances();
+ const detection = detectProvider(url, instances);
+ if (!detection) {
+ const err = unsupportedProviderError(url);
+ err.stage = 'detect';
+ err.context = { input: truncated, host: url.hostname };
+ throw err;
+ }
+
+ let parsed;
+ try {
+ parsed = detection.provider.parse(url, detection.ctx);
+ } catch (err) {
+ if (err instanceof ResolverError) {
+ err.stage = 'parse';
+ err.context = { input: truncated, host: url.hostname, providerId: detection.provider.id };
+ }
+ throw err;
+ }
+
+ let endpoint;
+ try {
+ endpoint = detection.provider.resolve(parsed, detection.ctx);
+ } catch (err) {
+ if (err instanceof ResolverError) {
+ err.stage = 'resolve';
+ err.context = { input: truncated, host: url.hostname, providerId: detection.provider.id, parsed };
+ }
+ throw err;
+ }
+
+ if (!endpoint?.url || !endpoint.providerId || !endpoint.method) {
+ const err = new ResolverError(ResolverErrorCode.MISSING_INFO, 'Provider adapter produced an incomplete endpoint.', [
+ 'This is a GitAPITaker bug — please report it with the URL you used.',
+ ]);
+ err.stage = 'resolve';
+ err.context = { input: truncated, host: url.hostname, providerId: detection.provider.id, parsed };
+ throw err;
+ }
+ return { url, provider: detection.provider, detection, parsed, endpoint };
+}
+
+/**
+ * Build a ResolvedEndpoint from an Endpoint Explorer item.
+ * Explorer items come from provider capability metadata (adapter.related()).
+ *
+ * @param {{url: string, label?: string, docUrl?: string, resourceType?: string}} item
+ * @param {{provider: object, ctx: object}} detection
+ */
+export function endpointFromExplorerItem(item, detection) {
+ return {
+ providerId: detection.provider.id,
+ method: 'GET',
+ url: item.url,
+ headers: detection.provider.requestHeaders ?? { Accept: 'application/json' },
+ label: item.label,
+ docUrl: item.docUrl,
+ resourceType: item.resourceType,
+ apiBase: detection.ctx.apiBase,
+ instanceId: detection.ctx.instanceId,
+ notes: ['Resolved from the Endpoint Explorer (provider capability metadata).'],
+ };
+}
+
+function safeListInstances() {
+ try {
+ return listInstances();
+ } catch {
+ return [];
+ }
+}
diff --git a/src/core/share.js b/src/core/share.js
new file mode 100644
index 0000000..3cacd4d
--- /dev/null
+++ b/src/core/share.js
@@ -0,0 +1,60 @@
+/**
+ * Shareable inspection URLs.
+ *
+ * A share URL encodes only the instruction "inspect this resource" — the
+ * target Git hosting URL. It NEVER contains API responses, tokens or cache
+ * data. Opening one performs a normal inspection with the usual Request
+ * Guard and cache rules applied.
+ *
+ * Hash-based (`#/inspect?u=…`) so it works on GitHub Pages repository
+ * subpaths without any server cooperation. A top-level `?u=` query is also
+ * accepted for convenience.
+ */
+
+/**
+ * @param {string} targetUrl The Git hosting URL to inspect.
+ * @param {{base?: string}} [opts] Base (origin+path) of the app; defaults to current page.
+ */
+export function buildShareUrl(targetUrl, opts = {}) {
+ const base = opts.base ?? defaultBase();
+ return `${base}#/inspect?u=${encodeURIComponent(targetUrl)}`;
+}
+
+/**
+ * Parse a shareable inspection target out of a hash and/or search string.
+ * @param {string} hash e.g. "#/inspect?u=https%3A%2F%2Fgithub.com%2Fflessan"
+ * @param {string} [search] e.g. "?u=..."
+ * @returns {string | null} target URL or null
+ */
+export function parseShareTarget(hash, search = '') {
+ const fromHash = parseQueryValue(hashQuery(hash), 'u');
+ if (fromHash) return fromHash;
+ const fromSearch = parseQueryValue(search.startsWith('?') ? search.slice(1) : search, 'u');
+ return fromSearch;
+}
+
+/** @param {string} hash @returns {string} */
+export function hashRoute(hash) {
+ const h = (hash ?? '').replace(/^#/, '');
+ const path = h.split('?')[0];
+ return path || '/';
+}
+
+function hashQuery(hash) {
+ const h = (hash ?? '').replace(/^#/, '');
+ const idx = h.indexOf('?');
+ return idx === -1 ? '' : h.slice(idx + 1);
+}
+
+function parseQueryValue(queryString, name) {
+ if (!queryString) return null;
+ const params = new URLSearchParams(queryString);
+ const value = params.get(name);
+ if (!value) return null;
+ return value;
+}
+
+function defaultBase() {
+ if (typeof location === 'undefined') return '';
+ return location.origin + location.pathname;
+}
diff --git a/src/core/storage.js b/src/core/storage.js
new file mode 100644
index 0000000..dae5417
--- /dev/null
+++ b/src/core/storage.js
@@ -0,0 +1,79 @@
+/**
+ * Storage abstraction.
+ *
+ * GitAPITaker persists only to the browser's localStorage. This wrapper
+ * degrades to an in-memory Map when localStorage is unavailable (private
+ * browsing modes, blocked storage, tests), and never throws from quota
+ * errors — callers receive structured results instead.
+ */
+
+/** Minimal key/value storage interface. @typedef {object} KvStorage */
+
+/** @returns {KvStorage} */
+function memoryStorage() {
+ const map = new Map();
+ return {
+ kind: 'memory',
+ get: (k) => (map.has(k) ? map.get(k) : null),
+ set: (k, v) => { map.set(k, v); return true; },
+ remove: (k) => { map.delete(k); },
+ keys: () => [...map.keys()],
+ clearPrefix: (prefix) => { for (const k of [...map.keys()]) if (k.startsWith(prefix)) map.delete(k); },
+ };
+}
+
+/** @returns {KvStorage} */
+function localStorageBackend() {
+ const ls = globalThis.localStorage;
+ return {
+ kind: 'localStorage',
+ get: (k) => ls.getItem(k),
+ set: (k, v) => { try { ls.setItem(k, v); return true; } catch { return false; } },
+ remove: (k) => { try { ls.removeItem(k); } catch { /* ignore */ } },
+ keys: () => { const out = []; for (let i = 0; i < ls.length; i += 1) out.push(ls.key(i)); return out; },
+ clearPrefix: (prefix) => { for (const k of localStorageBackend().keys()) if (k.startsWith(prefix)) ls.removeItem(k); },
+ };
+}
+
+let active = null;
+
+/** Lazily resolve the storage backend (localStorage if usable, memory otherwise). */
+export function getStorage() {
+ if (active) return active;
+ try {
+ if (typeof globalThis.localStorage !== 'undefined') {
+ const probe = 'gitapitaker.__probe__';
+ globalThis.localStorage.setItem(probe, '1');
+ globalThis.localStorage.removeItem(probe);
+ active = localStorageBackend();
+ return active;
+ }
+ } catch { /* fall through to memory */ }
+ active = memoryStorage();
+ return active;
+}
+
+/** Test hook: replace the backend (e.g. with a fake). */
+export function setStorageForTests(storage) {
+ active = storage;
+}
+
+/** Read and JSON-parse one namespaced value; returns null when absent/corrupt. */
+export function readJson(key) {
+ const raw = getStorage().get(key);
+ if (raw == null) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+/** Serialize and write one namespaced value. @returns {boolean} stored */
+export function writeJson(key, value) {
+ try {
+ return getStorage().set(key, JSON.stringify(value));
+ } catch {
+ return false;
+ }
+}
diff --git a/src/core/types.js b/src/core/types.js
new file mode 100644
index 0000000..42e1414
--- /dev/null
+++ b/src/core/types.js
@@ -0,0 +1,128 @@
+/**
+ * GitAPITaker core data structures (JSDoc typedefs).
+ *
+ * This file has no runtime code. It documents the explicit shapes used
+ * across the pipeline:
+ *
+ * input URL -> provider detection -> URL parser -> resource identification
+ * -> provider resolver -> API endpoint builder -> request layer
+ * -> response inspector
+ */
+
+/**
+ * A Git hosting resource identified from a website URL.
+ * Produced by a provider adapter's `parse()` and consumed by its `resolve()`.
+ * @typedef {object} ParsedResource
+ * @property {string} providerId Adapter id, e.g. "github".
+ * @property {string} resourceType e.g. "user" | "repo" | "issue" | "pull" | "commit" | ...
+ * @property {Record} params Resource-specific parameters (owner, repo, number, ref, path, ...).
+ * @property {string} originalUrl The normalized website URL this was parsed from.
+ */
+
+/**
+ * A concrete API request the application is about to (or did) perform.
+ * Built by a provider adapter's `resolve()` or by the endpoint explorer.
+ * @typedef {object} ResolvedEndpoint
+ * @property {string} providerId
+ * @property {string} method HTTP method. GitAPITaker only performs "GET".
+ * @property {string} url Final absolute API URL.
+ * @property {Record} headers Headers GitAPITaker will set on the request.
+ * @property {string} [resourceType]
+ * @property {ParsedResource} [parsed] The parsed web resource, when resolved from one.
+ * @property {string} [label] Human-readable label (used by the explorer).
+ * @property {string} [docUrl] Official documentation link for this endpoint.
+ * @property {string[]} [notes] Provider-specific mapping notes shown to the user.
+ * @property {string} [instanceId] Custom instance id when resolved against one.
+ * @property {string} [apiBase] API base URL actually used.
+ */
+
+/**
+ * The outcome of one live fetch performed by the request layer.
+ * Never fabricated: only values actually observed in the browser.
+ * @typedef {object} ResponseRecord
+ * @property {boolean} live True when the browser actually contacted the provider.
+ * @property {string} method
+ * @property {string} url
+ * @property {string} providerId
+ * @property {number} status HTTP status code.
+ * @property {string} statusText
+ * @property {Array<[string, string]>} headers Response headers as observed (CORS-exposed only).
+ * @property {string} bodyText Exact response body text as returned by Response.text().
+ * @property {number} sizeBytes
+ * @property {number} durationMs
+ * @property {number} fetchedAt Epoch millis.
+ * @property {Record} requestHeaders Headers that were set on the request.
+ * @property {string} [contentType]
+ */
+
+/**
+ * A structured cache record persisted in localStorage.
+ * @typedef {object} CacheEntry
+ * @property {string} key
+ * @property {string} providerId
+ * @property {string} method
+ * @property {string} endpoint
+ * @property {string} [webUrl] The original Git hosting URL, when known.
+ * @property {string} [resourceType]
+ * @property {number} status
+ * @property {string} statusText
+ * @property {Array<[string, string]>} headers
+ * @property {string} bodyText
+ * @property {number} sizeBytes
+ * @property {Record} requestHeaders
+ * @property {number} fetchedAt
+ * @property {number} ttlMs Freshness window used when the entry was written.
+ */
+
+/**
+ * Request Guard decision for one cache key.
+ * @typedef {object} GuardDecision
+ * @property {'live'|'cache'} action
+ * @property {string} reason e.g. "first-request" | "cooldown" | "forced".
+ * @property {number} suppressedCount Repeat requests suppressed for this key in this session.
+ * @property {number} [nextLiveAt] Epoch millis when a non-forced live request is allowed again.
+ */
+
+/**
+ * A history record. Intentionally small: never stores bodies or headers.
+ * @typedef {object} HistoryEntry
+ * @property {string} id
+ * @property {number} at Epoch millis.
+ * @property {string} providerId
+ * @property {string} [resourceType]
+ * @property {string} [webUrl]
+ * @property {string} endpoint
+ * @property {string} method
+ * @property {number} [status] Last observed status for this target.
+ * @property {string} [stateLabel] LIVE / CACHED / STALE of the last inspection.
+ */
+
+/**
+ * A user-configured self-hosted instance (Gitea/Forgejo or GitLab).
+ * @typedef {object} InstanceConfig
+ * @property {string} id
+ * @property {'gitea'|'gitlab'} kind
+ * @property {string} label
+ * @property {string} webBase e.g. "https://git.example.org"
+ * @property {string} apiBase e.g. "https://git.example.org/api/v1"
+ * @property {number} addedAt
+ */
+
+/**
+ * A structured application error (resolver errors, invalid instances, ...).
+ * @typedef {object} AppError
+ * @property {string} code Machine-readable code, e.g. "unsupported-provider".
+ * @property {string} message
+ * @property {string[]} [hints] Actionable suggestions shown to the user.
+ */
+
+/**
+ * One JSON diff finding.
+ * @typedef {object} DiffFinding
+ * @property {string} path Dotted path, arrays indexed e.g. "items.3.name".
+ * @property {'added'|'removed'|'changed'} type
+ * @property {*} [before]
+ * @property {*} [after]
+ */
+
+export {};
diff --git a/src/core/url.js b/src/core/url.js
new file mode 100644
index 0000000..a082e75
--- /dev/null
+++ b/src/core/url.js
@@ -0,0 +1,117 @@
+/**
+ * Input normalization and URL construction helpers.
+ *
+ * `normalizeInput()` accepts full URLs and reasonable shorthand forms and
+ * returns a canonical https:// website URL. It throws ResolverError with
+ * actionable hints for anything it refuses. Pure and deterministic.
+ */
+
+import { ResolverError, ResolverErrorCode } from './errors.js';
+
+/** git@host:owner/repo(.git) — common SSH remote form. */
+const SSH_FORM = /^git@([a-z0-9.-]+):(.+)$/i;
+
+/**
+ * Normalize user input into a URL object.
+ * Accepted forms:
+ * - https://github.com/flessan
+ * - github.com/flessan (scheme added)
+ * - www.github.com/flessan (www stripped)
+ * - git@github.com:owner/repo.git (SSH remote form)
+ * Query strings and fragments on website URLs are dropped.
+ *
+ * @param {string} input
+ * @returns {URL}
+ */
+export function normalizeInput(input) {
+ const raw = (input ?? '').trim();
+ if (!raw) {
+ throw new ResolverError(ResolverErrorCode.EMPTY_INPUT, 'No URL was provided.', [
+ 'Paste a Git hosting URL, for example https://github.com/flessan',
+ ]);
+ }
+
+ const ssh = raw.match(SSH_FORM);
+ if (ssh) {
+ const candidate = new URL(`https://${ssh[1]}/${ssh[2]}`);
+ return finalize(candidate, raw);
+ }
+
+ if (/^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^https?:\/\//i.test(raw)) {
+ const scheme = raw.slice(0, raw.indexOf(':'));
+ throw new ResolverError(
+ ResolverErrorCode.UNSUPPORTED_SCHEME,
+ `URL scheme "${scheme}://" is not supported.`,
+ ['GitAPITaker inspects https:// website URLs of Git hosting providers.', 'For SSH remotes, try the git@host:owner/repo.git form.'],
+ );
+ }
+
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
+ let url;
+ try {
+ url = new URL(withScheme);
+ } catch {
+ throw new ResolverError(ResolverErrorCode.MALFORMED_URL, `"${truncate(raw, 80)}" is not a valid URL.`, [
+ 'Expected something like https://github.com/owner or https://gitlab.com/group/project',
+ ]);
+ }
+ if (!/^https?:$/.test(url.protocol)) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_SCHEME, `URL scheme "${url.protocol}" is not supported.`);
+ }
+ return finalize(url, raw);
+}
+
+function finalize(url, raw) {
+ if (!url.hostname || !url.hostname.includes('.')) {
+ throw new ResolverError(ResolverErrorCode.MALFORMED_URL, `"${truncate(raw, 80)}" does not contain a hostname.`, [
+ 'Provide a full host such as github.com, gitlab.com or gitea.com.',
+ ]);
+ }
+ url.protocol = 'https:';
+ if (url.hostname.startsWith('www.')) url.hostname = url.hostname.slice(4);
+ url.search = '';
+ url.hash = '';
+ if (url.pathname.length > 1) url.pathname = url.pathname.replace(/\/+$/, '');
+ return url;
+}
+
+/**
+ * Join a base URL with encoded path segments. Each segment is encoded
+ * individually; pass-through encoding decisions belong to the adapters.
+ * @param {string} base
+ * @param {string[]} segments
+ */
+export function joinUrl(base, segments) {
+ const url = new URL(base);
+ const clean = segments.filter((s) => s !== undefined && s !== null && s !== '');
+ url.pathname = url.pathname.replace(/\/+$/, '') + '/' + clean.map((s) => String(s)).join('/');
+ return url.toString();
+}
+
+/** Encode every path component of a possibly multi-segment ref/path (slashes kept). */
+export function encodePathKeepingSlashes(value) {
+ return String(value).split('/').map((part) => encodeURIComponent(part)).join('/');
+}
+
+/** Fully encode a value including slashes (GitLab project paths, tags). */
+export function encodeFully(value) {
+ return encodeURIComponent(String(value));
+}
+
+/** Validate a user-supplied base URL such as a custom API base. Returns URL or null. */
+export function parseBaseUrl(input) {
+ const raw = (input ?? '').trim();
+ if (!raw) return null;
+ try {
+ const url = new URL(raw);
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
+ url.hash = '';
+ return url;
+ } catch {
+ return null;
+ }
+}
+
+function truncate(s, n) {
+ return s.length > n ? `${s.slice(0, n)}…` : s;
+}
diff --git a/src/providers/gitea.js b/src/providers/gitea.js
new file mode 100644
index 0000000..5baa2b5
--- /dev/null
+++ b/src/providers/gitea.js
@@ -0,0 +1,278 @@
+/**
+ * Gitea provider adapter (API v1) — also used for Forgejo instances.
+ *
+ * Built-in host: gitea.com. Self-hosted Gitea/Forgejo instances are matched
+ * through the instance registry (see providers/instances.js) and may override
+ * the API base, because not every deployment serves the API at the default
+ * /api/v1 path.
+ *
+ * Routes verified against Gitea's own router (routers/api/v1/api.go):
+ * GET /repos/{owner}/{repo}/releases/tags/{tag} (plural "tags")
+ * GET /repos/{owner}/{repo}/branches/{branch}
+ * GET /repos/{owner}/{repo}/pulls/{index}
+ * Docs: https://docs.gitea.com/api/
+ */
+
+import { ResolverError, ResolverErrorCode } from '../core/errors.js';
+import { joinUrl, encodePathKeepingSlashes, encodeFully } from '../core/url.js';
+
+const DOCS = 'https://docs.gitea.com/api/';
+const DEFAULT_API_SUFFIX = '/api/v1';
+
+/** gitea.com top-level paths that are not user/org accounts. */
+const RESERVED = new Set([
+ 'explore', 'assets', 'api', 'notifications', 'settings', 'user', 'issues',
+ 'pulls', 'events', 'dashboard', 'about', 'repos', 'stars', 'topics',
+ 'org', 'install', 'swagger',
+]);
+
+function requireNumber(value, what, url) {
+ if (!/^\d+$/.test(String(value))) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `Gitea ${what} numbers must be numeric, got "${value}".`, [
+ `Check the original URL: ${url}`,
+ ]);
+ }
+ return value;
+}
+
+export const gitea = {
+ id: 'gitea',
+ name: 'Gitea',
+ family: 'gitea',
+ docsUrl: DOCS,
+ defaultWebBase: 'https://gitea.com',
+ defaultApiBase: `https://gitea.com${DEFAULT_API_SUFFIX}`,
+ apiSuffixDefault: DEFAULT_API_SUFFIX,
+ apiInfo: {
+ versionLabel: 'API v1 (version is part of the base path /api/v1, not a request header)',
+ mediaType: 'application/json',
+ notes: [
+ 'Requests send Accept: application/json.',
+ 'Self-hosted Gitea/Forgejo instances usually expose interactive API docs at {instance}/api/swagger.',
+ ],
+ },
+ requestHeaders: { Accept: 'application/json' },
+ capabilities: {
+ selfHosted: true,
+ resources: [
+ { type: 'user', label: 'User', webPattern: '/{username}', apiPattern: '/users/{username}' },
+ { type: 'repo', label: 'Repository', webPattern: '/{owner}/{repo}', apiPattern: '/repos/{owner}/{repo}' },
+ { type: 'issue', label: 'Issue', webPattern: '/{o}/{r}/issues/{n}', apiPattern: '/repos/{o}/{r}/issues/{n}' },
+ { type: 'pull', label: 'Pull request', webPattern: '/{o}/{r}/pulls/{n}', apiPattern: '/repos/{o}/{r}/pulls/{n}' },
+ { type: 'commit', label: 'Commit', webPattern: '/{o}/{r}/commit/{sha}', apiPattern: '/repos/{o}/{r}/git/commits/{sha}' },
+ { type: 'commits', label: 'Commit list', webPattern: '/{o}/{r}/commits', apiPattern: '/repos/{o}/{r}/commits' },
+ { type: 'releases', label: 'Releases', webPattern: '/{o}/{r}/releases', apiPattern: '/repos/{o}/{r}/releases' },
+ { type: 'release-by-tag', label: 'Release by tag', webPattern: '/{o}/{r}/releases/tag/{tag}', apiPattern: '/repos/{o}/{r}/releases/tags/{tag}' },
+ { type: 'release-latest', label: 'Latest release', webPattern: '(API only)', apiPattern: '/repos/{o}/{r}/releases/latest' },
+ { type: 'branches', label: 'Branch list', webPattern: '/{o}/{r}/branches', apiPattern: '/repos/{o}/{r}/branches' },
+ { type: 'branch', label: 'Branch', webPattern: '/{o}/{r}/src/branch/{branch}', apiPattern: '/repos/{o}/{r}/branches/{branch}' },
+ { type: 'tags', label: 'Tag list', webPattern: '/{o}/{r}/tags', apiPattern: '/repos/{o}/{r}/tags' },
+ { type: 'contents', label: 'File contents', webPattern: '/{o}/{r}/src/branch/{ref}/{path}', apiPattern: '/repos/{o}/{r}/contents/{path}?ref={ref}' },
+ ],
+ limitations: [
+ 'Web URLs use /src/branch/{branch}/{path}; GitAPITaker maps single-segment forms to branches and treats the first segment as the ref for files (heuristic).',
+ 'release-by-tag uses /releases/tags/{tag} (plural), which requires a reasonably recent Gitea/Forgejo version.',
+ 'Self-hosted instances must be registered under Providers before their URLs resolve.',
+ ],
+ },
+
+ match(url) {
+ return url.hostname === 'gitea.com';
+ },
+
+ /** @param {URL} url */
+ parse(url) {
+ const original = url.toString();
+ let segs = url.pathname.split('/').filter(Boolean);
+ if (segs.length > 0) segs[segs.length - 1] = segs[segs.length - 1].replace(/\.git$/, '');
+ if (segs.length === 0) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, 'The Gitea homepage does not map to a single API resource.', [
+ 'Provide a user or repository URL, e.g. https://gitea.com/gitea/gitea',
+ ]);
+ }
+
+ if (segs.length === 1) {
+ const name = segs[0];
+ if (RESERVED.has(name.toLowerCase())) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `"/${name}" is a site page on this Gitea instance, not an API resource.`);
+ }
+ return mk('user', { username: name }, original);
+ }
+
+ const owner = segs[0];
+ if (RESERVED.has(owner.toLowerCase())) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `"/${owner}/…" is not a repository path on this Gitea instance.`);
+ }
+ const repo = segs[1];
+ if (segs.length === 2) return mk('repo', { owner, repo }, original);
+
+ const kind = segs[2];
+ const rest = segs.slice(3);
+ switch (kind) {
+ case 'issues':
+ if (rest.length === 0) return mk('issues', { owner, repo }, original);
+ return mk('issue', { owner, repo, number: requireNumber(rest[0], 'issue', original) }, original);
+ case 'pulls':
+ if (rest.length === 0) return mk('pulls', { owner, repo }, original);
+ return mk('pull', { owner, repo, number: requireNumber(rest[0], 'pull request', original) }, original);
+ case 'commit':
+ return mk('commit', { owner, repo, sha: rest[0] }, original);
+ case 'commits':
+ if (rest.length > 0 && rest[0] === 'branch') return mk('commits', { owner, repo, ref: rest.slice(1).join('/') || undefined }, original);
+ return mk('commits', { owner, repo }, original);
+ case 'releases':
+ if (rest.length === 0) return mk('releases', { owner, repo }, original);
+ if (rest[0] === 'tag') {
+ if (rest.length < 2) throw new ResolverError(ResolverErrorCode.MISSING_INFO, 'Missing tag name after /releases/tag/.');
+ return mk('release-by-tag', { owner, repo, tag: rest.slice(1).join('/') }, original);
+ }
+ if (rest[0] === 'latest') return mk('release-latest', { owner, repo }, original);
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `Unsupported Gitea releases path "/releases/${rest[0]}".`);
+ case 'tags':
+ return mk('tags', { owner, repo }, original);
+ case 'branches':
+ return mk('branches', { owner, repo }, original);
+ case 'src': {
+ // Gitea web browsing URLs: /src/branch/{branch}[/{path...}]
+ if (rest[0] === 'branch') {
+ if (rest.length === 2) return mk('branch', { owner, repo, branch: rest[1] }, original);
+ if (rest.length > 2) {
+ return mk('contents', { owner, repo, ref: rest[1], path: rest.slice(2).join('/') }, original);
+ }
+ }
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, 'This Gitea browsing URL could not be mapped (expected /src/branch/{branch}[/{path}]).');
+ }
+ default:
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `Gitea path "/${owner}/${repo}/${kind}" has no API mapping in GitAPITaker yet.`, [
+ `See the official docs for what exists: ${DOCS}`,
+ ]);
+ }
+ },
+
+ /**
+ * @param {import('../core/types.js').ParsedResource} parsed
+ * @param {{apiBase: string, instanceId?: string}} ctx
+ */
+ resolve(parsed, ctx) {
+ const api = ctx.apiBase.replace(/\/+$/, '');
+ const p = parsed.params;
+ const common = {
+ providerId: 'gitea',
+ method: 'GET',
+ parsed,
+ headers: { Accept: 'application/json' },
+ apiBase: api,
+ instanceId: ctx.instanceId,
+ notes: [],
+ };
+ const repoBase = () => joinUrl(api, ['repos', p.owner, p.repo]);
+
+ switch (parsed.resourceType) {
+ case 'user':
+ return { ...common, resourceType: 'user', url: joinUrl(api, ['users', p.username]), docUrl: DOCS, label: `Gitea user ${p.username}` };
+ case 'repo':
+ return { ...common, resourceType: 'repo', url: repoBase(), docUrl: DOCS, label: `Repository ${p.owner}/${p.repo}` };
+ case 'issue':
+ return { ...common, resourceType: 'issue', url: joinUrl(repoBase(), ['issues', p.number]), docUrl: DOCS, label: `Issue #${p.number}` };
+ case 'issues':
+ return { ...common, resourceType: 'issues', url: joinUrl(repoBase(), ['issues']), docUrl: DOCS, label: `Issues of ${p.owner}/${p.repo}` };
+ case 'pull':
+ return { ...common, resourceType: 'pull', url: joinUrl(repoBase(), ['pulls', p.number]), docUrl: DOCS, label: `Pull request #${p.number}` };
+ case 'pulls':
+ return { ...common, resourceType: 'pulls', url: joinUrl(repoBase(), ['pulls']), docUrl: DOCS, label: `Pull requests of ${p.owner}/${p.repo}` };
+ case 'commit':
+ return { ...common, resourceType: 'commit', url: joinUrl(repoBase(), ['git', 'commits', p.sha]), docUrl: DOCS, label: `Commit ${p.sha.slice(0, 10)}` };
+ case 'commits': {
+ let url = joinUrl(repoBase(), ['commits']);
+ if (p.ref) url += `?sha=${encodeURIComponent(p.ref)}`;
+ return { ...common, resourceType: 'commits', url, docUrl: DOCS, label: `Commits of ${p.owner}/${p.repo}` };
+ }
+ case 'releases':
+ return { ...common, resourceType: 'releases', url: joinUrl(repoBase(), ['releases']), docUrl: DOCS, label: `Releases of ${p.owner}/${p.repo}` };
+ case 'release-by-tag':
+ return {
+ ...common, resourceType: 'release-by-tag', url: joinUrl(repoBase(), ['releases', 'tags', encodePathKeepingSlashes(p.tag)]), docUrl: DOCS, label: `Release ${p.tag}`,
+ notes: ['Requires Gitea ≥ 1.14 / recent Forgejo; older instances may not serve /releases/tags/{tag}.'],
+ };
+ case 'release-latest':
+ return { ...common, resourceType: 'release-latest', url: joinUrl(repoBase(), ['releases', 'latest']), docUrl: DOCS, label: `Latest release of ${p.owner}/${p.repo}` };
+ case 'branches':
+ return { ...common, resourceType: 'branches', url: joinUrl(repoBase(), ['branches']), docUrl: DOCS, label: `Branches of ${p.owner}/${p.repo}` };
+ case 'branch':
+ return { ...common, resourceType: 'branch', url: joinUrl(repoBase(), ['branches', encodePathKeepingSlashes(p.branch)]), docUrl: DOCS, label: `Branch ${p.branch}` };
+ case 'tags':
+ return { ...common, resourceType: 'tags', url: joinUrl(repoBase(), ['tags']), docUrl: DOCS, label: `Tags of ${p.owner}/${p.repo}` };
+ case 'contents': {
+ const url = new URL(joinUrl(repoBase(), ['contents', encodePathKeepingSlashes(p.path)]));
+ url.searchParams.set('ref', p.ref);
+ return {
+ ...common, resourceType: 'contents', url: url.toString(), docUrl: DOCS, label: `File ${p.path}`,
+ notes: ['Gitea browsing URLs mix ref and path; the first path segment after /src/branch was taken as the ref.'],
+ };
+ }
+ default:
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `Gitea resource type "${parsed.resourceType}" cannot be resolved.`);
+ }
+ },
+
+ /** @param {import('../core/types.js').ParsedResource} parsed @param {{apiBase: string}} ctx */
+ related(parsed, ctx) {
+ const api = ctx.apiBase.replace(/\/+$/, '');
+ const p = parsed.params;
+ const item = (label, url, resourceType) => ({ label, url, docUrl: DOCS, resourceType });
+ const users = (sub) => joinUrl(api, ['users', p.username, sub]);
+ const repos = (sub) => joinUrl(api, ['repos', p.owner, p.repo, ...(sub ? sub.split('/') : [])]);
+
+ switch (parsed.resourceType) {
+ case 'user':
+ return [
+ item(`Repositories of ${p.username}`, users('repos'), 'repos'),
+ item(`Followers of ${p.username}`, users('followers'), 'followers'),
+ item(`Followed by ${p.username}`, users('following'), 'following'),
+ item(`Organizations of ${p.username}`, users('orgs'), 'orgs'),
+ item(`Starred by ${p.username}`, users('starred'), 'starred'),
+ ];
+ case 'repo':
+ return [
+ item('Issues', repos('issues'), 'issues'),
+ item('Pull requests', repos('pulls'), 'pulls'),
+ item('Commits', repos('commits'), 'commits'),
+ item('Releases', repos('releases'), 'releases'),
+ item('Branches', repos('branches'), 'branches'),
+ item('Tags', repos('tags'), 'tags'),
+ item('Root contents', repos('contents'), 'contents'),
+ item('Forks', repos('forks'), 'forks'),
+ item('Stargazers', repos('stargazers'), 'stargazers'),
+ ];
+ case 'issue':
+ return [item(`Comments on #${p.number}`, repos(`issues/${p.number}/comments`), 'comments')];
+ case 'pull':
+ return [
+ item(`Commits in #${p.number}`, repos(`pulls/${p.number}/commits`), 'commits'),
+ item(`Files in #${p.number}`, repos(`pulls/${p.number}/files`), 'files'),
+ item(`Comments on #${p.number}`, repos(`pulls/${p.number}/comments`), 'comments'),
+ ];
+ default:
+ return [];
+ }
+ },
+
+ describe(parsed) {
+ const p = parsed.params;
+ switch (parsed.resourceType) {
+ case 'user': return `Gitea user "${p.username}"`;
+ case 'repo': return `Gitea repository ${p.owner}/${p.repo}`;
+ case 'issue': return `issue ${p.owner}/${p.repo}#${p.number}`;
+ case 'pull': return `pull request ${p.owner}/${p.repo}#${p.number}`;
+ case 'commit': return `commit ${p.sha.slice(0, 10)} of ${p.owner}/${p.repo}`;
+ default: return `${parsed.resourceType} of ${p.owner ? `${p.owner}/${p.repo}` : p.username ?? ''}`;
+ }
+ },
+};
+
+/** Convenience export for self-hosted Forgejo — same adapter, distinct label. */
+export const forgejo = gitea;
+
+function mk(resourceType, params, originalUrl) {
+ return { providerId: 'gitea', resourceType, params, originalUrl };
+}
diff --git a/src/providers/github.js b/src/providers/github.js
new file mode 100644
index 0000000..b05db93
--- /dev/null
+++ b/src/providers/github.js
@@ -0,0 +1,300 @@
+/**
+ * GitHub provider adapter (REST API v2022-11-28).
+ *
+ * Owns all GitHub-specific knowledge: which website hosts match, how website
+ * paths map to resources, and how those resources map to api.github.com
+ * endpoints. Docs: https://docs.github.com/en/rest
+ */
+
+import { ResolverError, ResolverErrorCode } from '../core/errors.js';
+import { joinUrl, encodePathKeepingSlashes } from '../core/url.js';
+
+const API_BASE = 'https://api.github.com';
+const API_VERSION = '2022-11-28';
+const ACCEPT = 'application/vnd.github+json';
+const DOCS = 'https://docs.github.com/en/rest';
+
+/** Top-level github.com paths that are never user accounts. */
+const RESERVED = new Set([
+ 'orgs', 'topics', 'collections', 'search', 'features', 'marketplace', 'sponsors',
+ 'settings', 'notifications', 'new', 'login', 'join', 'signup', 'about', 'pricing',
+ 'security', 'enterprise', 'explore', 'events', 'trending', 'readmes', 'nonprofit',
+ 'site', 'contact', 'blog', 'business', 'partners', 'press', 'legal', 'careers',
+ 'support', 'integrations', 'stars', 'pulls', 'issues', 'codespaces', 'copilot',
+ 'organizations', 'users', 'account', 'dashboard', 'watching', 'premium',
+]);
+
+const DOCS_BY_RESOURCE = {
+ user: `${DOCS}/users/users#get-a-user`,
+ org: `${DOCS}/orgs/orgs#get-an-organization`,
+ repo: `${DOCS}/repos/repos#get-a-repository`,
+ issue: `${DOCS}/issues/issues#get-an-issue`,
+ issues: `${DOCS}/issues/issues#list-repository-issues`,
+ pull: `${DOCS}/pulls/pulls#get-a-pull-request`,
+ pulls: `${DOCS}/pulls/pulls#list-pull-requests`,
+ commit: `${DOCS}/commits/commits#get-a-commit`,
+ commits: `${DOCS}/commits/commits#list-commits`,
+ releases: `${DOCS}/releases/releases#list-releases`,
+ 'release-by-tag': `${DOCS}/releases/releases#get-a-release-by-tag-name`,
+ 'release-latest': `${DOCS}/releases/releases#get-the-latest-release`,
+ branches: `${DOCS}/branches/branches#list-branches`,
+ branch: `${DOCS}/branches/branches#get-a-branch`,
+ tags: `${DOCS}/repos/repos#list-repository-tags`,
+ contents: `${DOCS}/repos/contents#get-repository-content`,
+};
+
+function requireNumber(value, what, url) {
+ if (!/^\d+$/.test(String(value))) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `GitHub ${what} numbers must be numeric, got "${value}".`, [
+ `Check the original URL: ${url}`,
+ ]);
+ }
+ return value;
+}
+
+export const github = {
+ id: 'github',
+ name: 'GitHub',
+ family: 'github',
+ docsUrl: DOCS,
+ defaultWebBase: 'https://github.com',
+ defaultApiBase: API_BASE,
+ apiInfo: {
+ versionLabel: `REST API, version header X-GitHub-Api-Version: ${API_VERSION}`,
+ mediaType: ACCEPT,
+ notes: ['Requests send Accept: application/vnd.github+json and X-GitHub-Api-Version: 2022-11-28.'],
+ },
+ requestHeaders: { Accept: ACCEPT, 'X-GitHub-Api-Version': API_VERSION },
+ capabilities: {
+ selfHosted: false,
+ resources: [
+ { type: 'user', label: 'User', webPattern: '/{login}', apiPattern: '/users/{login}' },
+ { type: 'org', label: 'Organization', webPattern: '/orgs/{org}', apiPattern: '/orgs/{org}' },
+ { type: 'repo', label: 'Repository', webPattern: '/{owner}/{repo}', apiPattern: '/repos/{owner}/{repo}' },
+ { type: 'issue', label: 'Issue', webPattern: '/{o}/{r}/issues/{n}', apiPattern: '/repos/{o}/{r}/issues/{n}' },
+ { type: 'pull', label: 'Pull request', webPattern: '/{o}/{r}/pull/{n}', apiPattern: '/repos/{o}/{r}/pulls/{n}' },
+ { type: 'commit', label: 'Commit', webPattern: '/{o}/{r}/commit/{sha}', apiPattern: '/repos/{o}/{r}/commits/{sha}' },
+ { type: 'commits', label: 'Commit list', webPattern: '/{o}/{r}/commits', apiPattern: '/repos/{o}/{r}/commits' },
+ { type: 'releases', label: 'Releases', webPattern: '/{o}/{r}/releases', apiPattern: '/repos/{o}/{r}/releases' },
+ { type: 'release-by-tag', label: 'Release by tag', webPattern: '/{o}/{r}/releases/tag/{tag}', apiPattern: '/repos/{o}/{r}/releases/tags/{tag}' },
+ { type: 'release-latest', label: 'Latest release', webPattern: '/{o}/{r}/releases/latest', apiPattern: '/repos/{o}/{r}/releases/latest' },
+ { type: 'branches', label: 'Branch list', webPattern: '/{o}/{r}/branches', apiPattern: '/repos/{o}/{r}/branches' },
+ { type: 'branch', label: 'Branch', webPattern: '/{o}/{r}/tree/{branch}', apiPattern: '/repos/{o}/{r}/branches/{branch}' },
+ { type: 'tags', label: 'Tag list', webPattern: '/{o}/{r}/tags', apiPattern: '/repos/{o}/{r}/tags' },
+ { type: 'contents', label: 'File contents', webPattern: '/{o}/{r}/blob/{ref}/{path}', apiPattern: '/repos/{o}/{r}/contents/{path}?ref={ref}' },
+ ],
+ limitations: [
+ 'github.com/{name} is ambiguous between users and organizations; GitAPITaker resolves it to /users/{name} and suggests /orgs/{name} when a 404 comes back.',
+ 'Wiki, Projects, Actions, Discussions and Security pages have no direct mapping yet.',
+ 'tree/blob URLs mix a git ref and a path; the first segment after blob is treated as the ref (heuristic).',
+ ],
+ },
+
+ match(url) {
+ return url.hostname === 'github.com';
+ },
+
+ /** @param {URL} url */
+ parse(url) {
+ const segs = url.pathname.split('/').filter(Boolean);
+ const original = url.toString();
+ if (segs.length === 0) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, 'The github.com homepage does not map to a single API resource.', [
+ 'Provide a user, organization or repository URL, e.g. https://github.com/flessan',
+ ]);
+ }
+
+ if (segs[0] === 'orgs') {
+ if (segs.length < 2) throw new ResolverError(ResolverErrorCode.MISSING_INFO, 'Missing organization name after /orgs/.');
+ return mk('org', { org: segs[1] }, original);
+ }
+ if (segs.length === 1) {
+ const login = segs[0];
+ if (RESERVED.has(login.toLowerCase())) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `"github.com/${login}" is a GitHub site page, not an API resource.`);
+ }
+ return mk('user', { login }, original);
+ }
+
+ const owner = segs[0];
+ if (RESERVED.has(owner.toLowerCase())) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `"github.com/${owner}/…" is not a repository path GitHub exposes via this API.`);
+ }
+ const repo = segs[1].replace(/\.git$/, '');
+ if (segs.length === 2) return mk('repo', { owner, repo }, original);
+
+ const kind = segs[2];
+ const rest = segs.slice(3);
+ switch (kind) {
+ case 'issues':
+ if (rest.length === 0) return mk('issues', { owner, repo }, original);
+ return mk('issue', { owner, repo, number: requireNumber(rest[0], 'issue', original) }, original);
+ case 'pulls':
+ return mk('pulls', { owner, repo }, original);
+ case 'pull':
+ return mk('pull', { owner, repo, number: requireNumber(rest[0], 'pull request', original) }, original);
+ case 'commit':
+ return mk('commit', { owner, repo, sha: rest[0] }, original);
+ case 'commits':
+ if (rest.length > 0) return mk('commit', { owner, repo, sha: rest[0] }, original);
+ return mk('commits', { owner, repo }, original);
+ case 'releases':
+ if (rest.length === 0) return mk('releases', { owner, repo }, original);
+ if (rest[0] === 'tag') {
+ if (rest.length < 2) throw new ResolverError(ResolverErrorCode.MISSING_INFO, 'Missing tag name after /releases/tag/.');
+ return mk('release-by-tag', { owner, repo, tag: rest.slice(1).join('/') }, original);
+ }
+ if (rest[0] === 'latest') return mk('release-latest', { owner, repo }, original);
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `Unsupported GitHub releases path "/releases/${rest[0]}".`);
+ case 'tags':
+ return mk('tags', { owner, repo }, original);
+ case 'branches':
+ return mk('branches', { owner, repo }, original);
+ case 'tree': {
+ if (rest.length === 1) return mk('branch', { owner, repo, branch: rest[0] }, original);
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, 'GitHub tree URLs mix a branch name and a directory path, which cannot be split unambiguously without git data.', [
+ 'Inspect the branch list instead, or the specific file via its blob URL.',
+ ]);
+ }
+ case 'blob': {
+ if (rest.length < 2) throw new ResolverError(ResolverErrorCode.MISSING_INFO, 'Blob URLs need a ref and a file path, e.g. /blob/main/README.md');
+ return mk('contents', { owner, repo, ref: rest[0], path: rest.slice(1).join('/') }, original);
+ }
+ default:
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `GitHub path "/${owner}/${repo}/${kind}" has no API mapping in GitAPITaker yet.`, [
+ 'Wiki, Actions, Projects, Discussions and Security pages are not mapped.',
+ `See the official docs for what exists: ${DOCS}`,
+ ]);
+ }
+ },
+
+ /** @param {import('../core/types.js').ParsedResource} parsed */
+ resolve(parsed) {
+ const p = parsed.params;
+ const common = {
+ providerId: 'github',
+ method: 'GET',
+ parsed,
+ headers: { Accept: ACCEPT, 'X-GitHub-Api-Version': API_VERSION },
+ apiBase: API_BASE,
+ notes: [],
+ };
+ const repoBase = () => joinUrl(API_BASE, ['repos', p.owner, p.repo]);
+ switch (parsed.resourceType) {
+ case 'user':
+ return { ...common, resourceType: 'user', url: joinUrl(API_BASE, ['users', p.login]), docUrl: DOCS_BY_RESOURCE.user, label: `GitHub user ${p.login}` };
+ case 'org':
+ return { ...common, resourceType: 'org', url: joinUrl(API_BASE, ['orgs', p.org]), docUrl: DOCS_BY_RESOURCE.org, label: `GitHub org ${p.org}` };
+ case 'repo':
+ return { ...common, resourceType: 'repo', url: repoBase(), docUrl: DOCS_BY_RESOURCE.repo, label: `Repository ${p.owner}/${p.repo}` };
+ case 'issue':
+ return { ...common, resourceType: 'issue', url: joinUrl(repoBase(), ['issues', p.number]), docUrl: DOCS_BY_RESOURCE.issue, label: `Issue #${p.number}` };
+ case 'issues':
+ return { ...common, resourceType: 'issues', url: joinUrl(repoBase(), ['issues']), docUrl: DOCS_BY_RESOURCE.issues, label: `Issues of ${p.owner}/${p.repo}` };
+ case 'pull':
+ return { ...common, resourceType: 'pull', url: joinUrl(repoBase(), ['pulls', p.number]), docUrl: DOCS_BY_RESOURCE.pull, label: `Pull request #${p.number}` };
+ case 'pulls':
+ return { ...common, resourceType: 'pulls', url: joinUrl(repoBase(), ['pulls']), docUrl: DOCS_BY_RESOURCE.pulls, label: `Pull requests of ${p.owner}/${p.repo}` };
+ case 'commit':
+ return { ...common, resourceType: 'commit', url: joinUrl(repoBase(), ['commits', p.sha]), docUrl: DOCS_BY_RESOURCE.commit, label: `Commit ${p.sha.slice(0, 10)}` };
+ case 'commits':
+ return { ...common, resourceType: 'commits', url: joinUrl(repoBase(), ['commits']), docUrl: DOCS_BY_RESOURCE.commits, label: `Commits of ${p.owner}/${p.repo}` };
+ case 'releases':
+ return { ...common, resourceType: 'releases', url: joinUrl(repoBase(), ['releases']), docUrl: DOCS_BY_RESOURCE.releases, label: `Releases of ${p.owner}/${p.repo}` };
+ case 'release-by-tag':
+ return { ...common, resourceType: 'release-by-tag', url: joinUrl(repoBase(), ['releases', 'tags', encodePathKeepingSlashes(p.tag)]), docUrl: DOCS_BY_RESOURCE['release-by-tag'], label: `Release ${p.tag}` };
+ case 'release-latest':
+ return { ...common, resourceType: 'release-latest', url: joinUrl(repoBase(), ['releases', 'latest']), docUrl: DOCS_BY_RESOURCE['release-latest'], label: `Latest release of ${p.owner}/${p.repo}` };
+ case 'branches':
+ return { ...common, resourceType: 'branches', url: joinUrl(repoBase(), ['branches']), docUrl: DOCS_BY_RESOURCE.branches, label: `Branches of ${p.owner}/${p.repo}` };
+ case 'branch':
+ return { ...common, resourceType: 'branch', url: joinUrl(repoBase(), ['branches', encodePathKeepingSlashes(p.branch)]), docUrl: DOCS_BY_RESOURCE.branch, label: `Branch ${p.branch}` };
+ case 'tags':
+ return { ...common, resourceType: 'tags', url: joinUrl(repoBase(), ['tags']), docUrl: DOCS_BY_RESOURCE.tags, label: `Tags of ${p.owner}/${p.repo}` };
+ case 'contents': {
+ const url = new URL(joinUrl(repoBase(), ['contents', encodePathKeepingSlashes(p.path)]));
+ url.searchParams.set('ref', p.ref);
+ return {
+ ...common, resourceType: 'contents', url: url.toString(), docUrl: DOCS_BY_RESOURCE.contents, label: `File ${p.path}`,
+ notes: ['blob URLs mix ref and path; the first path segment after /blob was taken as the ref.'],
+ };
+ }
+ default:
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `GitHub resource type "${parsed.resourceType}" cannot be resolved.`);
+ }
+ },
+
+ /**
+ * Endpoint Explorer data: related resources for an already-parsed resource.
+ * Purely capability-metadata driven; the UI renders whatever this returns.
+ */
+ related(parsed) {
+ const p = parsed.params;
+ const item = (label, url, docUrl, resourceType) => ({ label, url, docUrl, resourceType });
+ const users = (sub) => joinUrl(API_BASE, ['users', p.login, sub]);
+ const repos = (sub) => joinUrl(API_BASE, ['repos', p.owner, p.repo, ...(sub ? sub.split('/') : [])]);
+
+ switch (parsed.resourceType) {
+ case 'user':
+ return [
+ item(`Repositories of ${p.login}`, users('repos'), `${DOCS}/repos/repos#list-public-repositories-for-a-user`, 'repos'),
+ item(`Followers of ${p.login}`, users('followers'), `${DOCS}/users/followers#list-followers-of-a-user`, 'followers'),
+ item(`Followed by ${p.login}`, users('following'), `${DOCS}/users/followers#list-the-people-a-user-follows`, 'following'),
+ item(`Gists of ${p.login}`, users('gists'), `${DOCS}/gists/gists#list-gists-for-a-user`, 'gists'),
+ item(`Organizations of ${p.login}`, users('orgs'), `${DOCS}/orgs/orgs#list-organizations-for-a-user`, 'orgs'),
+ item(`Public events of ${p.login}`, users('events'), `${DOCS}/activity/events`, 'events'),
+ item(`Events received by ${p.login}`, users('received_events'), `${DOCS}/activity/events#list-events-received-by-the-authenticated-user`, 'received_events'),
+ item(`Starred by ${p.login}`, users('starred'), `${DOCS}/activity/starring#list-repositories-starred-by-a-user`, 'starred'),
+ ];
+ case 'org':
+ return [
+ item(`Repositories of ${p.org}`, joinUrl(API_BASE, ['orgs', p.org, 'repos']), `${DOCS}/repos/repos#list-organization-repositories`, 'repos'),
+ item(`Members of ${p.org}`, joinUrl(API_BASE, ['orgs', p.org, 'members']), `${DOCS}/orgs/members#list-organization-members`, 'members'),
+ item(`Events of ${p.org}`, joinUrl(API_BASE, ['orgs', p.org, 'events']), `${DOCS}/activity/events#list-public-organization-events`, 'events'),
+ ];
+ case 'repo':
+ return [
+ item('Issues', repos('issues'), DOCS_BY_RESOURCE.issues, 'issues'),
+ item('Pull requests', repos('pulls'), DOCS_BY_RESOURCE.pulls, 'pulls'),
+ item('Commits', repos('commits'), DOCS_BY_RESOURCE.commits, 'commits'),
+ item('Releases', repos('releases'), DOCS_BY_RESOURCE.releases, 'releases'),
+ item('Branches', repos('branches'), DOCS_BY_RESOURCE.branches, 'branches'),
+ item('Tags', repos('tags'), DOCS_BY_RESOURCE.tags, 'tags'),
+ item('Root contents', repos('contents'), DOCS_BY_RESOURCE.contents, 'contents'),
+ item('Contributors', repos('contributors'), `${DOCS}/repos/repos#list-repository-contributors`, 'contributors'),
+ item('Languages', repos('languages'), `${DOCS}/repos/repos#list-repository-languages`, 'languages'),
+ item('Forks', repos('forks'), `${DOCS}/repos/forks#list-forks`, 'forks'),
+ ];
+ case 'issue':
+ return [
+ item(`Comments on #${p.number}`, repos(`issues/${p.number}/comments`), `${DOCS}/issues/comments#list-issue-comments`, 'comments'),
+ item(`Labels on #${p.number}`, repos(`issues/${p.number}/labels`), `${DOCS}/issues/labels#list-labels-for-an-issue`, 'labels'),
+ ];
+ case 'pull':
+ return [
+ item(`Commits in #${p.number}`, repos(`pulls/${p.number}/commits`), `${DOCS}/pulls/pulls#list-commits-on-a-pull-request`, 'commits'),
+ item(`Files in #${p.number}`, repos(`pulls/${p.number}/files`), `${DOCS}/pulls/pulls#list-pull-requests-files`, 'files'),
+ item(`Reviews on #${p.number}`, repos(`pulls/${p.number}/reviews`), `${DOCS}/pulls/reviews#list-reviews-for-a-pull-request`, 'reviews'),
+ ];
+ default:
+ return [];
+ }
+ },
+
+ describe(parsed) {
+ const p = parsed.params;
+ switch (parsed.resourceType) {
+ case 'user': return `GitHub user "${p.login}"`;
+ case 'org': return `GitHub organization "${p.org}"`;
+ case 'repo': return `GitHub repository ${p.owner}/${p.repo}`;
+ case 'issue': return `issue ${p.owner}/${p.repo}#${p.number}`;
+ case 'pull': return `pull request ${p.owner}/${p.repo}#${p.number}`;
+ case 'commit': return `commit ${p.sha.slice(0, 10)} of ${p.owner}/${p.repo}`;
+ default: return `${parsed.resourceType} of ${p.owner ? `${p.owner}/${p.repo}` : p.login ?? ''}`;
+ }
+ },
+};
+
+function mk(resourceType, params, originalUrl) {
+ return { providerId: 'github', resourceType, params, originalUrl };
+}
diff --git a/src/providers/gitlab.js b/src/providers/gitlab.js
new file mode 100644
index 0000000..4979e1f
--- /dev/null
+++ b/src/providers/gitlab.js
@@ -0,0 +1,246 @@
+/**
+ * GitLab provider adapter (REST API v4).
+ *
+ * GitLab deliberately does NOT map 1:1 from website URLs to API URLs:
+ * - users are looked up by query parameter: /api/v4/users?username={login}
+ * - projects are addressed by URL-encoded full path: /api/v4/projects/{url-encoded-path}
+ * - website project sub-resources live under a "/-/" separator
+ * - the API version is part of the base path (/api/v4), not a header
+ * This adapter owns all of those rules. Docs: https://docs.gitlab.com/api/rest/
+ */
+
+import { ResolverError, ResolverErrorCode } from '../core/errors.js';
+import { joinUrl, encodeFully, encodePathKeepingSlashes } from '../core/url.js';
+
+const DOCS = 'https://docs.gitlab.com/api/rest/';
+const DEFAULT_API_SUFFIX = '/api/v4';
+
+/** gitlab.com top-level paths that are not users/groups/projects. */
+const RESERVED = new Set([
+ 'explore', 'help', 'admin', 'dashboard', 'search', 'users', 'groups', 'projects',
+ 'api', '-', 'snippets', 'preferences', 'profile', 'activity', 'issues',
+ 'merge_requests', 'todos', 'milestones', 'labels', 'boards', 'playground',
+]);
+
+function requireNumber(value, what, url) {
+ if (!/^\d+$/.test(String(value))) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `GitLab ${what} ids must be numeric, got "${value}".`, [
+ `Check the original URL: ${url}`,
+ ]);
+ }
+ return value;
+}
+
+export const gitlab = {
+ id: 'gitlab',
+ name: 'GitLab',
+ family: 'gitlab',
+ docsUrl: DOCS,
+ defaultWebBase: 'https://gitlab.com',
+ defaultApiBase: `https://gitlab.com${DEFAULT_API_SUFFIX}`,
+ apiSuffixDefault: DEFAULT_API_SUFFIX,
+ apiInfo: {
+ versionLabel: 'REST API v4 (version is part of the base path /api/v4, not a request header)',
+ mediaType: 'application/json',
+ notes: ['Requests send Accept: application/json.'],
+ },
+ requestHeaders: { Accept: 'application/json' },
+ capabilities: {
+ selfHosted: true,
+ resources: [
+ { type: 'user', label: 'User', webPattern: '/{username}', apiPattern: '/users?username={username}' },
+ { type: 'project', label: 'Project (incl. nested groups)', webPattern: '/{group}[/{subgroup}...]/{project}', apiPattern: '/projects/{url-encoded-full-path}' },
+ { type: 'issue', label: 'Issue', webPattern: '/{path}/-/issues/{iid}', apiPattern: '/projects/{id}/issues/{iid}' },
+ { type: 'mr', label: 'Merge request', webPattern: '/{path}/-/merge_requests/{iid}', apiPattern: '/projects/{id}/merge_requests/{iid}' },
+ { type: 'commit', label: 'Commit', webPattern: '/{path}/-/commit/{sha}', apiPattern: '/projects/{id}/repository/commits/{sha}' },
+ { type: 'commits', label: 'Commit list', webPattern: '/{path}/-/commits', apiPattern: '/projects/{id}/repository/commits' },
+ { type: 'releases', label: 'Releases', webPattern: '/{path}/-/releases', apiPattern: '/projects/{id}/releases' },
+ { type: 'release-by-tag', label: 'Release by tag', webPattern: '/{path}/-/releases/{tag}', apiPattern: '/projects/{id}/releases/{url-encoded-tag}' },
+ { type: 'branches', label: 'Branch list', webPattern: '/{path}/-/branches', apiPattern: '/projects/{id}/repository/branches' },
+ { type: 'branch', label: 'Branch', webPattern: '/{path}/-/tree/{branch}', apiPattern: '/projects/{id}/repository/branches/{url-encoded-branch}' },
+ { type: 'tags', label: 'Tag list', webPattern: '/{path}/-/tags', apiPattern: '/projects/{id}/repository/tags' },
+ { type: 'file', label: 'File', webPattern: '/{path}/-/blob/{ref}/{file}', apiPattern: '/projects/{id}/repository/files/{url-encoded-file}?ref={ref}' },
+ ],
+ limitations: [
+ 'Users resolve to /users?username= and the response is a JSON array (possibly empty) rather than a single object.',
+ 'Related resources for a single user cannot be built without the numeric user id; inspect the lookup response first.',
+ 'tree/blob URLs mix ref and path; the first segment after blob is treated as the ref (heuristic).',
+ 'Self-hosted instances must be registered under Providers before their URLs resolve.',
+ ],
+ },
+
+ match(url) {
+ return url.hostname === 'gitlab.com';
+ },
+
+ /** @param {URL} url */
+ parse(url) {
+ const original = url.toString();
+ let segs = url.pathname.split('/').filter(Boolean);
+ if (segs.length > 0) segs[segs.length - 1] = segs[segs.length - 1].replace(/\.git$/, '');
+ if (segs.length === 0) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, 'The GitLab homepage does not map to a single API resource.', [
+ 'Provide a user or project URL, e.g. https://gitlab.com/gitlab-org/gitlab',
+ ]);
+ }
+
+ const sepIndex = segs.indexOf('-');
+ const projectSegs = sepIndex === -1 ? segs : segs.slice(0, sepIndex);
+ const sub = sepIndex === -1 ? [] : segs.slice(sepIndex + 1);
+
+ if (sepIndex === -1) {
+ if (segs.length === 1) {
+ const username = segs[0];
+ if (RESERVED.has(username.toLowerCase())) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `"gitlab.com/${username}" is a GitLab site page, not an API resource.`);
+ }
+ return mk('user', { username }, original);
+ }
+ if (RESERVED.has(segs[0].toLowerCase())) {
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `"gitlab.com/${segs[0]}/…" is not a project path.`);
+ }
+ return mk('project', { fullPath: segs.join('/') }, original);
+ }
+
+ if (projectSegs.length === 0) {
+ throw new ResolverError(ResolverErrorCode.MALFORMED_URL, 'GitLab sub-resource URL is missing its project path before "/-/".');
+ }
+ const fullPath = projectSegs.join('/');
+
+ const [kind, ...rest] = sub;
+ switch (kind) {
+ case 'issues':
+ return mk('issue', { fullPath, iid: requireNumber(rest[0], 'issue', original) }, original);
+ case 'merge_requests':
+ return mk('mr', { fullPath, iid: requireNumber(rest[0], 'merge request', original) }, original);
+ case 'commit':
+ return mk('commit', { fullPath, sha: rest[0] }, original);
+ case 'commits':
+ return mk('commits', { fullPath, ref: rest.join('/') || undefined }, original);
+ case 'releases':
+ if (rest.length === 0) return mk('releases', { fullPath }, original);
+ return mk('release-by-tag', { fullPath, tag: rest.join('/') }, original);
+ case 'tags':
+ return mk('tags', { fullPath }, original);
+ case 'branches':
+ return mk('branches', { fullPath }, original);
+ case 'tree':
+ if (rest.length === 1) return mk('branch', { fullPath, branch: rest[0] }, original);
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, 'GitLab tree URLs mix a branch name and a directory path, which cannot be split unambiguously without git data.');
+ case 'blob': {
+ if (rest.length < 2) throw new ResolverError(ResolverErrorCode.MISSING_INFO, 'Blob URLs need a ref and a file path, e.g. /-/blob/main/README.md');
+ return mk('file', { fullPath, ref: rest[0], path: rest.slice(1).join('/') }, original);
+ }
+ default:
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `GitLab path "/-/${kind}" has no API mapping in GitAPITaker yet.`, [
+ `See the official docs for what exists: ${DOCS}`,
+ ]);
+ }
+ },
+
+ /**
+ * @param {import('../core/types.js').ParsedResource} parsed
+ * @param {{apiBase: string, instanceId?: string}} ctx
+ */
+ resolve(parsed, ctx) {
+ const api = ctx.apiBase.replace(/\/+$/, '');
+ const p = parsed.params;
+ const common = {
+ providerId: 'gitlab',
+ method: 'GET',
+ parsed,
+ headers: { Accept: 'application/json' },
+ apiBase: api,
+ instanceId: ctx.instanceId,
+ notes: [],
+ };
+ const project = () => joinUrl(api, ['projects', encodeFully(p.fullPath)]);
+
+ switch (parsed.resourceType) {
+ case 'user':
+ return {
+ ...common, resourceType: 'user',
+ url: `${joinUrl(api, ['users'])}?username=${encodeURIComponent(p.username)}`,
+ docUrl: `${DOCS}users/`,
+ label: `GitLab user lookup: ${p.username}`,
+ notes: ['GitLab resolves users by query parameter; the response is a JSON array and may be empty if the username does not exist.'],
+ };
+ case 'project':
+ return { ...common, resourceType: 'project', url: project(), docUrl: `${DOCS}projects/`, label: `GitLab project ${p.fullPath}` };
+ case 'issue':
+ return { ...common, resourceType: 'issue', url: joinUrl(project(), ['issues', p.iid]), docUrl: `${DOCS}issues/`, label: `Issue #${p.iid} of ${p.fullPath}` };
+ case 'mr':
+ return { ...common, resourceType: 'mr', url: joinUrl(project(), ['merge_requests', p.iid]), docUrl: `${DOCS}merge_requests/`, label: `MR !${p.iid} of ${p.fullPath}` };
+ case 'commit':
+ return { ...common, resourceType: 'commit', url: joinUrl(project(), ['repository', 'commits', p.sha]), docUrl: `${DOCS}commits/`, label: `Commit ${p.sha.slice(0, 10)}` };
+ case 'commits': {
+ let url = joinUrl(project(), ['repository', 'commits']);
+ if (p.ref) url += `?ref_name=${encodeURIComponent(p.ref)}`;
+ return { ...common, resourceType: 'commits', url, docUrl: `${DOCS}commits/`, label: `Commits of ${p.fullPath}` };
+ }
+ case 'releases':
+ return { ...common, resourceType: 'releases', url: joinUrl(project(), ['releases']), docUrl: `${DOCS}releases/`, label: `Releases of ${p.fullPath}` };
+ case 'release-by-tag':
+ return { ...common, resourceType: 'release-by-tag', url: joinUrl(project(), ['releases', encodeFully(p.tag)]), docUrl: `${DOCS}releases/`, label: `Release ${p.tag}` };
+ case 'branches':
+ return { ...common, resourceType: 'branches', url: joinUrl(project(), ['repository', 'branches']), docUrl: `${DOCS}branches/`, label: `Branches of ${p.fullPath}` };
+ case 'branch':
+ return { ...common, resourceType: 'branch', url: joinUrl(project(), ['repository', 'branches', encodeFully(p.branch)]), docUrl: `${DOCS}branches/`, label: `Branch ${p.branch}` };
+ case 'tags':
+ return { ...common, resourceType: 'tags', url: joinUrl(project(), ['repository', 'tags']), docUrl: `${DOCS}tags/`, label: `Tags of ${p.fullPath}` };
+ case 'file': {
+ const url = new URL(joinUrl(project(), ['repository', 'files', encodeFully(p.path)]));
+ url.searchParams.set('ref', p.ref);
+ return {
+ ...common, resourceType: 'file', url: url.toString(), docUrl: `${DOCS}repository_files/`, label: `File ${p.path}`,
+ notes: ['blob URLs mix ref and path; the first path segment after /blob was taken as the ref.'],
+ };
+ }
+ default:
+ throw new ResolverError(ResolverErrorCode.UNSUPPORTED_RESOURCE, `GitLab resource type "${parsed.resourceType}" cannot be resolved.`);
+ }
+ },
+
+ /** @param {import('../core/types.js').ParsedResource} parsed @param {{apiBase: string}} ctx */
+ related(parsed, ctx) {
+ const api = ctx.apiBase.replace(/\/+$/, '');
+ const p = parsed.params;
+ const item = (label, url, docUrl, resourceType) => ({ label, url, docUrl, resourceType });
+ const project = () => joinUrl(api, ['projects', encodeFully(p.fullPath)]);
+
+ if (parsed.resourceType === 'user') {
+ // Without the numeric user id we cannot build /users/:id/projects etc.
+ // This is an honest capability limitation, surfaced in the UI.
+ return [];
+ }
+ if (['project', 'issue', 'mr', 'commit', 'commits', 'releases', 'release-by-tag', 'branches', 'branch', 'tags', 'file'].includes(parsed.resourceType)) {
+ return [
+ item('Issues', joinUrl(project(), ['issues']), `${DOCS}issues/`, 'issues'),
+ item('Merge requests', joinUrl(project(), ['merge_requests']), `${DOCS}merge_requests/`, 'merge_requests'),
+ item('Commits', joinUrl(project(), ['repository', 'commits']), `${DOCS}commits/`, 'commits'),
+ item('Releases', joinUrl(project(), ['releases']), `${DOCS}releases/`, 'releases'),
+ item('Branches', joinUrl(project(), ['repository', 'branches']), `${DOCS}branches/`, 'branches'),
+ item('Tags', joinUrl(project(), ['repository', 'tags']), `${DOCS}tags/`, 'tags'),
+ item('Contributors', joinUrl(project(), ['repository', 'contributors']), `${DOCS}repositories/`, 'contributors'),
+ item('Members (incl. inherited)', joinUrl(project(), ['members', 'all']), `${DOCS}members/`, 'members'),
+ ];
+ }
+ return [];
+ },
+
+ describe(parsed) {
+ const p = parsed.params;
+ switch (parsed.resourceType) {
+ case 'user': return `GitLab user lookup "${p.username}"`;
+ case 'project': return `GitLab project ${p.fullPath}`;
+ case 'issue': return `issue ${p.fullPath}#${p.iid}`;
+ case 'mr': return `merge request ${p.fullPath}!${p.iid}`;
+ case 'commit': return `commit ${p.sha.slice(0, 10)} of ${p.fullPath}`;
+ default: return `${parsed.resourceType} of ${p.fullPath ?? ''}`;
+ }
+ },
+};
+
+function mk(resourceType, params, originalUrl) {
+ return { providerId: 'gitlab', resourceType, params, originalUrl };
+}
diff --git a/src/providers/instances.js b/src/providers/instances.js
new file mode 100644
index 0000000..2e06872
--- /dev/null
+++ b/src/providers/instances.js
@@ -0,0 +1,125 @@
+/**
+ * Custom (self-hosted) instances.
+ *
+ * Users can register self-hosted Gitea/Forgejo or GitLab instances so their
+ * website URLs resolve. Configuration lives entirely in localStorage and is
+ * never transmitted anywhere.
+ */
+
+import { readJson, writeJson, getStorage } from '../core/storage.js';
+import { parseBaseUrl } from '../core/url.js';
+import { ResolverError, ResolverErrorCode } from '../core/errors.js';
+import { getProvider, listProviders } from './registry.js';
+
+const KEY = 'gitapitaker.instances.v1';
+
+/** @returns {import('../core/types.js').InstanceConfig[]} */
+export function listInstances() {
+ const value = readJson(KEY);
+ return Array.isArray(value) ? value : [];
+}
+
+/** Hosts already owned by built-in adapters cannot be re-registered. */
+function builtInHosts() {
+ return listProviders().map((p) => new URL(p.defaultWebBase).hostname);
+}
+
+/**
+ * Add or replace an instance for a given host.
+ * @param {{kind: string, label?: string, webBase: string, apiBase?: string}} input
+ * @returns {import('../core/types.js').InstanceConfig}
+ */
+export function addInstance(input) {
+ const adapter = getProvider(input.kind);
+ if (!adapter || !adapter.capabilities?.selfHosted) {
+ throw new ResolverError(ResolverErrorCode.INVALID_INSTANCE, `Provider "${input.kind}" does not support self-hosted instances in GitAPITaker.`);
+ }
+ const web = parseBaseUrl(input.webBase);
+ if (!web) {
+ throw new ResolverError(ResolverErrorCode.INVALID_INSTANCE, 'Instance base URL is not a valid http(s) URL.', [
+ 'Example: https://git.example.org',
+ ]);
+ }
+ if (builtInHosts().includes(web.hostname)) {
+ throw new ResolverError(ResolverErrorCode.INVALID_INSTANCE, `"${web.hostname}" is a built-in provider host and cannot be overridden.`);
+ }
+
+ let api;
+ if (input.apiBase && input.apiBase.trim()) {
+ const parsedApi = parseBaseUrl(input.apiBase);
+ if (!parsedApi) {
+ throw new ResolverError(ResolverErrorCode.INVALID_INSTANCE, 'Custom API base is not a valid http(s) URL.', [
+ `Example: ${web.origin}${adapter.apiSuffixDefault}`,
+ ]);
+ }
+ api = parsedApi.toString().replace(/\/+$/, '');
+ } else {
+ api = `${web.origin}${adapter.apiSuffixDefault}`;
+ }
+
+ const instances = listInstances().filter((i) => {
+ try { return new URL(i.webBase).hostname !== web.hostname; } catch { return true; }
+ });
+ const entry = {
+ id: `inst-${web.hostname}-${adapter.id}`,
+ kind: adapter.id,
+ label: input.label?.trim() || web.hostname,
+ webBase: web.origin,
+ apiBase: api,
+ addedAt: Date.now(),
+ };
+ instances.push(entry);
+ if (!writeJson(KEY, instances)) {
+ throw new ResolverError(ResolverErrorCode.INVALID_INSTANCE, 'Could not persist the instance (browser storage unavailable).');
+ }
+ return entry;
+}
+
+/** @param {string} id */
+export function removeInstance(id) {
+ writeJson(KEY, listInstances().filter((i) => i.id !== id));
+}
+
+/**
+ * Verify an instance is reachable and answers like the expected API.
+ * Honest probing: we report whatever status comes back; nothing is faked.
+ * - Gitea/Forgejo: GET {apiBase}/version (public)
+ * - GitLab: GET {apiBase}/version (usually 401 unauthenticated, which still
+ * proves the API exists at that base)
+ *
+ * @param {import('../core/types.js').InstanceConfig} instance
+ * @param {typeof fetch} [fetchImpl]
+ * @returns {Promise<{ok: boolean, status: number|null, detail: string}>}
+ */
+export async function probeInstance(instance, fetchImpl = globalThis.fetch) {
+ const url = `${instance.apiBase.replace(/\/+$/, '')}/version`;
+ try {
+ const res = await fetchImpl(url, { headers: { Accept: 'application/json' } });
+ if (instance.kind === 'gitlab') {
+ const ok = res.status === 401 || res.status === 200;
+ return {
+ ok,
+ status: res.status,
+ detail: ok
+ ? 'API reachable at this base (GitLab /version requires auth; 401 confirms the API exists).'
+ : `Unexpected status ${res.status} from ${url}.`,
+ };
+ }
+ if (res.ok) {
+ let detail = '';
+ try {
+ const body = await res.json();
+ detail = body?.version ? `Gitea/Forgejo version ${body.version}` : 'version endpoint answered';
+ } catch { detail = 'version endpoint answered (non-JSON body)'; }
+ return { ok: true, status: res.status, detail };
+ }
+ return { ok: false, status: res.status, detail: `${url} answered ${res.status} ${res.statusText}` };
+ } catch {
+ return { ok: false, status: null, detail: 'No response — host unreachable, offline, or CORS blocked the probe.' };
+ }
+}
+
+/** Test hook. */
+export function clearInstancesForTests() {
+ getStorage().remove(KEY);
+}
diff --git a/src/providers/registry.js b/src/providers/registry.js
new file mode 100644
index 0000000..a8dc5d0
--- /dev/null
+++ b/src/providers/registry.js
@@ -0,0 +1,88 @@
+/**
+ * Provider registry.
+ *
+ * Detection is separated from parsing and endpoint resolution:
+ * detectProvider(url, instances) -> which adapter owns this host
+ * adapter.parse(url, ctx) -> ParsedResource
+ * adapter.resolve(parsed, ctx) -> ResolvedEndpoint
+ *
+ * Adding a provider = add one adapter module, register it here, done.
+ * The rest of the application (resolver, request layer, cache, guard,
+ * inspector, explorer) is provider-agnostic.
+ */
+
+import { github } from './github.js';
+import { gitlab } from './gitlab.js';
+import { gitea } from './gitea.js';
+import { ResolverError, ResolverErrorCode } from '../core/errors.js';
+
+const builtIns = new Map();
+
+/** Register a provider adapter. */
+export function registerProvider(adapter) {
+ if (!adapter?.id || typeof adapter.parse !== 'function' || typeof adapter.resolve !== 'function') {
+ throw new Error('Invalid provider adapter: id, parse() and resolve() are required.');
+ }
+ builtIns.set(adapter.id, adapter);
+}
+
+registerProvider(github);
+registerProvider(gitlab);
+registerProvider(gitea);
+
+/** @returns {import('../core/types.js').ProviderAdapter[]} */
+export function listProviders() {
+ return [...builtIns.values()];
+}
+
+/** @param {string} id */
+export function getProvider(id) {
+ return builtIns.get(id) ?? null;
+}
+
+/**
+ * Detect which provider adapter owns a normalized URL.
+ * Built-in hosts win; then user-registered instances, matched by hostname.
+ *
+ * @param {URL} url
+ * @param {import('../core/types.js').InstanceConfig[]} [instances]
+ * @returns {{provider: object, ctx: {webBase: string, apiBase: string, instanceId?: string, instanceLabel?: string}} | null}
+ */
+export function detectProvider(url, instances = []) {
+ for (const adapter of builtIns.values()) {
+ if (typeof adapter.match === 'function' && adapter.match(url)) {
+ return {
+ provider: adapter,
+ ctx: { webBase: adapter.defaultWebBase, apiBase: adapter.defaultApiBase },
+ };
+ }
+ }
+ for (const inst of instances) {
+ try {
+ if (new URL(inst.webBase).hostname === url.hostname) {
+ const adapter = builtIns.get(inst.kind);
+ if (!adapter) continue;
+ return {
+ provider: adapter,
+ ctx: { webBase: inst.webBase, apiBase: inst.apiBase, instanceId: inst.id, instanceLabel: inst.label },
+ };
+ }
+ } catch { /* ignore malformed stored instance */ }
+ }
+ return null;
+}
+
+/**
+ * Detection-only failure used by the resolver.
+ * @param {URL} url
+ */
+export function unsupportedProviderError(url) {
+ const supported = [...builtIns.values()].map((p) => p.defaultWebBase.replace('https://', '')).join(', ');
+ return new ResolverError(ResolverErrorCode.UNSUPPORTED_PROVIDER, `No provider adapter recognizes the host "${url.hostname}".`, [
+ `Built-in providers: ${supported}.`,
+ 'Self-hosted Gitea, Forgejo or GitLab? Register the instance under Providers → Custom instances.',
+ 'Adding a new provider adapter is documented in the README (provider adapter architecture).',
+ ], [
+ { label: 'Register a self-hosted instance', goto: 'providers' },
+ ]);
+}
diff --git a/src/ui/announce.js b/src/ui/announce.js
new file mode 100644
index 0000000..2261707
--- /dev/null
+++ b/src/ui/announce.js
@@ -0,0 +1,16 @@
+/**
+ * Screen-reader announcements for request state changes (start, complete,
+ * error, cached, suppressed). Polite by default; errors use the assertive
+ * region. Visual UI mirrors every announcement — nothing is announced
+ * invisibly that is not also shown.
+ */
+
+/** @param {string} message @param {{assertive?: boolean}} [opts] */
+export function announce(message, opts = {}) {
+ const id = opts.assertive ? 'sr-assertive' : 'sr-polite';
+ const node = document.getElementById(id);
+ if (!node) return;
+ // Re-announce identical text by toggling content in the next frame.
+ node.textContent = '';
+ setTimeout(() => { node.textContent = message; }, 30);
+}
diff --git a/src/ui/cache-view.js b/src/ui/cache-view.js
new file mode 100644
index 0000000..31e40ba
--- /dev/null
+++ b/src/ui/cache-view.js
@@ -0,0 +1,86 @@
+/**
+ * Cache Inspector page: transparent view into the localStorage cache.
+ * Shows every stored entry with metadata and honest freshness state, and
+ * offers inspect / refresh / delete / clear-all controls.
+ */
+
+import { el, clear } from './dom.js';
+import { listEntries, deleteEntry, clearAll, approximateUsageBytes } from '../core/cache.js';
+import { formatBytes, formatAge, formatTimestamp } from '../core/format.js';
+import { truncateMiddle } from '../core/format.js';
+import { announce } from './announce.js';
+
+/**
+ * @param {{
+ * onInspectEntry: (entry: import('../core/types.js').CacheEntry, state: string) => void,
+ * onRefreshEntry: (entry: import('../core/types.js').CacheEntry) => void,
+ * }} hooks
+ */
+export function renderCacheView(hooks) {
+ const container = document.getElementById('page-cache');
+ const mount = container.querySelector('#cache-list');
+ const summary = container.querySelector('#cache-summary');
+ clear(mount);
+
+ const entries = listEntries();
+ const usage = approximateUsageBytes();
+ summary.textContent = entries.length
+ ? `${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} · approx. ${formatBytes(usage)} of localStorage · nothing here ever leaves this browser.`
+ : `Cache is empty. Responses are stored here only after a live request. (¬_¬)`;
+
+ if (entries.length === 0) return;
+
+ const table = el('table', { className: 'm3-table cache-table' },
+ el('thead', {}, el('tr', {},
+ el('th', { scope: 'col' }, 'Endpoint'),
+ el('th', { scope: 'col' }, 'Provider'),
+ el('th', { scope: 'col' }, 'Status'),
+ el('th', { scope: 'col' }, 'Fetched'),
+ el('th', { scope: 'col' }, 'Freshness'),
+ el('th', { scope: 'col' }, 'Size'),
+ el('th', { scope: 'col' }, 'Actions'),
+ )),
+ );
+ const tbody = el('tbody');
+ table.append(tbody);
+
+ for (const { entry, state } of entries) {
+ const actions = el('td', { className: 'cache-actions' },
+ el('button', { type: 'button', className: 'm3-btn tonal btn-sm', 'aria-label': `Inspect cached response for ${entry.endpoint}` }, 'Inspect'),
+ el('button', { type: 'button', className: 'm3-btn tonal btn-sm', 'aria-label': `Request ${entry.endpoint} live and update the cache` }, 'Refresh'),
+ el('button', { type: 'button', className: 'm3-btn text btn-sm btn-danger', 'aria-label': `Delete cached entry for ${entry.endpoint}` }, 'Delete'),
+ );
+ const [inspectBtn, refreshBtn, deleteBtn] = actions.querySelectorAll('button');
+ inspectBtn.addEventListener('click', () => hooks.onInspectEntry(entry, state));
+ refreshBtn.addEventListener('click', () => hooks.onRefreshEntry(entry));
+ deleteBtn.addEventListener('click', () => {
+ deleteEntry(entry.key);
+ renderCacheView(hooks);
+ announce('Cache entry deleted.');
+ });
+
+ tbody.append(el('tr', {},
+ el('td', { className: 'mono', title: entry.endpoint }, truncateMiddle(entry.endpoint, 64)),
+ el('td', {}, entry.providerId),
+ el('td', { className: 'mono' }, String(entry.status)),
+ el('td', { className: 'mono', title: formatTimestamp(entry.fetchedAt) }, formatAge(entry.fetchedAt)),
+ el('td', {}, el('span', { className: `m3-chip chip-${state === 'fresh' ? 'cached' : 'stale'}` },
+ el('span', { className: 'state-dot', 'aria-hidden': 'true' }), state === 'fresh' ? 'FRESH' : 'STALE')),
+ el('td', { className: 'mono' }, formatBytes(entry.sizeBytes)),
+ actions,
+ ));
+ }
+ mount.append(table);
+ mount.append(el('p', { className: 'view-note' },
+ 'Fresh entries are younger than their TTL and will be served by the Request Guard when you repeat a request. ',
+ 'Stale entries are still inspectable (offline mode) but are always labeled STALE — never presented as fresh.'));
+}
+
+/** Wire the "clear cache" button once at boot. */
+export function initCacheView() {
+ document.getElementById('cache-clear').addEventListener('click', () => {
+ clearAll();
+ announce('Local cache cleared.');
+ document.dispatchEvent(new CustomEvent('gitapitaker:cache-changed'));
+ });
+}
diff --git a/src/ui/community.js b/src/ui/community.js
new file mode 100644
index 0000000..d3b0309
--- /dev/null
+++ b/src/ui/community.js
@@ -0,0 +1,55 @@
+/**
+ * Community page: GitHub Discussions via Giscus.
+ * The widget is lazy-loaded only when this page is visited, and only when
+ * the configuration in src/community/config.js is complete.
+ */
+
+import { el, clear } from './dom.js';
+import { GISCUS_CONFIG, isGiscusConfigured, giscusTerm } from '../community/config.js';
+
+let loaded = false;
+
+export function renderCommunityView() {
+ const mount = document.getElementById('giscus-mount');
+ clear(mount);
+
+ if (!isGiscusConfigured()) {
+ mount.append(
+ el('p', { className: 'empty-note' },
+ 'The Giscus widget is not configured yet. GitAPITaker uses GitHub Discussions as its community backend; ',
+ 'the repository owner needs to enable Discussions and fill in the ids in ',
+ el('code', { className: 'mono' }, 'src/community/config.js'),
+ ' (instructions are in that file). (・_・;)'),
+ el('p', {},
+ el('a', { href: `https://github.com/${GISCUS_CONFIG.repo}/discussions`, target: '_blank', rel: 'noopener noreferrer' },
+ 'Open the repository discussions on GitHub'),
+ ),
+ );
+ return;
+ }
+
+ if (loaded) {
+ // Giscus re-mounts via postMessage when config changes; simplest honest
+ // behavior is to recreate the script node on revisit.
+ loaded = false;
+ }
+ const script = el('script', {
+ src: 'https://giscus.app/client.js',
+ 'data-repo': GISCUS_CONFIG.repo,
+ 'data-repo-id': GISCUS_CONFIG.repoId,
+ 'data-category': GISCUS_CONFIG.category,
+ 'data-category-id': GISCUS_CONFIG.categoryId,
+ 'data-mapping': GISCUS_CONFIG.mapping,
+ 'data-strict': GISCUS_CONFIG.strict,
+ 'data-reactions-enabled': GISCUS_CONFIG.reactionsEnabled,
+ 'data-emit-metadata': GISCUS_CONFIG.emitMetadata,
+ 'data-input-position': GISCUS_CONFIG.inputPosition,
+ 'data-theme': GISCUS_CONFIG.theme,
+ 'data-lang': GISCUS_CONFIG.lang,
+ 'data-term': giscusTerm('general'),
+ crossorigin: 'anonymous',
+ async: '',
+ });
+ mount.append(script);
+ loaded = true;
+}
diff --git a/src/ui/dom.js b/src/ui/dom.js
new file mode 100644
index 0000000..d584673
--- /dev/null
+++ b/src/ui/dom.js
@@ -0,0 +1,56 @@
+/** Minimal DOM helpers for the presentation layer. */
+
+/**
+ * Create an element.
+ * @param {string} tag
+ * @param {Record} [attrs] className, dataset, aria-*, event handlers via on*
+ * @param {...(Node|string)} children
+ */
+export function el(tag, attrs = {}, ...children) {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs)) {
+ if (v == null || v === false) continue;
+ if (k === 'className') node.className = v;
+ else if (k === 'dataset') Object.assign(node.dataset, v);
+ else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
+ else if (k === 'html') node.innerHTML = v; // only used with trusted, app-generated escaped HTML
+ else if (v === true) node.setAttribute(k, '');
+ else node.setAttribute(k, String(v));
+ }
+ for (const child of children.flat()) {
+ if (child == null) continue;
+ node.append(child.nodeType ? child : document.createTextNode(String(child)));
+ }
+ return node;
+}
+
+/** Remove all children. @param {Node} node */
+export function clear(node) {
+ while (node.firstChild) node.removeChild(node.firstChild);
+ return node;
+}
+
+/** Copy text to clipboard with a fallback; resolves true on success. */
+export async function copyText(text) {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch { /* fall through */ }
+ try {
+ const ta = el('textarea', { className: 'sr-only', value: text });
+ document.body.append(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ ta.remove();
+ return ok;
+ } catch {
+ return false;
+ }
+}
+
+/** Format a key/value pair list for copying. */
+export function pairsToText(pairs) {
+ return pairs.map(([k, v]) => `${k}: ${v}`).join('\n');
+}
diff --git a/src/ui/explorer.js b/src/ui/explorer.js
new file mode 100644
index 0000000..6db7c8b
--- /dev/null
+++ b/src/ui/explorer.js
@@ -0,0 +1,45 @@
+/**
+ * Endpoint Explorer — keyboard-navigable list of related endpoints derived
+ * from provider capability metadata (adapter.related()). Selecting an item
+ * inspects that API endpoint directly.
+ */
+
+import { el } from './dom.js';
+import { rovingList } from './keyboard.js';
+import { truncateMiddle } from '../core/format.js';
+
+/**
+ * @param {Array<{label: string, url: string, docUrl?: string, resourceType?: string}>} items
+ * @param {{onSelect: (item: object) => void}} hooks
+ */
+export function renderExplorer(items, hooks) {
+ const list = el('div', { className: 'explorer-list', role: 'list', 'aria-label': 'Related endpoints' });
+
+ for (const item of items) {
+ const inspectBtn = el('button', {
+ type: 'button',
+ className: 'explorer-inspect',
+ tabindex: '-1',
+ 'aria-label': `${item.label}: inspect ${item.url}`,
+ },
+ el('span', { className: 'explorer-label' }, item.label),
+ el('code', { className: 'explorer-url mono' }, truncateMiddle(item.url, 96)),
+ el('span', { className: 'explorer-go', 'aria-hidden': 'true' }, 'inspect'),
+ );
+ inspectBtn.addEventListener('click', () => hooks.onSelect(item));
+
+ const row = el('div', { className: 'explorer-item', role: 'listitem' }, inspectBtn);
+ if (item.docUrl) {
+ row.append(el('a', {
+ href: item.docUrl, target: '_blank', rel: 'noopener noreferrer', className: 'explorer-doc',
+ 'aria-label': `Documentation for ${item.label}`,
+ }, 'docs'));
+ }
+ list.append(row);
+ }
+
+ rovingList(list, '.explorer-inspect', {
+ onActivate: (node) => node.click(),
+ });
+ return list;
+}
diff --git a/src/ui/help.js b/src/ui/help.js
new file mode 100644
index 0000000..3ac9837
--- /dev/null
+++ b/src/ui/help.js
@@ -0,0 +1,23 @@
+/** Keyboard shortcuts help dialog (opened via "?" or the command palette). */
+
+import { announce } from './announce.js';
+
+export function createHelp() {
+ const dialog = document.getElementById('help-dialog');
+
+ function open() {
+ dialog.showModal();
+ dialog.querySelector('.dialog-close')?.focus();
+ announce('Keyboard shortcuts dialog opened.');
+ }
+ function close() {
+ if (dialog.open) dialog.close();
+ }
+ dialog.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape') { event.preventDefault(); close(); }
+ });
+ const closeBtn = dialog.querySelector('.dialog-close');
+ closeBtn?.addEventListener('click', close);
+
+ return { open, close };
+}
diff --git a/src/ui/history-view.js b/src/ui/history-view.js
new file mode 100644
index 0000000..7e13d1e
--- /dev/null
+++ b/src/ui/history-view.js
@@ -0,0 +1,73 @@
+/**
+ * History page: local inspection history with keyboard navigation.
+ * Entries are small metadata records; reopening re-runs the inspection.
+ */
+
+import { el, clear } from './dom.js';
+import { listHistory, removeHistory, clearHistory } from '../core/history.js';
+import { rovingList } from './keyboard.js';
+import { formatAge, truncateMiddle } from '../core/format.js';
+import { announce } from './announce.js';
+
+let reopenHook = () => {};
+
+/** Wire the page once at boot. @param {{onReopen: (entry: import('../core/types.js').HistoryEntry) => void}} hooks */
+export function initHistory(hooks) {
+ reopenHook = hooks.onReopen;
+ document.getElementById('history-clear').addEventListener('click', () => {
+ clearHistory();
+ renderHistoryView();
+ announce('History cleared.');
+ });
+}
+
+/** Re-render the history list. */
+export function renderHistoryView() {
+ const container = document.getElementById('page-history');
+ const listMount = container.querySelector('#history-list');
+ const summary = container.querySelector('#history-summary');
+ clear(listMount);
+
+ const entries = listHistory();
+ summary.textContent = entries.length
+ ? `${entries.length} local entr${entries.length === 1 ? 'y' : 'ies'} — stored in this browser only, never transmitted.`
+ : '';
+
+ if (entries.length === 0) {
+ listMount.append(el('p', { className: 'empty-note' }, 'No inspections yet. History is local to this browser. (・_・;)'));
+ return;
+ }
+
+ for (const entry of entries) {
+ const row = el('div', { className: 'history-row', dataset: { id: entry.id } },
+ el('button', {
+ type: 'button', className: 'history-main', tabindex: '-1',
+ 'aria-label': `Reopen inspection of ${entry.endpoint}, ${entry.providerId}, ${formatAge(entry.at)}`,
+ },
+ el('span', { className: 'm3-chip chip-provider' }, entry.providerId),
+ entry.resourceType ? el('span', { className: 'm3-chip chip-muted mono' }, entry.resourceType) : null,
+ el('code', { className: 'history-endpoint mono' }, truncateMiddle(entry.endpoint, 90)),
+ el('span', { className: 'history-meta mono' },
+ `${formatAge(entry.at)}${entry.status ? ` · HTTP ${entry.status}` : ''}${entry.stateLabel ? ` · ${entry.stateLabel}` : ''}`),
+ ),
+ el('button', {
+ type: 'button', className: 'm3-btn text btn-sm btn-danger history-remove', 'aria-label': `Remove history entry for ${entry.endpoint}`,
+ }, 'Remove'),
+ );
+ row.querySelector('.history-main').addEventListener('click', () => reopenHook(entry));
+ row.querySelector('.history-remove').addEventListener('click', () => {
+ removeHistory(entry.id);
+ renderHistoryView();
+ announce('History entry removed.');
+ });
+ listMount.append(row);
+ }
+
+ rovingList(listMount, '.history-main', {
+ onActivate: (node) => {
+ const id = node.closest('.history-row')?.dataset.id;
+ const entry = listHistory().find((e) => e.id === id);
+ if (entry) reopenHook(entry);
+ },
+ });
+}
diff --git a/src/ui/inspector.js b/src/ui/inspector.js
new file mode 100644
index 0000000..a16137c
--- /dev/null
+++ b/src/ui/inspector.js
@@ -0,0 +1,489 @@
+/**
+ * Inspector presentation layer — the "instrument panel".
+ *
+ * Signature elements:
+ * - Resolution pipeline: DETECT → PARSE → RESOLVE → FETCH, rendered with
+ * the actual values from each stage (or the stage that failed).
+ * - Metadata rail: state badge, status, timing, size, guard status and
+ * actions — always labeled LIVE / CACHED / STALE, never color alone.
+ *
+ * All inspection logic lives in core modules; this file only renders state.
+ */
+
+import { el, clear, copyText } from './dom.js';
+import { createTabs } from './tabs.js';
+import { announce } from './announce.js';
+import { renderJsonTree } from '../viewer/json.js';
+import { renderRawView } from '../viewer/raw.js';
+import { renderHeadersView } from '../viewer/headers.js';
+import { renderRequestView } from '../viewer/request.js';
+import { tryParseJson } from '../core/request.js';
+import { formatBytes, formatDuration, formatAge, formatTimestamp, truncateMiddle } from '../core/format.js';
+import { interpretHttpStatus } from '../core/errors.js';
+import { renderExplorer } from './explorer.js';
+import { buildShareUrl } from '../core/share.js';
+import { buildCurlCommand } from '../core/curl.js';
+import { describePagination } from '../core/pagination.js';
+import { showSnackbar } from './snackbar.js';
+
+const EXAMPLES = [
+ 'https://github.com/flessan',
+ 'https://github.com/flessan/AdbPureFlow',
+ 'https://github.com/flessan/AdbPureFlow/issues/12',
+ 'https://gitlab.com/gitlab-org/gitlab',
+ 'https://gitea.com/gitea/gitea',
+];
+
+let tabsInstance = null;
+
+/** Wire the input form. @param {{onInspect: (value: string) => void}} hooks */
+export function initInspector(hooks) {
+ const form = document.getElementById('inspect-form');
+ const input = document.getElementById('url-input');
+ const examples = document.getElementById('examples');
+
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ hooks.onInspect(input.value);
+ });
+
+ for (const example of EXAMPLES) {
+ const btn = el('button', { type: 'button', className: 'example-chip mono', title: `Inspect ${example}` }, example.replace('https://', ''));
+ btn.addEventListener('click', () => {
+ input.value = example;
+ hooks.onInspect(example);
+ });
+ examples.append(btn);
+ }
+}
+
+export function focusInput() {
+ const input = document.getElementById('url-input');
+ input.focus();
+ input.select();
+}
+
+export function getInputValue() {
+ return document.getElementById('url-input').value;
+}
+
+export function setInputValue(value) {
+ document.getElementById('url-input').value = value;
+}
+
+/* ------------------------------------------------------------------ */
+/* Pipeline */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Render the stage-by-stage pipeline.
+ * @param {Array<{label: string, value: string, state?: 'ok'|'active'|'fail'|'pending'|'skip'}>} stages
+ */
+export function renderPipeline(stages) {
+ const bar = document.getElementById('pipeline');
+ clear(bar);
+ if (!stages || stages.length === 0) {
+ bar.hidden = true;
+ return;
+ }
+ bar.hidden = false;
+ stages.forEach((stage, i) => {
+ if (i > 0) bar.append(el('span', { className: 'pipeline-sep', 'aria-hidden': 'true' }, '─'));
+ bar.append(el('div', { className: `pipeline-stage ps-${stage.state ?? 'ok'}` },
+ el('span', { className: 'stage-label' }, stage.label),
+ el('span', { className: 'stage-value mono', title: stage.value }, stage.value),
+ ));
+ });
+}
+
+/* ------------------------------------------------------------------ */
+/* States */
+/* ------------------------------------------------------------------ */
+
+/** Hide result sections, show a waiting state. @param {object} endpoint @param {Array} stages */
+export function showPending(endpoint, stages = []) {
+ toggle('empty-state', false);
+ toggle('resolver-error', false);
+ toggle('result-area', true);
+ toggle('pending-note', true);
+ hideInterpretation();
+ hideChangeNote();
+ hidePagination();
+ toggle('explorer-area', false);
+ clear(document.getElementById('response-tabs'));
+
+ renderPipeline([...stages, { label: 'fetch', value: 'direct request in flight…', state: 'active' }]);
+
+ const rail = document.getElementById('status-bar');
+ clear(rail);
+ rail.append(
+ el('div', { className: 'rail-head' }, stateChip('pending')),
+ el('p', { className: 'rail-endpoint mono' }, `${endpoint.method || 'GET'} ${endpoint.url}`),
+ el('p', { className: 'view-note' }, 'Contacting the provider directly from this browser…'),
+ );
+ announce(`Requesting ${endpoint.url} directly from the provider.`);
+}
+
+/**
+ * Render a finished inspection.
+ * @param {{
+ * endpoint: import('../core/types.js').ResolvedEndpoint,
+ * providerName: string,
+ * data: {status: number, statusText: string, headers: Array<[string,string]>, bodyText: string, sizeBytes: number, contentType?: string, durationMs?: number, fetchedAt: number},
+ * state: 'live'|'cached'|'stale',
+ * stages?: Array<{label: string, value: string}>,
+ * meta?: {guardNote?: string|null, interpretation?: object|null, webUrl?: string, instanceLabel?: string, source?: string, changeNote?: object|null, pagination?: object|null, onPaginate?: (url: string) => void}
+ * }} args
+ */
+export function showResult({ endpoint, providerName, data, state, stages = [], meta = {} }) {
+ toggle('empty-state', false);
+ toggle('resolver-error', false);
+ toggle('result-area', true);
+ toggle('pending-note', false);
+
+ renderPipeline([...stages, fetchStage(state, data, meta)]);
+ renderMetaRail({ endpoint, providerName, data, state, meta });
+ renderInterpretation({ data, meta, endpoint });
+ renderChangeNote(meta.changeNote ?? null);
+ renderPagination(meta.pagination ?? null, meta.onPaginate);
+ renderTabsArea({ endpoint, data, meta });
+
+ const stateWord = state === 'live'
+ ? 'live response'
+ : state === 'cached'
+ ? 'cached response (provider not contacted this time)'
+ : 'stale cached response';
+ announce(`Done. HTTP ${data.status} ${data.statusText || ''} — ${stateWord}.`, { assertive: data.status >= 400 });
+}
+
+function fetchStage(state, data, meta) {
+ const size = formatBytes(data.sizeBytes);
+ if (state === 'live') {
+ return { label: 'fetch', value: `LIVE ${data.status} · ${formatDuration(data.durationMs)} · ${size}`, state: 'ok' };
+ }
+ const age = `stored ${formatAge(data.fetchedAt)}`;
+ if (meta.reason === 'offline') return { label: 'fetch', value: `STALE · provider unreachable · ${age}`, state: 'fail' };
+ return { label: 'fetch', value: `${state.toUpperCase()} · ${age} · ${size}`, state: state === 'cached' ? 'ok' : 'pending' };
+}
+
+/* ------------------------------------------------------------------ */
+/* Metadata rail */
+/* ------------------------------------------------------------------ */
+
+function renderMetaRail({ endpoint, providerName, data, state, meta }) {
+ const rail = document.getElementById('status-bar');
+ clear(rail);
+
+ const statusOk = data.status < 400 && data.status > 0;
+ rail.append(el('div', { className: 'rail-head' },
+ stateChip(state),
+ el('span', { className: `rail-status mono ${statusOk ? 'ok' : 'err'}` }, `${data.status}${data.statusText ? ` ${data.statusText}` : ''}`),
+ ));
+
+ const kv = el('dl', { className: 'rail-kv' });
+ const row = (label, value, title) => {
+ kv.append(el('dt', {}, label), el('dd', { className: 'mono', title: title ?? undefined }, value));
+ };
+ row('endpoint', truncateMiddle(endpoint.url, 46), endpoint.url);
+ row('provider', providerName + (meta.instanceLabel ? ` · ${meta.instanceLabel}` : ''));
+ if (state === 'live' && typeof data.durationMs === 'number') row('duration', formatDuration(data.durationMs));
+ if (state !== 'live') row('age', formatAge(data.fetchedAt));
+ row('size', formatBytes(data.sizeBytes));
+ row('fetched', formatTimestamp(data.fetchedAt));
+ if (meta.source) row('source', meta.source);
+ rail.append(kv);
+
+ const guardNote = el('div', { id: 'guard-note', className: 'guard-note', role: 'note', hidden: '' });
+ if (meta.guardNote) {
+ guardNote.hidden = false;
+ guardNote.append(el('p', {}, meta.guardNote),
+ el('button', { type: 'button', className: 'btn btn-link', onClick: () => document.dispatchEvent(new CustomEvent('gitapitaker:refresh')) }, 'Force live request'));
+ }
+ rail.append(guardNote);
+
+ rail.append(el('div', { className: 'rail-actions' },
+ railButton('Refresh', 'Force a live request (bypasses the Request Guard)', () => document.dispatchEvent(new CustomEvent('gitapitaker:refresh'))),
+ railButton('Diff', 'Compare with an older snapshot of this endpoint', () => document.dispatchEvent(new CustomEvent('gitapitaker:diff'))),
+ railButton('Share', 'Copy a shareable inspection link (target URL only, never the response)', async () => {
+ const target = meta.webUrl ?? endpoint.url;
+ const ok = await copyText(buildShareUrl(target));
+ showSnackbar(ok ? 'Share link copied — target URL only' : 'Copy failed');
+ announce(ok ? 'Share link copied. It contains only the target URL, never the response.' : 'Copy failed.');
+ }),
+ railButton('cURL', 'Copy this request as a cURL command', async () => {
+ const ok = await copyText(buildCurlCommand(endpoint));
+ showSnackbar(ok ? 'cURL copied — no credentials included' : 'Copy failed');
+ announce(ok ? 'cURL command copied. It contains no credentials.' : 'Copy failed.');
+ }),
+ ));
+}
+
+function railButton(text, label, onClick) {
+ const btn = el('button', { type: 'button', className: 'm3-btn tonal btn-sm', title: label, 'aria-label': label }, text);
+ btn.addEventListener('click', () => onClick());
+ return btn;
+}
+
+/* ------------------------------------------------------------------ */
+/* Interpretation / change note / pagination */
+/* ------------------------------------------------------------------ */
+
+function renderInterpretation({ data, meta, endpoint }) {
+ const interp = meta.interpretation !== undefined
+ ? meta.interpretation
+ : (data.status >= 400 ? interpretHttpStatus(data.status, endpoint.providerId, data.headers, endpoint.parsed) : null);
+ const banner = document.getElementById('interp-banner');
+ clear(banner);
+ if (!interp) { banner.hidden = true; return; }
+ banner.hidden = false;
+ banner.className = `interp-banner ${data.status >= 400 ? 'interp-warn' : 'interp-info'}`;
+ banner.setAttribute('role', 'note');
+ banner.append(
+ el('p', { className: 'interp-title' }, interp.title, ' ',
+ el('span', { className: 'interp-tag' }, 'GitAPITaker interpretation — not provider documentation')),
+ el('ul', {}, interp.causes.map((c) => el('li', {}, c))),
+ interp.actions?.length
+ ? el('div', { className: 'interp-actions' }, el('strong', {}, 'What you can do: '), el('ul', {}, interp.actions.map((a) => el('li', {}, a))))
+ : null,
+ interp.quickActions?.length ? el('div', { className: 'interp-quick' }, quickActionButtons(interp.quickActions)) : null,
+ el('p', { className: 'view-note' }, 'The provider’s original response body is preserved unchanged in the RAW and JSON views below.'),
+ );
+}
+
+function quickActionButtons(actions) {
+ return actions.map((a) => {
+ const btn = el('button', { type: 'button', className: 'm3-btn outlined btn-sm' }, a.label);
+ btn.addEventListener('click', () => {
+ if (a.input) document.dispatchEvent(new CustomEvent('gitapitaker:inspect', { detail: { input: a.input } }));
+ if (a.goto) document.dispatchEvent(new CustomEvent('gitapitaker:goto', { detail: { page: a.goto } }));
+ });
+ return btn;
+ });
+}
+
+function renderChangeNote(changeNote) {
+ const node = document.getElementById('change-note');
+ clear(node);
+ if (!changeNote) { node.hidden = true; return; }
+ node.hidden = false;
+ const text = changeNote.findings >= 0
+ ? `This response changed since the previous capture — ${changeNote.findings} structural difference${changeNote.findings === 1 ? '' : 's'} detected.`
+ : 'This response body changed since the previous capture (non-JSON bodies cannot be diffed structurally — compare RAW).';
+ const diffBtn = el('button', { type: 'button', className: 'm3-btn text btn-sm' }, 'View diff');
+ diffBtn.addEventListener('click', () => document.dispatchEvent(new CustomEvent('gitapitaker:diff')));
+ node.append(el('span', {}, text), ' ', diffBtn);
+}
+
+function hideChangeNote() {
+ const node = document.getElementById('change-note');
+ clear(node);
+ node.hidden = true;
+}
+
+function renderPagination(pagination, onPaginate) {
+ const bar = document.getElementById('pagination-bar');
+ clear(bar);
+ if (!pagination || (!pagination.nextUrl && !pagination.prevUrl)) { bar.hidden = true; return; }
+ bar.hidden = false;
+ bar.append(
+ el('span', { className: 'pagination-label' }, 'Pagination'),
+ el('span', { className: 'mono pagination-info' }, describePagination(pagination)),
+ );
+ const prev = el('button', { type: 'button', className: 'm3-btn tonal btn-sm', disabled: !pagination.prevUrl }, '← Prev');
+ const next = el('button', { type: 'button', className: 'm3-btn tonal btn-sm', disabled: !pagination.nextUrl }, 'Next →');
+ if (pagination.prevUrl) prev.addEventListener('click', () => onPaginate?.(pagination.prevUrl));
+ if (pagination.nextUrl) next.addEventListener('click', () => onPaginate?.(pagination.nextUrl));
+ bar.append(el('span', { className: 'pagination-buttons' }, prev, next));
+}
+
+function hidePagination() {
+ const bar = document.getElementById('pagination-bar');
+ clear(bar);
+ bar.hidden = true;
+}
+
+function hideInterpretation() {
+ const banner = document.getElementById('interp-banner');
+ clear(banner);
+ banner.hidden = true;
+}
+
+/* ------------------------------------------------------------------ */
+/* Tabs */
+/* ------------------------------------------------------------------ */
+
+function renderTabsArea({ endpoint, data, meta }) {
+ const mount = document.getElementById('response-tabs');
+ clear(mount);
+ const parsed = tryParseJson(data.bodyText);
+
+ tabsInstance = createTabs([
+ {
+ id: 'json', label: 'JSON', kbd: '1',
+ render: (panel) => {
+ if (!parsed.isJson) {
+ panel.append(el('p', { className: 'empty-note' },
+ 'This body is not valid JSON', data.contentType ? ` (Content-Type: ${data.contentType}).` : '.',
+ ' The RAW tab shows exactly what the provider returned.'));
+ return;
+ }
+ panel.append(renderJsonTree(parsed.value));
+ },
+ },
+ {
+ id: 'raw', label: 'RAW', kbd: '2',
+ render: (panel) => panel.append(renderRawView(data.bodyText, { sizeBytes: data.sizeBytes, contentType: data.contentType })),
+ },
+ {
+ id: 'headers', label: 'HEADERS', kbd: '3',
+ render: (panel) => panel.append(renderHeadersView(data.headers)),
+ },
+ {
+ id: 'request', label: 'REQUEST', kbd: '4',
+ render: (panel) => panel.append(renderRequestView(endpoint, { instanceLabel: meta.instanceLabel, source: meta.source })),
+ },
+ ], { ariaLabel: 'Response views' });
+ mount.append(tabsInstance.root);
+}
+
+/* ------------------------------------------------------------------ */
+/* Errors & empty state */
+/* ------------------------------------------------------------------ */
+
+const STAGE_ORDER = ['input', 'detect', 'parse', 'resolve', 'fetch'];
+
+/**
+ * Show a resolver error with an honest stage-by-stage pipeline view.
+ * @param {import('../core/errors.js').ResolverError} err
+ */
+export function showResolverError(err) {
+ toggle('result-area', false);
+ toggle('pending-note', false);
+ toggle('empty-state', false);
+ hideInterpretation();
+ hideChangeNote();
+ hidePagination();
+ toggle('explorer-area', false);
+
+ const failedAt = err.stage ?? 'input';
+ const ctx = err.context ?? {};
+ const failedIndex = STAGE_ORDER.indexOf(failedAt);
+ const values = {
+ input: ctx.input ?? '—',
+ detect: ctx.host ? `${ctx.host} → ${ctx.providerId ?? 'no adapter matched'}` : (ctx.input ?? '—'),
+ parse: ctx.providerId ? `${ctx.providerId} resource parse` : '—',
+ resolve: ctx.parsed ? `${ctx.parsed.resourceType}` : '—',
+ fetch: 'not attempted',
+ };
+ const stages = STAGE_ORDER.map((label, i) => ({
+ label,
+ value: i === failedIndex ? (label === failedAt ? shortFailureValue(label, err, values) : values[label]) : (i < failedIndex ? values[label] : '—'),
+ state: i === failedIndex ? 'fail' : (i < failedIndex ? 'ok' : 'skip'),
+ }));
+ renderPipeline(stages);
+
+ const box = document.getElementById('resolver-error');
+ clear(box);
+ box.hidden = false;
+ box.append(
+ el('h2', {}, 'GitAPITaker could not resolve this input'),
+ el('p', { className: 'mono error-code' }, `${err.code} · stage: ${failedAt}`),
+ el('p', {}, err.message),
+ err.hints?.length ? el('ul', { className: 'hint-list' }, err.hints.map((h) => el('li', {}, h))) : null,
+ err.quickActions?.length ? el('div', { className: 'interp-quick' }, quickActionButtons(err.quickActions)) : null,
+ el('p', { className: 'view-note' }, 'No request was sent. Nothing was contacted.'),
+ );
+ announce(`Resolution failed at ${failedAt}: ${err.message}`, { assertive: true });
+}
+
+function shortFailureValue(stage, err, values) {
+ if (stage === 'detect') return `${err.context?.host ?? '?'} → no adapter`;
+ return values[stage] ?? 'failed';
+}
+
+/**
+ * The request never produced a response and no cached fallback exists.
+ * @param {{endpoint: object, providerName: string, failure: {title: string, causes: string[], actions: string[]}, stages?: Array}} args
+ */
+export function showNetworkError({ endpoint, providerName, failure, stages = [] }) {
+ toggle('result-area', false);
+ toggle('pending-note', false);
+ toggle('empty-state', false);
+ hideInterpretation();
+ hideChangeNote();
+ hidePagination();
+ toggle('explorer-area', false);
+
+ renderPipeline([...stages, { label: 'fetch', value: 'no response — network/CORS failure', state: 'fail' }]);
+
+ const box = document.getElementById('resolver-error');
+ clear(box);
+ box.hidden = false;
+ box.append(
+ el('h2', {}, 'The provider could not be reached'),
+ el('p', { className: 'mono error-code' }, `network-error · ${providerName}`),
+ el('p', {}, failure.title),
+ el('ul', { className: 'hint-list' }, failure.causes.map((c) => el('li', {}, c))),
+ el('p', {}, el('strong', {}, 'What you can do: ')),
+ el('ul', { className: 'hint-list' }, failure.actions.map((a) => el('li', {}, a))),
+ el('p', { className: 'view-note' },
+ `Attempted: ${endpoint.method || 'GET'} ${endpoint.url}. No response was received, so no response data is shown.`,
+ ' If a cached copy exists, it is available in the Cache inspector.'),
+ );
+ announce(`Request failed: ${failure.title}`, { assertive: true });
+}
+
+/** Show the first-visit empty state. */
+export function showEmptyState() {
+ toggle('result-area', false);
+ toggle('resolver-error', false);
+ toggle('pending-note', false);
+ toggle('empty-state', true);
+ renderPipeline([]);
+}
+
+/** Mount (or hide) the endpoint explorer for the current resolution. */
+export function mountExplorer(detection, parsed, hooks) {
+ const area = document.getElementById('explorer-area');
+ clear(area);
+ if (!detection || !parsed) { area.hidden = true; return; }
+ const related = detection.provider.related ? detection.provider.related(parsed, detection.ctx) : [];
+ if (!related.length) {
+ area.hidden = true;
+ return;
+ }
+ area.hidden = false;
+ area.append(el('h2', { className: 'section-title' }, 'Endpoint Explorer'),
+ el('p', { className: 'view-note' }, `Related resources for ${detection.provider.describe(parsed)} — driven by the ${detection.provider.name} adapter’s capability metadata.`));
+ area.append(renderExplorer(related, hooks));
+}
+
+/* ------------------------------------------------------------------ */
+/* Helpers */
+/* ------------------------------------------------------------------ */
+
+function stateChip(state) {
+ const labels = {
+ live: ['LIVE', 'Browser contacted the provider for this inspection'],
+ cached: ['CACHED', 'Served from local cache — the provider was NOT contacted this time'],
+ stale: ['STALE', 'Older cached copy — the provider was not contacted (or could not be reached)'],
+ pending: ['REQUESTING', 'Contacting the provider…'],
+ };
+ const [label, title] = labels[state] ?? labels.live;
+ return el('span', { className: `m3-chip chip-${state}`, title },
+ el('span', { className: 'state-dot', 'aria-hidden': 'true' }), label);
+}
+
+function toggle(id, visible) {
+ const node = document.getElementById(id);
+ if (node) node.hidden = !visible;
+}
+
+/** Keyboard shortcut support: switch JSON/RAW/HEADERS/REQUEST. */
+export function selectResponseTab(id) {
+ tabsInstance?.select(id);
+}
+
+export function hasResult() {
+ return !document.getElementById('result-area').hidden;
+}
diff --git a/src/ui/keyboard.js b/src/ui/keyboard.js
new file mode 100644
index 0000000..dd89f7a
--- /dev/null
+++ b/src/ui/keyboard.js
@@ -0,0 +1,36 @@
+/** Keyboard interaction helpers (roving tabindex for lists). */
+
+/**
+ * Make a list navigable with ArrowUp/Down/Home/End, Enter/Space to activate.
+ * @param {HTMLElement} container
+ * @param {string} itemSelector
+ * @param {{onActivate: (item: HTMLElement) => void}} hooks
+ */
+export function rovingList(container, itemSelector, hooks) {
+ container.addEventListener('keydown', (event) => {
+ const items = [...container.querySelectorAll(itemSelector)].filter((n) => !n.hidden && !n.disabled);
+ if (items.length === 0) return;
+ const current = items.indexOf(document.activeElement);
+ let next = null;
+ if (event.key === 'ArrowDown') next = current < 0 ? 0 : Math.min(items.length - 1, current + 1);
+ else if (event.key === 'ArrowUp') next = current < 0 ? 0 : Math.max(0, current - 1);
+ else if (event.key === 'Home') next = 0;
+ else if (event.key === 'End') next = items.length - 1;
+ if (next !== null) {
+ event.preventDefault();
+ setRoving(items, items[next]);
+ items[next].focus();
+ return;
+ }
+ if ((event.key === 'Enter' || event.key === ' ') && current >= 0) {
+ event.preventDefault();
+ hooks.onActivate(items[current]);
+ }
+ });
+ const items = [...container.querySelectorAll(itemSelector)];
+ if (items.length) setRoving(items, items[0]);
+}
+
+function setRoving(items, active) {
+ for (const item of items) item.tabIndex = item === active ? 0 : -1;
+}
diff --git a/src/ui/palette.js b/src/ui/palette.js
new file mode 100644
index 0000000..c47fb25
--- /dev/null
+++ b/src/ui/palette.js
@@ -0,0 +1,113 @@
+/**
+ * Command palette (Ctrl/Cmd+K). Fully keyboard navigable:
+ * filter by typing, ArrowUp/Down, Enter to run, Escape to close.
+ * Uses a native