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 + + + + + + + + +
+ + + + +
+ +
+
+ GitAPITaker + Git hosting API inspector +
+
+ + +
+
+ +
+ + +
+
+

Inspector

+

Take a Git hosting URL. Inspect the API behind it.

+
+ +
+
+
+
+ + + +
+ +
+

Full URLs, shorthand like github.com/flessan, and git@ SSH remotes all work. Press Enter to inspect, or / to focus this field.

+
+ Try: +
+
+
+
+ + + + + + + + +
+ + + + + + + + + + + + + + + +
+
+
+ + + + + + + + +
+
+ + + +
+ +
+ +

Up/Down to move · Enter to run · Esc to close

+
+
+ + + +
+
+

Keyboard shortcuts

+ +
+ + + + + + + + + + + + + + + + +
Ctrl/Cmd KCommand palette
/Focus the URL input
EnterInspect (while in the URL input)
14Switch JSON / RAW / HEADERS / REQUEST
rForce live request (bypasses the Request Guard)
yCopy as cURL
sCopy share link
dResponse diff against an older snapshot
tCycle color theme (auto / dark / light)
?This help dialog
EscClose dialogs / palette
/Navigate lists, palette and explorer
/Switch response tabs (focus on tablist)
+

In the JSON view: type in the filter box to search keys and values (matches auto-expand); click a key to copy its path, click a value to copy it. Shortcuts are ignored while typing and never trap focus.

+
+
+ + + +
+
+

Response diff

+ +
+
+
+
+ + + + 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 for focus containment. + */ + +import { el, clear } from './dom.js'; +import { announce } from './announce.js'; + +/** + * @param {{getActions: () => Array<{id: string, label: string, hint?: string, keywords?: string, run: () => void}>}} hooks + */ +export function createPalette(hooks) { + const dialog = document.getElementById('palette-dialog'); + const input = dialog.querySelector('#palette-input'); + const list = dialog.querySelector('#palette-list'); + const emptyNote = dialog.querySelector('#palette-empty'); + let visible = []; + + function render(filterText) { + const q = (filterText ?? '').trim().toLowerCase(); + const actions = hooks.getActions(); + visible = actions.filter((a) => + !q + || a.label.toLowerCase().includes(q) + || (a.keywords ?? '').toLowerCase().includes(q)); + clear(list); + for (const action of visible) { + const item = el('button', { + type: 'button', + className: 'palette-item', + role: 'option', + 'aria-selected': 'false', + dataset: { actionId: action.id }, + }, + el('span', { className: 'palette-label' }, action.label), + action.hint ? el('kbd', { className: 'palette-hint' }, action.hint) : null, + ); + item.addEventListener('click', () => runAction(action)); + list.append(item); + } + emptyNote.hidden = visible.length > 0; + updateActive(0); + } + + function items() { return [...list.querySelectorAll('.palette-item')]; } + + function activeIndex() { + return items().findIndex((n) => n.classList.contains('active')); + } + + function updateActive(index) { + const all = items(); + all.forEach((n, i) => { + n.classList.toggle('active', i === index); + n.setAttribute('aria-selected', String(i === index)); + n.tabIndex = i === index ? 0 : -1; + }); + all[index]?.scrollIntoView({ block: 'nearest' }); + } + + function runAction(action) { + close(); + try { + action.run(); + } catch (err) { + announce(`Action failed: ${err.message}`, { assertive: true }); + } + } + + function open() { + if (dialog.open) return; + dialog.showModal(); + input.value = ''; + render(''); + input.focus(); + } + + function close() { + if (dialog.open) dialog.close(); + } + + input.addEventListener('input', () => render(input.value)); + + dialog.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { event.preventDefault(); close(); return; } + if (document.activeElement === input || items().includes(document.activeElement)) { + const all = items(); + const current = activeIndex(); + if (event.key === 'ArrowDown') { + event.preventDefault(); + updateActive(Math.min(all.length - 1, current + 1)); + all[activeIndex()]?.focus(); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + updateActive(Math.max(0, current - 1)); + all[activeIndex()]?.focus(); + } else if (event.key === 'Enter') { + event.preventDefault(); + const idx = activeIndex(); + const action = visible[idx >= 0 ? idx : 0]; + if (action) runAction(action); + } + } + }); + + // Click on backdrop closes. + dialog.addEventListener('click', (event) => { + if (event.target === dialog) close(); + }); + + return { open, close, isOpen: () => dialog.open }; +} diff --git a/src/ui/providers-view.js b/src/ui/providers-view.js new file mode 100644 index 0000000..b9339e1 --- /dev/null +++ b/src/ui/providers-view.js @@ -0,0 +1,174 @@ +/** + * Providers page: official documentation links, capability overview per + * provider, and the custom-instance manager for self-hosted Gitea/Forgejo + * and GitLab. Capability tables are rendered from adapter metadata — this + * page has no hardcoded resource lists of its own. + */ + +import { el, clear } from './dom.js'; +import { listProviders } from '../providers/registry.js'; +import { listInstances, addInstance, removeInstance, probeInstance } from '../providers/instances.js'; +import { ResolverError } from '../core/errors.js'; +import { announce } from './announce.js'; + +export function renderProvidersView() { + const container = document.getElementById('page-providers'); + const cards = container.querySelector('#provider-cards'); + clear(cards); + for (const provider of listProviders()) { + cards.append(renderProviderCard(provider)); + } + renderInstanceManager(container.querySelector('#instance-manager')); +} + +function renderProviderCard(provider) { + const cap = provider.capabilities; + const card = el('article', { className: 'provider-card', 'aria-labelledby': `provider-${provider.id}-title` }, + el('header', { className: 'provider-card-head' }, + el('h2', { id: `provider-${provider.id}-title` }, provider.name), + el('a', { href: provider.docsUrl, target: '_blank', rel: 'noopener noreferrer', className: 'm3-btn outlined btn-sm' }, 'Official API docs'), + ), + el('dl', { className: 'kv-list' }, + el('dt', {}, 'Default web host'), el('dd', { className: 'mono' }, provider.defaultWebBase), + el('dt', {}, 'API base'), el('dd', { className: 'mono' }, provider.defaultApiBase), + el('dt', {}, 'API version'), el('dd', {}, provider.apiInfo.versionLabel), + el('dt', {}, 'Self-hosted support'), el('dd', {}, cap.selfHosted ? 'Yes — register instances below' : 'No (built-in host only)'), + ), + ); + + const table = el('table', { className: 'kv-table' }, + el('caption', { className: 'sr-only' }, `${provider.name} resources GitAPITaker can resolve`), + el('thead', {}, el('tr', {}, + el('th', { scope: 'col' }, 'Resource'), + el('th', { scope: 'col' }, 'Website URL'), + el('th', { scope: 'col' }, 'API endpoint'), + )), + ); + const tbody = el('tbody'); + for (const resource of cap.resources) { + tbody.append(el('tr', {}, + el('td', {}, resource.label), + el('td', { className: 'mono' }, resource.webPattern), + el('td', { className: 'mono' }, resource.apiPattern), + )); + } + table.append(tbody); + card.append(el('h3', { className: 'view-subhead' }, 'Supported resources'), table); + + if (cap.limitations?.length) { + card.append(el('h3', { className: 'view-subhead' }, 'Known limitations'), + el('ul', { className: 'note-list' }, cap.limitations.map((l) => el('li', {}, l)))); + } + + card.append(el('p', { className: 'provider-discuss' }, + discussLink(provider.id, provider.name))); + return card; +} + +/** Contextual link into GitHub Discussions (community backend). */ +export function discussLink(providerId, providerName) { + const title = encodeURIComponent(`[provider:${providerId}] Mapping feedback / behavior notes`); + const url = `https://github.com/34labs/gitapi-dev/discussions/new?category=providers&title=${title}`; + return el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, + `Discuss ${providerName} mappings in the community`); +} + +function renderInstanceManager(mount) { + clear(mount); + const instances = listInstances(); + + const form = el('form', { className: 'instance-form' }, + el('div', { className: 'form-grid' }, + labeled('Kind', el('select', { id: 'inst-kind', className: 'input' }, + el('option', { value: 'gitea' }, 'Gitea / Forgejo'), + el('option', { value: 'gitlab' }, 'GitLab (self-managed)'), + )), + labeled('Instance URL', el('input', { + id: 'inst-web', type: 'url', className: 'input mono', required: '', + placeholder: 'https://git.example.org', 'aria-describedby': 'inst-help', + })), + labeled('API base (optional)', el('input', { + id: 'inst-api', type: 'url', className: 'input mono', + placeholder: 'auto: {instance}/api/v1 or /api/v4', + })), + labeled('Label (optional)', el('input', { id: 'inst-label', type: 'text', className: 'input', placeholder: 'work gitlab' })), + ), + el('p', { id: 'inst-help', className: 'view-note' }, + 'Instances are stored in this browser only. The API base defaults to {instance}/api/v1 (Gitea/Forgejo) or {instance}/api/v4 (GitLab); override it only if your deployment differs.'), + el('div', { className: 'btn-row' }, + el('button', { type: 'submit', className: 'm3-btn filled' }, 'Add instance'), + el('button', { type: 'button', className: 'm3-btn outlined', id: 'inst-probe' }, 'Add + verify'), + ), + el('p', { id: 'inst-result', className: 'view-note', role: 'status' }), + ); + + form.addEventListener('submit', (event) => { event.preventDefault(); submit(false); }); + form.querySelector('#inst-probe').addEventListener('click', () => submit(true)); + + async function submit(withProbe) { + const result = form.querySelector('#inst-result'); + result.textContent = ''; + try { + const entry = addInstance({ + kind: form.querySelector('#inst-kind').value, + label: form.querySelector('#inst-label').value, + webBase: form.querySelector('#inst-web').value, + apiBase: form.querySelector('#inst-api').value, + }); + if (withProbe) { + result.textContent = 'Verifying…'; + const probe = await probeInstance(entry); + result.textContent = `${probe.ok ? 'Verified.' : 'Not verified.'} ${probe.detail}${probe.status ? ` (HTTP ${probe.status})` : ''}`; + announce(result.textContent, { assertive: !probe.ok }); + } else { + result.textContent = `Saved ${entry.label}. Resolve URLs like ${entry.webBase}/owner/repo now.`; + announce(result.textContent); + } + form.querySelector('#inst-web').value = ''; + form.querySelector('#inst-api').value = ''; + form.querySelector('#inst-label').value = ''; + renderInstanceList(mount.querySelector('#instance-list')); + } catch (err) { + if (err instanceof ResolverError) { + result.textContent = err.message; + announce(`Could not add instance: ${err.message}`, { assertive: true }); + } else { + throw err; + } + } + } + + mount.append(el('h2', {}, 'Custom instances (self-hosted)'), form, el('div', { id: 'instance-list' })); + renderInstanceList(mount.querySelector('#instance-list')); +} + +function renderInstanceList(mount) { + clear(mount); + const instances = listInstances(); + if (instances.length === 0) { + mount.append(el('p', { className: 'empty-note' }, 'No custom instances registered.')); + return; + } + const list = el('ul', { className: 'instance-list' }); + for (const inst of instances) { + const row = el('li', { className: 'instance-row' }, + el('span', { className: 'm3-chip chip-provider' }, inst.kind), + el('strong', {}, inst.label), + el('code', { className: 'mono' }, inst.webBase), + el('span', { className: 'view-note' }, `API: ${inst.apiBase}`), + el('button', { type: 'button', className: 'm3-btn text btn-sm btn-danger', 'aria-label': `Remove instance ${inst.label}` }, 'Remove'), + ); + row.querySelector('button').addEventListener('click', () => { + removeInstance(inst.id); + renderInstanceList(mount); + announce(`Instance ${inst.label} removed.`); + }); + list.append(row); + } + mount.append(list); +} + +function labeled(labelText, control) { + const id = control.id; + return el('label', { className: 'field', for: id }, el('span', { className: 'field-label' }, labelText), control); +} diff --git a/src/ui/router.js b/src/ui/router.js new file mode 100644 index 0000000..ad33b07 --- /dev/null +++ b/src/ui/router.js @@ -0,0 +1,35 @@ +/** + * Hash-based router — works on GitHub Pages repository subpaths without any + * server-side routing. Routes: + * #/ inspector + * #/inspect?u= inspector + auto-inspect share target + * #/history #/cache #/providers #/community #/about + */ + +import { hashRoute, parseShareTarget } from '../core/share.js'; + +const PAGES = ['inspector', 'history', 'cache', 'providers', 'community', 'about']; + +/** Parse the current location into {page, inspectTarget}. */ +export function parseLocation() { + const hash = typeof location !== 'undefined' ? location.hash : ''; + const search = typeof location !== 'undefined' ? location.search : ''; + const route = hashRoute(hash).replace(/^\/+/, '') || 'inspector'; + const page = route === 'inspect' ? 'inspector' : (PAGES.includes(route) ? route : 'inspector'); + const inspectTarget = parseShareTarget(hash, search); + return { page, inspectTarget }; +} + +/** @param {{onChange: (state: {page: string, inspectTarget: string|null}) => void}} hooks */ +export function createRouter(hooks) { + const emit = () => hooks.onChange(parseLocation()); + window.addEventListener('hashchange', emit); + return { emit }; +} + +/** @param {string} page */ +export function navigate(page) { + const target = page === 'inspector' ? '#/' : `#/${page}`; + if (location.hash === target) return; + location.hash = target; +} diff --git a/src/ui/snackbar.js b/src/ui/snackbar.js new file mode 100644 index 0000000..d30d287 --- /dev/null +++ b/src/ui/snackbar.js @@ -0,0 +1,17 @@ +/** + * M3 snackbar — brief visual confirmation for transient actions + * (copies, theme changes). Screen-reader announcements stay separate; + * the snackbar mirrors them visually. + */ + +let hideTimer; + +/** @param {string} message */ +export function showSnackbar(message) { + const bar = document.getElementById('snackbar'); + if (!bar) return; + clearTimeout(hideTimer); + bar.textContent = message; + bar.hidden = false; + hideTimer = setTimeout(() => { bar.hidden = true; }, 2600); +} diff --git a/src/ui/tabs.js b/src/ui/tabs.js new file mode 100644 index 0000000..abfc4cd --- /dev/null +++ b/src/ui/tabs.js @@ -0,0 +1,100 @@ +/** + * Accessible tabs (WAI-ARIA APG pattern): arrow keys move and activate, + * Home/End jump, panels are lazily rendered on activation. + */ + +import { el, clear } from './dom.js'; + +/** + * @param {{id: string, label: string, render: (panel: HTMLElement) => void}[]} tabs + * @param {{ariaLabel?: string, onSelect?: (id: string) => void}} [opts] + */ +export function createTabs(tabs, opts = {}) { + const root = el('div', { className: 'tabs' }); + const tablist = el('div', { className: 'tablist', role: 'tablist', 'aria-label': opts.ariaLabel ?? 'Response views' }); + const panelWrap = el('div', { className: 'tab-panels' }); + root.append(tablist, panelWrap); + + const buttons = new Map(); + const panels = new Map(); + const rendered = new Set(); + let selectedId = null; + + tabs.forEach((tab, i) => { + const btn = el('button', { + type: 'button', + role: 'tab', + id: `tab-${tab.id}`, + className: 'tab', + 'aria-controls': `panel-${tab.id}`, + 'aria-selected': 'false', + tabindex: i === 0 ? '0' : '-1', + dataset: { tabId: tab.id }, + title: tab.kbd ? `Shortcut: ${tab.kbd}` : undefined, + }, tab.label, tab.kbd ? el('span', { className: 'tab-kbd', 'aria-hidden': 'true' }, tab.kbd) : null); + btn.addEventListener('click', () => select(tab.id)); + buttons.set(tab.id, btn); + + const panel = el('section', { + role: 'tabpanel', + id: `panel-${tab.id}`, + className: 'tab-panel', + tabindex: '0', + 'aria-labelledby': `tab-${tab.id}`, + hidden: '', + }); + panels.set(tab.id, panel); + tablist.append(btn); + panelWrap.append(panel); + }); + + tablist.addEventListener('keydown', (event) => { + const ids = tabs.map((t) => t.id); + const current = ids.indexOf(selectedId ?? ids[0]); + let next = null; + if (event.key === 'ArrowRight') next = (current + 1) % ids.length; + else if (event.key === 'ArrowLeft') next = (current - 1 + ids.length) % ids.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = ids.length - 1; + if (next === null) return; + event.preventDefault(); + select(ids[next]); + buttons.get(ids[next]).focus(); + }); + + /** @param {string} id */ + function select(id) { + selectedId = id; + for (const tab of tabs) { + const active = tab.id === id; + const btn = buttons.get(tab.id); + const panel = panels.get(tab.id); + btn.setAttribute('aria-selected', String(active)); + btn.tabIndex = active ? 0 : -1; + panel.hidden = !active; + if (active && !rendered.has(tab.id)) { + rendered.add(tab.id); + clear(panel); + tab.render(panel); + } + } + opts.onSelect?.(id); + } + + /** Re-render all panels (used after a new response arrives). */ + function invalidate() { + rendered.clear(); + if (selectedId) { + const tab = tabs.find((t) => t.id === selectedId); + if (tab) { + const panel = panels.get(selectedId); + clear(panel); + rendered.add(selectedId); + tab.render(panel); + } + } + } + + select(tabs[0].id); + return { root, select, invalidate, get selectedId() { return selectedId; } }; +} diff --git a/src/ui/theme.js b/src/ui/theme.js new file mode 100644 index 0000000..616c92d --- /dev/null +++ b/src/ui/theme.js @@ -0,0 +1,35 @@ +/** + * Color theme: auto (follow OS) / dark / light. Persisted locally, + * applied via a data attribute — CSS owns the actual palettes. + */ + +import { readJson, writeJson } from '../core/storage.js'; + +const KEY = 'gitapitaker.theme.v1'; +export const THEME_ORDER = ['auto', 'dark', 'light']; + +/** @returns {'auto'|'dark'|'light'} */ +export function getTheme() { + const t = readJson(KEY); + return THEME_ORDER.includes(t) ? t : 'auto'; +} + +/** @param {'auto'|'dark'|'light'} theme */ +export function setTheme(theme) { + writeJson(KEY, theme); + applyTheme(theme); +} + +/** Pure helper: the next theme in the cycle. @param {string} current */ +export function nextTheme(current) { + const idx = THEME_ORDER.indexOf(current); + return THEME_ORDER[(idx + 1) % THEME_ORDER.length]; +} + +/** @param {'auto'|'dark'|'light'} theme */ +export function applyTheme(theme) { + if (typeof document === 'undefined') return; // Node/test context + const root = document.documentElement; + if (theme === 'auto') root.removeAttribute('data-theme'); + else root.dataset.theme = theme; +} diff --git a/src/viewer/headers.js b/src/viewer/headers.js new file mode 100644 index 0000000..532f0be --- /dev/null +++ b/src/viewer/headers.js @@ -0,0 +1,63 @@ +/** + * HEADERS view: the response headers exactly as the browser exposed them. + * Browsers only expose CORS-safelisted response headers plus headers the + * provider lists in Access-Control-Expose-Headers — this view says so + * instead of pretending the list is complete when it cannot know. + */ + +import { el, pairsToText, copyText } from '../ui/dom.js'; + +/** @param {Array<[string, string]>} headers */ +export function renderHeadersView(headers) { + const view = el('div', { className: 'headers-view' }); + const list = headers ?? []; + + view.append(el('p', { className: 'view-note' }, + `${list.length} header${list.length === 1 ? '' : 's'} exposed by the browser. `, + 'Browsers hide response headers unless they are CORS-safelisted or listed in Access-Control-Expose-Headers; the provider may have sent more.', + )); + + if (list.length === 0) { + view.append(el('p', { className: 'empty-note' }, 'No response headers were exposed for this response.')); + return view; + } + + const filter = el('input', { + type: 'search', className: 'input headers-filter', placeholder: 'Filter headers (e.g. etag, rate, cache)', + 'aria-label': 'Filter response headers', + }); + const tbody = el('tbody'); + const table = el('table', { className: 'kv-table' }, + el('thead', {}, el('tr', {}, el('th', { scope: 'col' }, 'Header'), el('th', { scope: 'col' }, 'Value'))), + tbody, + ); + + const rows = [...list].sort((a, b) => a[0].localeCompare(b[0])); + for (const [name, value] of rows) { + tbody.append(el('tr', {}, + el('th', { scope: 'row', className: 'mono' }, name), + el('td', { className: 'mono kv-value' }, value), + )); + } + + filter.addEventListener('input', () => { + const q = filter.value.trim().toLowerCase(); + for (const tr of tbody.querySelectorAll('tr')) { + const name = tr.querySelector('th')?.textContent.toLowerCase() ?? ''; + const value = tr.querySelector('td')?.textContent.toLowerCase() ?? ''; + tr.hidden = q !== '' && !name.includes(q) && !value.includes(q); + } + }); + + const copy = el('button', { + type: 'button', className: 'm3-btn tonal btn-sm', + onClick: async (e) => { + const ok = await copyText(pairsToText(rows)); + e.target.textContent = ok ? 'Copied' : 'Copy failed'; + setTimeout(() => { e.target.textContent = 'Copy headers'; }, 1500); + }, + }, 'Copy headers'); + + view.append(filter, copy, table); + return view; +} diff --git a/src/viewer/json.js b/src/viewer/json.js new file mode 100644 index 0000000..8eb9fee --- /dev/null +++ b/src/viewer/json.js @@ -0,0 +1,156 @@ +/** + * JSON view: accessible, keyboard-operable tree with collapsible nodes, + * syntax highlighting, live key/value search with auto-expand of matches, + * and click-to-copy JSONPath + values. + * + * The RAW view always shows the exact body; this view is the parsed, + * formatted representation. If the body is not valid JSON, this view says + * so and points to RAW — it never reinterprets the body. + */ + +import { el, clear, copyText } from '../ui/dom.js'; +import { announce } from '../ui/announce.js'; +import { showSnackbar } from '../ui/snackbar.js'; +import { findMatches, subtreeHasMatch } from '../core/jsonsearch.js'; + +const DEFAULT_EXPAND_DEPTH = 2; + +/** + * @param {*} value Parsed JSON value. + * @param {{expandDepth?: number}} [opts] + */ +export function renderJsonTree(value, opts = {}) { + const expandDepth = opts.expandDepth ?? DEFAULT_EXPAND_DEPTH; + const root = el('div', { className: 'json-view' }); + + const searchInput = el('input', { + type: 'search', + className: 'input json-search', + placeholder: 'Filter keys & values…', + 'aria-label': 'Filter JSON keys and values', + spellcheck: 'false', + }); + const matchInfo = el('span', { className: 'json-match-info', role: 'status' }); + const treeMount = el('div', { className: 'json-tree' }); + + const toolbar = el('div', { className: 'json-toolbar', role: 'group', 'aria-label': 'JSON tree controls' }, + searchInput, matchInfo, + el('span', { className: 'json-toolbar-spacer' }), + el('button', { type: 'button', className: 'm3-btn text btn-sm', onClick: () => setAll(treeMount, true) }, 'Expand all'), + el('button', { type: 'button', className: 'm3-btn text btn-sm', onClick: () => setAll(treeMount, false) }, 'Collapse all'), + ); + + let debounceTimer; + searchInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => renderTree(searchInput.value), 140); + }); + + let currentMatches = []; + + function renderTree(query) { + const q = (query ?? '').trim(); + const { paths } = findMatches(value, q); + currentMatches = paths; + matchInfo.textContent = q ? `${paths.length}${paths.length >= 1000 ? '+' : ''} match${paths.length === 1 ? '' : 'es'}` : ''; + clear(treeMount); + treeMount.append(buildNode(value, null, 0, '$', new Set(paths), q !== '')); + } + + function setAll(container, expanded) { + container.querySelectorAll('button.json-toggle').forEach((btn) => { + const li = btn.closest('li'); + if (li) applyToggle(li, expanded); + }); + } + + /** @param {HTMLElement} node @param {string} what */ + async function copyAndAnnounce(text, what) { + const ok = await copyText(text); + showSnackbar(ok ? `Copied ${what}` : 'Copy failed'); + announce(ok ? `Copied ${what}.` : 'Copy failed.', {}); + } + + /** + * @param {*} v @param {string|null} key @param {number} depth + * @param {string} path @param {Set} matchSet @param {boolean} searching + */ + function buildNode(v, key, depth, path, matchSet, searching) { + const keyNode = key === null ? null : el('span', { + className: 'j-key', + role: 'button', + tabindex: '-1', + title: `Click to copy path ${path}`, + onClick: () => copyAndAnnounce(path, `path ${path}`), + }, `"${key}"`, el('span', { className: 'j-punct' }, ': ')); + + if (v !== null && typeof v === 'object') { + const isArray = Array.isArray(v); + const entries = isArray ? v.map((item, i) => [String(i), item]) : Object.entries(v); + const open = isArray ? '[' : '{'; + const close = isArray ? ']' : '}'; + const summary = isArray ? `${v.length} items` : `${entries.length} ${entries.length === 1 ? 'key' : 'keys'}`; + + const toggle = el('button', { + type: 'button', + className: 'json-toggle', + 'aria-expanded': 'false', + 'aria-label': `${key === null ? 'Root' : `Property ${key}`}: ${open}…${close}, ${summary}. Toggle.`, + }, el('span', { className: 'j-caret', 'aria-hidden': 'true' }, '▸'), el('span', { className: 'j-punct' }, open)); + + const childList = el('ul', { className: 'json-children', hidden: '' }); + for (const [childKey, childValue] of entries) { + const childPath = isArray ? `${path}[${childKey}]` : `${path}.${childKey}`; + childList.append(buildNode(childValue, isArray ? null : childKey, depth + 1, childPath, matchSet, searching)); + } + const closePunct = el('span', { className: 'j-punct' }, close); + const preview = el('span', { className: 'j-preview', 'aria-hidden': 'true' }, ` … ${summary} `); + + const li = el('li', { className: 'json-node' }, keyNode, toggle, preview, childList, closePunct); + toggle.addEventListener('click', () => applyToggle(li, toggle.getAttribute('aria-expanded') !== 'true')); + + const shouldExpand = searching ? subtreeHasMatch(currentMatches, path) : depth < expandDepth; + if (shouldExpand) applyToggle(li, true); + return li; + } + + const isHit = matchSet.has(path); + const valueNode = renderPrimitive(v, isHit); + valueNode.setAttribute('role', 'button'); + valueNode.setAttribute('tabindex', '-1'); + valueNode.title = 'Click to copy value'; + valueNode.addEventListener('click', () => copyAndAnnounce(JSON.stringify(v), 'value')); + const liClass = `json-node json-leaf${isHit ? ' json-leaf-hit' : ''}`; + return el('li', { className: liClass }, keyNode, valueNode); + } + + renderTree(''); + root.append(toolbar, treeMount, el('p', { className: 'view-note json-hint' }, + 'Tip: click a key to copy its path, click a value to copy it. Search auto-expands matches.')); + return root; +} + +function applyToggle(li, expanded) { + const btn = li.querySelector(':scope > .json-toggle'); + const list = li.querySelector(':scope > .json-children'); + const preview = li.querySelector(':scope > .j-preview'); + const close = li.querySelector(':scope > .j-punct'); + if (!btn || !list) return; + btn.setAttribute('aria-expanded', String(expanded)); + list.hidden = !expanded; + const caret = btn.querySelector('.j-caret'); + if (caret) caret.textContent = expanded ? '▾' : '▸'; + if (preview) preview.hidden = expanded; + if (close) close.hidden = !expanded; +} + +function renderPrimitive(value, hit) { + const cls = hit ? ' j-hit' : ''; + if (value === null) return el('span', { className: `j-null${cls}` }, 'null'); + switch (typeof value) { + case 'string': return el('span', { className: `j-string${cls}` }, `"${value}"`); + case 'number': return el('span', { className: `j-number${cls}` }, String(value)); + case 'boolean': return el('span', { className: `j-boolean${cls}` }, String(value)); + default: return el('span', { className: `j-null${cls}` }, String(value)); + } +} diff --git a/src/viewer/raw.js b/src/viewer/raw.js new file mode 100644 index 0000000..2f951ad --- /dev/null +++ b/src/viewer/raw.js @@ -0,0 +1,39 @@ +/** + * RAW view: the exact response body as returned by Response.text(). + * No beautifying, normalization or reinterpretation. When the body is JSON, + * the JSON tab shows the parsed/formatted form — this tab stays faithful. + */ + +import { el, copyText } from '../ui/dom.js'; +import { formatBytes } from '../core/format.js'; + +/** + * @param {string} bodyText + * @param {{sizeBytes?: number, contentType?: string}} [meta] + */ +export function renderRawView(bodyText, meta = {}) { + const view = el('div', { className: 'raw-view' }); + const note = el('p', { className: 'view-note' }, + 'Exact response body as received (decoded as text by the browser). ', + `Size: ${formatBytes(meta.sizeBytes ?? new TextEncoder().encode(bodyText).length)}.`, + meta.contentType ? ` Content-Type: ${meta.contentType}.` : '', + ); + view.append(note); + + if (bodyText === '') { + view.append(el('p', { className: 'empty-note' }, 'The response body is empty.')); + return view; + } + + const pre = el('pre', { className: 'raw-body', tabindex: '0', 'aria-label': 'Raw response body' }, bodyText); + const copy = el('button', { + type: 'button', className: 'm3-btn tonal btn-sm', + onClick: async (e) => { + const ok = await copyText(bodyText); + e.target.textContent = ok ? 'Copied' : 'Copy failed'; + setTimeout(() => { e.target.textContent = 'Copy raw body'; }, 1500); + }, + }, 'Copy raw body'); + view.append(copy, pre); + return view; +} diff --git a/src/viewer/request.js b/src/viewer/request.js new file mode 100644 index 0000000..81b513c --- /dev/null +++ b/src/viewer/request.js @@ -0,0 +1,75 @@ +/** + * REQUEST view: what GitAPITaker actually requested. + * Only real information — method, final URL, headers the app sets, and + * honest notes about what the browser itself controls (User-Agent, + * credentials). Includes Copy-as-cURL. + */ + +import { el, pairsToText, copyText } from '../ui/dom.js'; +import { buildCurlCommand } from '../core/curl.js'; +import { getProvider } from '../providers/registry.js'; + +/** + * @param {import('../core/types.js').ResolvedEndpoint} endpoint + * @param {{instanceLabel?: string, source?: string}} [meta] + */ +export function renderRequestView(endpoint, meta = {}) { + const view = el('div', { className: 'request-view' }); + const provider = getProvider(endpoint.providerId); + + const rows = [ + ['Method', endpoint.method || 'GET'], + ['Final endpoint', endpoint.url], + ['Provider', provider ? `${provider.name} (${endpoint.providerId})` : endpoint.providerId], + ]; + if (meta.instanceLabel) rows.push(['Instance', meta.instanceLabel]); + if (endpoint.apiBase) rows.push(['API base', endpoint.apiBase]); + rows.push(['Request source', meta.source ?? 'URL inspection']); + if (provider?.apiInfo?.versionLabel) rows.push(['API version', provider.apiInfo.versionLabel]); + + const dl = el('dl', { className: 'kv-list' }); + for (const [k, v] of rows) { + dl.append(el('dt', {}, k), el('dd', { className: 'mono' }, v)); + } + view.append(dl); + + const headerEntries = Object.entries(endpoint.headers ?? {}); + view.append(el('h3', { className: 'view-subhead' }, 'Request headers set by GitAPITaker')); + if (headerEntries.length === 0) { + view.append(el('p', { className: 'empty-note' }, 'No custom headers are set for this provider.')); + } else { + view.append(el('pre', { className: 'raw-body raw-body-sm', tabindex: '0', 'aria-label': 'Request headers' }, pairsToText(headerEntries))); + } + + const honesty = el('ul', { className: 'note-list' }); + honesty.append( + el('li', {}, 'User-Agent is controlled by the browser and cannot be set from a web page.'), + el('li', {}, 'Credentials mode is "omit": no cookies or stored credentials are sent to provider APIs.'), + el('li', {}, 'The request goes directly from this browser to the provider. GitAPITaker operates no proxy.'), + el('li', {}, 'GitAPITaker v0.1 performs unauthenticated requests; no tokens are ever attached, stored or shared.'), + ); + view.append(el('h3', { className: 'view-subhead' }, 'Browser-managed details'), honesty); + + if (endpoint.docUrl) { + view.append(el('p', {}, + el('a', { href: endpoint.docUrl, target: '_blank', rel: 'noopener noreferrer' }, 'Official documentation for this endpoint'), + )); + } + + const curl = buildCurlCommand(endpoint); + const curlSection = el('div', { className: 'curl-block' }, + el('h3', { className: 'view-subhead' }, 'Copy as cURL'), + el('pre', { className: 'raw-body raw-body-sm', tabindex: '0', 'aria-label': 'cURL command' }, curl), + el('button', { + type: 'button', className: 'm3-btn tonal btn-sm', + onClick: async (e) => { + const ok = await copyText(curl); + e.target.textContent = ok ? 'Copied' : 'Copy failed'; + setTimeout(() => { e.target.textContent = 'Copy cURL'; }, 1500); + }, + }, 'Copy cURL'), + el('p', { className: 'view-note' }, 'Represents exactly what GitAPITaker sends. No credentials are included.'), + ); + view.append(curlSection); + return view; +} diff --git a/styles/app.css b/styles/app.css new file mode 100644 index 0000000..0918c45 --- /dev/null +++ b/styles/app.css @@ -0,0 +1,995 @@ +/* ============================================================ + GitAPITaker v0.3 — Material Design 3 (Material You) styling, + hand-rolled in pure CSS: tonal color roles, rounded surfaces, + state layers, M3 buttons/chips/cards/tabs/dialogs/snackbar, + navigation rail (desktop) and bottom navigation (mobile). + No component libraries, no CDN, no web fonts — privacy intact. + ============================================================ */ + +/* ---------- M3 color roles (baseline tonal palette) ---------- */ + +:root { + --md-primary: #6750a4; + --md-on-primary: #ffffff; + --md-primary-container: #eaddff; + --md-on-primary-container: #21005d; + --md-secondary: #625b71; + --md-on-secondary: #ffffff; + --md-secondary-container: #e8def8; + --md-on-secondary-container: #1d192b; + --md-tertiary: #7d5260; + --md-tertiary-container: #ffd8e4; + --md-on-tertiary-container: #31111d; + --md-surface: #fef7ff; + --md-surface-dim: #ded8e1; + --md-surface-bright: #fef7ff; + --md-surface-container-lowest: #ffffff; + --md-surface-container-low: #f7f2fa; + --md-surface-container: #f3edf7; + --md-surface-container-high: #ece6f0; + --md-surface-container-highest: #e6e0e9; + --md-on-surface: #1d1b20; + --md-on-surface-variant: #49454f; + --md-outline: #79747e; + --md-outline-variant: #cac4d0; + --md-inverse-surface: #322f35; + --md-inverse-on-surface: #f5eff7; + --md-inverse-primary: #d0bcff; + --md-error: #b3261e; + --md-on-error: #ffffff; + --md-error-container: #f9dedc; + --md-on-error-container: #410e0b; + --md-success: #3b6939; + --md-success-container: #c2f0bd; + --md-on-success-container: #0a2003; + --md-warn-container: #ffddb0; + --md-on-warn-container: #291800; + --md-info-container: #d5e3ff; + --md-on-info-container: #001b3e; + --md-scrim: rgba(0, 0, 0, 0.4); + --md-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); + --md-shadow-2: 0 1px 2px rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); + --md-shadow-3: 0 4px 8px 3px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.3); + --hover-layer: rgba(29, 27, 32, 0.08); + --press-layer: rgba(29, 27, 32, 0.12); + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + --sans: Roboto, system-ui, -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif; + color-scheme: light; +} + +:root[data-theme="dark"] { + --md-primary: #d0bcff; + --md-on-primary: #381e72; + --md-primary-container: #4f378b; + --md-on-primary-container: #eaddff; + --md-secondary: #ccc2dc; + --md-on-secondary: #332d41; + --md-secondary-container: #4a4458; + --md-on-secondary-container: #e8def8; + --md-tertiary: #efb8c8; + --md-tertiary-container: #633b48; + --md-on-tertiary-container: #ffd8e4; + --md-surface: #141218; + --md-surface-dim: #141218; + --md-surface-bright: #3b383e; + --md-surface-container-lowest: #0f0d13; + --md-surface-container-low: #1d1b20; + --md-surface-container: #211f26; + --md-surface-container-high: #2b2930; + --md-surface-container-highest: #36343b; + --md-on-surface: #e6e0e9; + --md-on-surface-variant: #cac4d0; + --md-outline: #938f99; + --md-outline-variant: #49454f; + --md-inverse-surface: #e6e0e9; + --md-inverse-on-surface: #322f35; + --md-inverse-primary: #6750a4; + --md-error: #f2b8b5; + --md-on-error: #601410; + --md-error-container: #8c1d18; + --md-on-error-container: #f9dedc; + --md-success: #a6d39e; + --md-success-container: #21511f; + --md-on-success-container: #c2f0bd; + --md-warn-container: #5c4300; + --md-on-warn-container: #ffddb0; + --md-info-container: #1a3c6e; + --md-on-info-container: #d5e3ff; + --md-scrim: rgba(0, 0, 0, 0.6); + --md-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5), 0 1px 3px 1px rgba(0, 0, 0, 0.3); + --md-shadow-2: 0 1px 2px rgba(0, 0, 0, 0.5), 0 2px 6px 2px rgba(0, 0, 0, 0.3); + --md-shadow-3: 0 4px 8px 3px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.5); + --hover-layer: rgba(230, 224, 233, 0.08); + --press-layer: rgba(230, 224, 233, 0.12); + color-scheme: dark; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --md-primary: #d0bcff; + --md-on-primary: #381e72; + --md-primary-container: #4f378b; + --md-on-primary-container: #eaddff; + --md-secondary: #ccc2dc; + --md-on-secondary: #332d41; + --md-secondary-container: #4a4458; + --md-on-secondary-container: #e8def8; + --md-tertiary: #efb8c8; + --md-tertiary-container: #633b48; + --md-on-tertiary-container: #ffd8e4; + --md-surface: #141218; + --md-surface-dim: #141218; + --md-surface-bright: #3b383e; + --md-surface-container-lowest: #0f0d13; + --md-surface-container-low: #1d1b20; + --md-surface-container: #211f26; + --md-surface-container-high: #2b2930; + --md-surface-container-highest: #36343b; + --md-on-surface: #e6e0e9; + --md-on-surface-variant: #cac4d0; + --md-outline: #938f99; + --md-outline-variant: #49454f; + --md-inverse-surface: #e6e0e9; + --md-inverse-on-surface: #322f35; + --md-inverse-primary: #6750a4; + --md-error: #f2b8b5; + --md-on-error: #601410; + --md-error-container: #8c1d18; + --md-on-error-container: #f9dedc; + --md-success: #a6d39e; + --md-success-container: #21511f; + --md-on-success-container: #c2f0bd; + --md-warn-container: #5c4300; + --md-on-warn-container: #ffddb0; + --md-info-container: #1a3c6e; + --md-on-info-container: #d5e3ff; + --md-scrim: rgba(0, 0, 0, 0.6); + --md-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.5), 0 1px 3px 1px rgba(0, 0, 0, 0.3); + --md-shadow-2: 0 1px 2px rgba(0, 0, 0, 0.5), 0 2px 6px 2px rgba(0, 0, 0, 0.3); + --md-shadow-3: 0 4px 8px 3px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.5); + --hover-layer: rgba(230, 224, 233, 0.08); + --press-layer: rgba(230, 224, 233, 0.12); + color-scheme: dark; + } +} + +/* ---------- base ---------- */ + +* { box-sizing: border-box; } + +html { font-size: 16px; } + +body { + margin: 0; + background: var(--md-surface); + color: var(--md-on-surface); + font-family: var(--sans); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +.mono { font-family: var(--mono); font-size: 0.9em; } + +a { color: var(--md-primary); text-decoration: none; } +a:hover { text-decoration: underline; } + +h1 { font-size: 1.75rem; font-weight: 500; margin: 0; letter-spacing: 0; } +h2 { font-size: 1.15rem; font-weight: 500; margin: 1.6rem 0 0.5rem; } +h3 { font-size: 1rem; font-weight: 500; margin: 1rem 0 0.4rem; } + +code { + font-family: var(--mono); + font-size: 0.85em; + background: var(--md-surface-container-high); + border-radius: 6px; + padding: 0.1em 0.4em; + overflow-wrap: anywhere; +} + +kbd { + font-family: var(--mono); + font-size: 0.7rem; + border: 1px solid var(--md-outline-variant); + border-bottom-width: 2px; + border-radius: 6px; + padding: 0.12em 0.45em; + background: var(--md-surface-container); + color: var(--md-on-surface-variant); +} + +:focus-visible { + outline: 2px solid var(--md-primary); + outline-offset: 2px; + border-radius: 4px; +} + +.sr-only { + position: absolute; width: 1px; height: 1px; + margin: -1px; padding: 0; overflow: hidden; + clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +.skip-link { + position: absolute; left: -9999px; top: 0; + background: var(--md-inverse-surface); color: var(--md-inverse-on-surface); + padding: 0.5rem 1.2rem; border-radius: 0 0 12px 0; z-index: 100; +} +.skip-link:focus { left: 0; top: 0; } + +.helper-text { color: var(--md-on-surface-variant); font-size: 0.8rem; margin: 0.4rem 0; } +.empty-note { color: var(--md-on-surface-variant); font-style: italic; } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } +} + +/* ---------- app shell: rail + content ---------- */ + +.app-shell { + display: grid; + grid-template-columns: 80px minmax(0, 1fr); + min-height: 100vh; +} + +.nav-rail { + background: var(--md-surface); + border-right: 1px solid var(--md-surface-container-highest); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0 16px; + position: sticky; + top: 0; + height: 100vh; + z-index: 30; +} + +.brand { display: inline-flex; margin-bottom: 18px; } +.brand:hover { text-decoration: none; } +.brand-mark { + font-family: var(--mono); font-weight: 700; font-size: 0.9rem; + color: var(--md-on-primary); + background: var(--md-primary); + border-radius: 16px; + width: 48px; height: 32px; + display: inline-flex; align-items: center; justify-content: center; +} + +.site-nav { display: flex; flex-direction: column; gap: 8px; width: 100%; } +.site-nav a, .bottom-nav a { + display: flex; flex-direction: column; align-items: center; gap: 3px; + color: var(--md-on-surface-variant); + font-size: 0.72rem; font-weight: 500; letter-spacing: 0.02em; + padding: 4px 0; + text-decoration: none; +} +.site-nav a:hover .nav-label, .bottom-nav a:hover .nav-label { color: var(--md-on-surface); } +.nav-icon { + width: 56px; height: 32px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 16px; + transition: background-color 0.15s ease; +} +.nav-icon svg { width: 22px; height: 22px; } +.site-nav a:hover .nav-icon, .bottom-nav a:hover .nav-icon { background: var(--hover-layer); } +.site-nav a[aria-current="page"], .bottom-nav a[aria-current="page"] { color: var(--md-on-surface); } +.site-nav a[aria-current="page"] .nav-icon, .bottom-nav a[aria-current="page"] .nav-icon { + background: var(--md-secondary-container); + color: var(--md-on-secondary-container); +} +.rail-spacer { flex: 1; } +.rail-version { color: var(--md-outline); font-size: 0.68rem; } + +.app-content { display: flex; flex-direction: column; min-width: 0; } + +/* ---------- top app bar ---------- */ + +.app-bar { + display: flex; align-items: center; gap: 1rem; + padding: 10px 24px; + background: var(--md-surface); + border-bottom: 1px solid var(--md-surface-container-highest); + position: sticky; top: 0; z-index: 25; +} +.app-bar-title { display: flex; align-items: baseline; gap: 0.6rem; min-width: 0; } +.brand-inline { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.01em; } +.app-bar-sub { color: var(--md-on-surface-variant); font-size: 0.8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.app-bar-actions { margin-left: auto; display: flex; align-items: center; gap: 0.5rem; } + +.bottom-nav { + display: none; + position: fixed; bottom: 0; left: 0; right: 0; + background: var(--md-surface-container); + border-top: 1px solid var(--md-surface-container-highest); + z-index: 40; + padding: 6px 4px 8px; + justify-content: space-around; +} + +/* ---------- M3 buttons ---------- */ + +.m3-btn { + position: relative; + display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; + font-family: var(--sans); + font-size: 0.875rem; font-weight: 500; letter-spacing: 0.01em; + height: 40px; + padding: 0 24px; + border-radius: 100px; + border: none; + cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: box-shadow 0.15s ease; +} +.m3-btn::before { + content: ""; + position: absolute; inset: 0; + border-radius: inherit; + background: transparent; + pointer-events: none; +} +.m3-btn:hover::before { background: var(--hover-layer); } +.m3-btn:active::before { background: var(--press-layer); } +.m3-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.m3-btn:disabled::before { background: transparent; } + +.m3-btn.filled { background: var(--md-primary); color: var(--md-on-primary); } +.m3-btn.filled::before { background: transparent; } +.m3-btn.filled:hover { box-shadow: var(--md-shadow-1); } + +.m3-btn.tonal { background: var(--md-secondary-container); color: var(--md-on-secondary-container); } +.m3-btn.outlined { background: transparent; color: var(--md-primary); border: 1px solid var(--md-outline); } +.m3-btn.text { background: transparent; color: var(--md-primary); padding: 0 12px; } +.m3-btn.elevated { background: var(--md-surface-container-low); color: var(--md-primary); box-shadow: var(--md-shadow-1); } + +.m3-btn.btn-sm { height: 32px; padding: 0 16px; font-size: 0.8rem; } +.m3-btn.btn-sm.text { padding: 0 10px; } +.m3-btn.btn-danger { color: var(--md-error); } +.m3-btn.btn-danger.outlined { border-color: var(--md-error); } + +.btn-icon { display: inline-flex; } +.btn-icon svg { width: 18px; height: 18px; } +.btn-theme { padding: 0 14px; } +.theme-label { text-transform: capitalize; } + +.btn-link { + background: none; border: none; color: var(--md-primary); padding: 0; + font: inherit; font-size: 0.83rem; cursor: pointer; text-decoration: underline; +} + +/* ---------- M3 cards ---------- */ + +.card { + border-radius: 16px; + padding: 16px 20px; +} +.card.elevated { background: var(--md-surface-container-low); box-shadow: var(--md-shadow-1); } +.card.outlined { background: var(--md-surface); border: 1px solid var(--md-outline-variant); } +.card.filled { background: var(--md-surface-container-highest); } +.card.error-card { + background: var(--md-error-container); + color: var(--md-on-error-container); + margin: 16px 0; +} +.card.error-card a { color: var(--md-on-error-container); text-decoration: underline; } +.card.error-card .helper-text { color: var(--md-on-error-container); opacity: 0.8; } + +/* ---------- M3 text field ---------- */ + +.m3-field { + position: relative; + display: flex; align-items: center; + background: var(--md-surface-container-highest); + border-radius: 8px 8px 0 0; + border-bottom: 1px solid var(--md-on-surface-variant); + transition: border-color 0.15s ease; + flex: 1; + min-width: 0; +} +.m3-field:focus-within { border-bottom: 2px solid var(--md-primary); margin-bottom: -1px; } +.field-icon { display: inline-flex; color: var(--md-on-surface-variant); padding-left: 14px; } +.field-icon svg { width: 20px; height: 20px; } +.m3-field input { + flex: 1; min-width: 0; + font: inherit; font-size: 0.95rem; + color: var(--md-on-surface); + background: transparent; + border: none; + padding: 14px; + outline: none; +} +.m3-field input::placeholder { color: var(--md-on-surface-variant); opacity: 0.85; } + +/* outlined variant (forms) */ +.m3-field.outlined { + background: transparent; + border: 1px solid var(--md-outline); + border-radius: 8px; +} +.m3-field.outlined:focus-within { border: 2px solid var(--md-primary); margin: -1px; } +.m3-field.outlined input { padding: 10px 12px; } + +/* ---------- layout ---------- */ + +main { + max-width: 1080px; + width: 100%; + margin: 0 auto; + padding: 24px 24px 64px; +} + +.page-head { margin-bottom: 20px; } +.page-sub { color: var(--md-on-surface-variant); font-size: 0.95rem; margin: 0.4rem 0 0; max-width: 72ch; } + +.section-title { + font-size: 1.05rem; font-weight: 500; + margin: 1.75rem 0 0.6rem; +} + +.page-toolbar { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.75rem; } +.btn-row { display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.5rem 0; } + +/* ---------- inspect card ---------- */ + +.inspect-card { margin-bottom: 20px; } +.field-row { display: flex; gap: 12px; align-items: stretch; flex-wrap: wrap; } +.try-row { display: flex; align-items: flex-start; gap: 8px; margin-top: 12px; flex-wrap: wrap; } +.try-label { color: var(--md-on-surface-variant); font-size: 0.8rem; padding-top: 6px; } +.examples { display: flex; gap: 8px; flex-wrap: wrap; } +.example-chip { + font-family: var(--mono); + font-size: 0.75rem; + height: 30px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid var(--md-outline-variant); + background: transparent; + color: var(--md-on-surface-variant); + cursor: pointer; +} +.example-chip:hover { background: var(--hover-layer); color: var(--md-on-surface); } + +/* ---------- M3 chips (status & pipeline) ---------- */ + +.m3-chip { + display: inline-flex; align-items: center; gap: 6px; + height: 28px; + padding: 0 12px; + border-radius: 8px; + font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em; + font-family: var(--mono); + white-space: nowrap; +} +.state-dot { width: 8px; height: 8px; display: inline-block; } +.chip-live { background: var(--md-success-container); color: var(--md-on-success-container); } +.chip-live .state-dot { background: var(--md-success); border-radius: 50%; } +.chip-cached { background: var(--md-info-container); color: var(--md-on-info-container); } +.chip-cached .state-dot { background: var(--md-on-info-container); border-radius: 2px; } +.chip-stale { background: var(--md-warn-container); color: var(--md-on-warn-container); } +.chip-stale .state-dot { background: var(--md-on-warn-container); transform: rotate(45deg); border-radius: 2px; } +.chip-pending { background: var(--md-surface-container-highest); color: var(--md-on-surface-variant); } +.chip-pending .state-dot { background: var(--md-on-surface-variant); border-radius: 50%; } +.chip-muted { background: var(--md-surface-container-highest); color: var(--md-on-surface-variant); font-weight: 500; } +.chip-provider { background: var(--md-primary-container); color: var(--md-on-primary-container); font-weight: 500; } +.chip-status-ok { background: var(--md-success-container); color: var(--md-on-success-container); } +.chip-status-err { background: var(--md-error-container); color: var(--md-on-error-container); } + +/* ---------- pipeline ---------- */ + +.pipeline { + display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; + margin: 0 0 20px; +} +.pipeline-stage { + display: flex; flex-direction: column; gap: 2px; + background: var(--md-surface-container); + border-radius: 12px; + padding: 8px 14px; + min-width: 0; + max-width: 100%; + border-left: 4px solid var(--md-outline-variant); +} +.pipeline-stage .stage-label { + font-family: var(--mono); + font-size: 0.62rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.14em; + color: var(--md-on-surface-variant); +} +.pipeline-stage .stage-value { + font-family: var(--mono); + font-size: 0.78rem; + overflow-wrap: anywhere; + color: var(--md-on-surface); +} +.ps-ok { border-left-color: var(--md-success); } +.ps-active { border-left-color: var(--md-primary); background: var(--md-primary-container); } +.ps-active .stage-value { color: var(--md-on-primary-container); } +.ps-active .stage-label { color: var(--md-on-primary-container); } +.ps-fail { border-left-color: var(--md-error); background: var(--md-error-container); } +.ps-fail .stage-value, .ps-fail .stage-label { color: var(--md-on-error-container); } +.ps-skip { opacity: 0.55; } +.ps-pending { border-left-color: var(--md-warn-container); } + +/* ---------- empty state ---------- */ + +.empty-state { + text-align: center; + padding: 40px 24px; + color: var(--md-on-surface-variant); +} +.empty-icon { display: inline-flex; color: var(--md-primary); margin-bottom: 12px; } +.empty-icon svg { width: 48px; height: 48px; } +.empty-title { font-size: 1.05rem; font-weight: 500; color: var(--md-on-surface); margin: 0 0 6px; } +.empty-body { max-width: 56ch; margin: 0 auto; font-size: 0.9rem; } + +/* ---------- inspector grid & meta card ---------- */ + +.inspector-grid { + display: grid; + grid-template-columns: 270px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.meta-card { + position: sticky; + top: 76px; + display: flex; flex-direction: column; gap: 12px; +} +.rail-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.rail-status { font-size: 1.25rem; font-weight: 600; font-family: var(--mono); } +.rail-status.ok { color: var(--md-success); } +.rail-status.err { color: var(--md-error); } +.rail-endpoint { font-family: var(--mono); font-size: 0.75rem; overflow-wrap: anywhere; margin: 0; } + +.rail-kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 12px; margin: 0; } +.rail-kv dt { + color: var(--md-on-surface-variant); + font-size: 0.68rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.08em; + align-self: center; +} +.rail-kv dd { margin: 0; overflow-wrap: anywhere; font-size: 0.8rem; font-family: var(--mono); } + +.rail-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.rail-actions .m3-btn { width: 100%; padding: 0 8px; } + +.guard-note { + background: var(--md-info-container); + color: var(--md-on-info-container); + border-radius: 12px; + padding: 10px 12px; + font-size: 0.8rem; +} +.guard-note p { margin: 0 0 6px; } +.guard-note .btn-link { color: var(--md-on-info-container); font-weight: 600; } + +.response-main { min-width: 0; display: flex; flex-direction: column; gap: 12px; } + +.pending-note { + color: var(--md-on-surface-variant); + background: var(--md-surface-container); + border-radius: 12px; + padding: 12px 16px; + margin: 0; +} + +/* ---------- interpretation / change / pagination ---------- */ + +.interp-banner { + border-radius: 16px; + padding: 14px 18px; + background: var(--md-surface-container); + border-left: 6px solid var(--md-outline); +} +.interp-warn { border-left-color: var(--md-warn-container); border-left-color: var(--md-tertiary); } +.interp-info { border-left-color: var(--md-primary); } +.interp-title { font-weight: 600; margin: 0 0 6px; font-size: 0.95rem; } +.interp-tag { + display: inline-block; + font-size: 0.64rem; font-weight: 600; letter-spacing: 0.05em; + font-family: var(--mono); + color: var(--md-on-surface-variant); + background: var(--md-surface-container-highest); + border-radius: 6px; + padding: 2px 8px; + margin-left: 6px; + vertical-align: middle; +} +.interp-banner ul { margin: 6px 0; padding-left: 20px; } +.interp-banner li { margin: 3px 0; font-size: 0.88rem; color: var(--md-on-surface-variant); } +.interp-actions { font-size: 0.88rem; } +.interp-actions strong { color: var(--md-on-surface); } +.interp-quick { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; } + +.change-note { + background: var(--md-tertiary-container); + color: var(--md-on-tertiary-container); + border-radius: 16px; + font-size: 0.88rem; + padding: 12px 16px; + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; +} + +.pagination-bar { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + padding: 10px 16px; +} +.pagination-label { + font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.1em; + color: var(--md-on-surface-variant); +} +.pagination-info { color: var(--md-on-surface-variant); font-family: var(--mono); font-size: 0.8rem; } +.pagination-buttons { display: inline-flex; gap: 8px; margin-left: auto; } + +/* ---------- tabs ---------- */ + +.tabs { } +.tablist { + display: flex; gap: 4px; + background: var(--md-surface-container); + border-radius: 16px 16px 0 0; + padding: 8px 8px 0; + overflow-x: auto; +} +.tab { + position: relative; + display: inline-flex; align-items: center; gap: 8px; + font-family: var(--sans); + font-size: 0.85rem; font-weight: 500; letter-spacing: 0.02em; + background: none; border: none; + color: var(--md-on-surface-variant); + padding: 10px 18px 12px; + cursor: pointer; + white-space: nowrap; + border-radius: 12px 12px 0 0; +} +.tab:hover { color: var(--md-on-surface); background: var(--hover-layer); } +.tab[aria-selected="true"] { color: var(--md-on-surface); } +.tab[aria-selected="true"]::after { + content: ""; + position: absolute; left: 14px; right: 14px; bottom: 0; + height: 3px; + border-radius: 3px 3px 0 0; + background: var(--md-primary); +} +.tab-kbd { + font-size: 0.62rem; + border: 1px solid var(--md-outline-variant); + border-radius: 5px; + padding: 0 0.35em; + color: var(--md-on-surface-variant); +} + +.tab-panel { + background: var(--md-surface-container-low); + border-radius: 0 0 16px 16px; + padding: 16px 20px; + min-height: 180px; +} + +.view-subhead { + font-size: 0.72rem; color: var(--md-on-surface-variant); + text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; + margin: 1.1rem 0 0.4rem; +} + +/* ---------- JSON tree ---------- */ + +.json-toolbar { margin-bottom: 8px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.json-search { + max-width: 300px; width: auto; display: inline-block; + font: inherit; font-size: 0.85rem; + color: var(--md-on-surface); + background: var(--md-surface-container-highest); + border: none; + border-bottom: 1px solid var(--md-on-surface-variant); + border-radius: 8px 8px 0 0; + padding: 8px 12px; +} +.json-search:focus { outline: none; border-bottom: 2px solid var(--md-primary); } +.json-match-info { color: var(--md-primary); font-size: 0.8rem; font-family: var(--mono); min-width: 6ch; } +.json-toolbar-spacer { flex: 1; } +.json-hint { margin-top: 10px; } + +.json-view .json-children { list-style: none; margin: 0; padding-left: 20px; border-left: 1px solid var(--md-outline-variant); } +.json-node { margin: 2px 0; } +.json-toggle { + background: none; border: none; padding: 0; cursor: pointer; + color: var(--md-on-surface); font-family: var(--mono); font-size: 0.85rem; +} +.json-toggle:hover .j-caret { color: var(--md-primary); } +.j-caret { color: var(--md-on-surface-variant); display: inline-block; width: 1em; } +.j-key { + color: var(--md-primary); font-family: var(--mono); font-size: 0.85rem; + overflow-wrap: anywhere; cursor: pointer; + border-bottom: 1px dotted transparent; +} +.j-key:hover { border-bottom-color: var(--md-primary); } +.j-punct { color: var(--md-on-surface-variant); font-family: var(--mono); } +.j-preview { color: var(--md-on-surface-variant); font-family: var(--mono); font-size: 0.78rem; } +.j-string { color: var(--md-success); font-family: var(--mono); font-size: 0.85rem; white-space: pre-wrap; overflow-wrap: anywhere; cursor: pointer; } +.j-number { color: var(--md-tertiary); font-family: var(--mono); font-size: 0.85rem; cursor: pointer; } +.j-boolean { color: var(--md-error); font-family: var(--mono); font-size: 0.85rem; cursor: pointer; } +.j-null { color: var(--md-on-surface-variant); font-style: italic; font-family: var(--mono); font-size: 0.85rem; cursor: pointer; } +.j-hit { background: var(--md-primary-container); outline: 1px solid var(--md-primary); border-radius: 4px; } +.json-leaf-hit { background: var(--md-primary-container); border-radius: 4px; } + +/* ---------- raw / kv ---------- */ + +.raw-body { + font-family: var(--mono); font-size: 0.8rem; + background: var(--md-surface-container); + border-radius: 12px; + padding: 14px; + margin: 8px 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 520px; + overflow: auto; +} +.raw-body-sm { max-height: 220px; } + +.m3-table { width: 100%; border-collapse: collapse; font-size: 0.86rem; margin: 8px 0; } +.m3-table th, .m3-table td, +.kv-table th, .kv-table td { + text-align: left; vertical-align: top; + border-bottom: 1px solid var(--md-outline-variant); + padding: 10px 12px; +} +.m3-table thead th, .kv-table thead th { + color: var(--md-on-surface-variant); + font-size: 0.68rem; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.08em; +} +.kv-table { width: 100%; border-collapse: collapse; font-size: 0.86rem; margin: 8px 0; } +.kv-value { overflow-wrap: anywhere; } + +.kv-list { display: grid; grid-template-columns: max-content 1fr; gap: 6px 16px; font-size: 0.88rem; margin: 8px 0; } +.kv-list dt { color: var(--md-on-surface-variant); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; align-self: center; } +.kv-list dd { margin: 0; overflow-wrap: anywhere; font-family: var(--mono); font-size: 0.82rem; } + +.note-list { padding-left: 20px; } +.note-list li { margin: 6px 0; font-size: 0.92rem; } + +.headers-filter { max-width: 340px; margin: 8px 8px 8px 0; display: inline-block; width: auto; } + +/* generic outlined input used in filters/forms */ +.input { + font: inherit; font-size: 0.9rem; + color: var(--md-on-surface); + background: transparent; + border: 1px solid var(--md-outline); + border-radius: 8px; + padding: 8px 12px; + width: 100%; + min-width: 0; +} +.input:focus { outline: none; border: 2px solid var(--md-primary); margin: -1px; } +.input::placeholder { color: var(--md-on-surface-variant); opacity: 0.8; } + +.field { display: block; } +.field-label { + display: block; font-size: 0.72rem; color: var(--md-on-surface-variant); + font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; + margin-bottom: 6px; +} + +select.input { background: var(--md-surface); } + +/* ---------- explorer ---------- */ + +.explorer-area { margin-top: 8px; } +.explorer-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; } +.explorer-item { + display: flex; align-items: stretch; gap: 8px; +} +.explorer-inspect { + flex: 1; + display: grid; + grid-template-columns: max-content minmax(0, 1fr) max-content; + gap: 4px 14px; + align-items: baseline; + text-align: left; + font: inherit; color: var(--md-on-surface); + background: var(--md-surface-container); + border: none; + border-radius: 12px; + padding: 12px 16px; + cursor: pointer; + min-width: 0; +} +.explorer-inspect:hover { background: var(--md-surface-container-high); } +.explorer-label { font-weight: 500; font-size: 0.9rem; } +.explorer-url { color: var(--md-on-surface-variant); font-size: 0.75rem; overflow-wrap: anywhere; } +.explorer-go { + font-size: 0.62rem; color: var(--md-primary); letter-spacing: 0.1em; + text-transform: uppercase; font-weight: 700; +} +.explorer-doc { + align-self: center; + font-size: 0.75rem; font-weight: 500; + border: 1px solid var(--md-outline); + border-radius: 100px; + padding: 6px 14px; + color: var(--md-primary); + background: transparent; +} +.explorer-doc:hover { background: var(--hover-layer); text-decoration: none; } + +/* ---------- history ---------- */ + +.history-list { display: flex; flex-direction: column; gap: 8px; } +.history-row { + display: flex; align-items: center; gap: 8px; + background: var(--md-surface-container); + border-radius: 12px; + padding: 6px 10px; +} +.history-main { + flex: 1; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + background: none; border: none; border-radius: 8px; color: var(--md-on-surface); + font: inherit; text-align: left; padding: 8px; cursor: pointer; + min-width: 0; +} +.history-main:hover { background: var(--hover-layer); } +.history-endpoint { font-family: var(--mono); font-size: 0.78rem; overflow-wrap: anywhere; flex: 1 1 260px; } +.history-meta { color: var(--md-on-surface-variant); font-size: 0.72rem; font-family: var(--mono); } + +/* ---------- cache ---------- */ + +.cache-actions { display: flex; gap: 6px; flex-wrap: wrap; } + +/* ---------- providers ---------- */ + +.provider-cards { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; } +.provider-card { + background: var(--md-surface-container-low); + box-shadow: var(--md-shadow-1); + border-radius: 16px; + padding: 18px 22px; + overflow-x: auto; +} +.provider-card-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.provider-card-head h2 { margin: 0; } +.provider-card-head a { margin-left: auto; } +.provider-discuss { margin-top: 12px; } + +.instance-form { + background: var(--md-surface-container-low); + border-radius: 16px; + box-shadow: var(--md-shadow-1); + padding: 18px 22px; + margin: 12px 0; +} +.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin-bottom: 8px; } + +.instance-list { list-style: none; padding: 0; margin: 12px 0; display: flex; flex-direction: column; gap: 8px; } +.instance-row { + display: flex; align-items: center; gap: 12px; flex-wrap: wrap; + background: var(--md-surface-container); border-radius: 12px; + padding: 10px 14px; +} +.instance-row button { margin-left: auto; } + +/* ---------- community ---------- */ + +.giscus-mount { margin-top: 20px; min-height: 120px; } + +/* ---------- prose (about) ---------- */ + +.prose { max-width: 78ch; } +.prose p, .prose li { font-size: 0.95rem; } +.prose h2 { color: var(--md-on-surface); } + +/* ---------- dialogs ---------- */ + +dialog.m3-dialog { + background: var(--md-surface-container-high); + color: var(--md-on-surface); + border: none; + border-radius: 28px; + padding: 0; + max-width: min(640px, calc(100vw - 32px)); + width: 100%; + box-shadow: var(--md-shadow-3); +} +dialog.m3-dialog::backdrop { background: var(--md-scrim); } + +.dialog-box { padding: 24px; } +.dialog-box-wide { max-height: 82vh; overflow-y: auto; } +.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 12px; } +.dialog-head h2 { margin: 0; font-size: 1.2rem; } + +.help-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; } +.help-table th, .help-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--md-outline-variant); } +.help-table th[scope="row"] { white-space: nowrap; width: 1%; } + +/* ---------- palette ---------- */ + +.palette-dialog { margin-top: 10vh; } +.palette-box { padding: 16px; } +.palette-input { + width: 100%; + font: inherit; font-size: 0.95rem; + color: var(--md-on-surface); + background: var(--md-surface-container-highest); + border: none; + border-bottom: 2px solid var(--md-primary); + border-radius: 12px 12px 0 0; + padding: 12px 16px; +} +.palette-input:focus { outline: none; } +.palette-list { margin-top: 8px; max-height: 46vh; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; } +.palette-item { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + width: 100%; + font: inherit; font-size: 0.9rem; color: var(--md-on-surface); + text-align: left; + background: none; border: none; + border-radius: 12px; + padding: 10px 14px; + cursor: pointer; +} +.palette-item:hover { background: var(--hover-layer); } +.palette-item.active, .palette-item:focus-visible { background: var(--md-secondary-container); color: var(--md-on-secondary-container); } +.palette-hint { flex-shrink: 0; } +.palette-foot { margin: 8px 0 0; } + +/* ---------- diff ---------- */ + +.diff-chooser { margin-bottom: 12px; } +.diff-snapshots { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; margin-top: 6px; } +.diff-snapshots button[aria-checked="true"] { background: var(--md-secondary-container); color: var(--md-on-secondary-container); border-color: transparent; } +.diff-table td { font-size: 0.78rem; overflow-wrap: anywhere; } +.diff-added { background: var(--md-success-container); color: var(--md-on-success-container); } +.diff-removed { background: var(--md-error-container); color: var(--md-on-error-container); } +.diff-changed { background: var(--md-warn-container); color: var(--md-on-warn-container); } + +/* ---------- snackbar ---------- */ + +.snackbar { + position: fixed; + left: 50%; bottom: 24px; + transform: translateX(-50%); + background: var(--md-inverse-surface); + color: var(--md-inverse-on-surface); + border-radius: 8px; + padding: 12px 20px; + font-size: 0.88rem; + box-shadow: var(--md-shadow-2); + z-index: 100; + max-width: min(560px, calc(100vw - 32px)); +} + +/* ---------- responsive ---------- */ + +@media (max-width: 900px) { + .app-shell { grid-template-columns: 1fr; } + .nav-rail { display: none; } + .bottom-nav { display: flex; } + main { padding: 20px 16px 96px; } + .inspector-grid { grid-template-columns: 1fr; } + .meta-card { position: static; } + .app-bar { padding: 10px 16px; } + .app-bar-sub { display: none; } +} + +@media (max-width: 560px) { + .field-row .m3-btn { width: 100%; } + .field-row { flex-direction: column; align-items: stretch; } + .pagination-buttons { margin-left: 0; width: 100%; } + .explorer-inspect { grid-template-columns: 1fr; } + .bottom-nav .nav-label { font-size: 0.62rem; } +} + +/* ---------- small additions ---------- */ + +.error-code { font-weight: 600; opacity: 0.9; font-family: var(--mono); font-size: 0.8rem; } +.change-note .m3-btn { color: var(--md-on-tertiary-container); } +.guard-note .btn-link { text-decoration: underline; } +.hint-list { padding-left: 20px; margin: 8px 0; } +.hint-list li { margin: 4px 0; font-size: 0.9rem; } +.diff-table { width: 100%; border-collapse: collapse; margin: 8px 0; } diff --git a/tests/cache.test.js b/tests/cache.test.js new file mode 100644 index 0000000..ea1222c --- /dev/null +++ b/tests/cache.test.js @@ -0,0 +1,98 @@ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { setStorageForTests } from '../src/core/storage.js'; +import { makeFakeStorage } from './helpers/fake-storage.js'; +import { + cacheKey, fnv1a, readEntry, storeLiveResponse, listEntries, entryState, + deleteEntry, clearAll, listSnapshots, DEFAULT_TTL_MS, MAX_SNAPSHOTS, +} from '../src/core/cache.js'; + +function fakeRecord(overrides = {}) { + return { + live: true, method: 'GET', url: 'https://api.github.com/users/flessan', providerId: 'github', + status: 200, statusText: 'OK', headers: [['content-type', 'application/json']], + bodyText: '{"login":"flessan"}', sizeBytes: 19, durationMs: 120, fetchedAt: Date.now(), + requestHeaders: { Accept: 'application/vnd.github+json' }, + ...overrides, + }; +} + +beforeEach(() => setStorageForTests(makeFakeStorage())); + +test('cache keys include provider, method and endpoint context', () => { + const a = cacheKey('github', 'GET', 'https://api.github.com/users/flessan'); + const b = cacheKey('github', 'GET', 'https://api.github.com/users/other'); + const c = cacheKey('gitlab', 'GET', 'https://api.github.com/users/flessan'); + assert.notEqual(a, b); + assert.notEqual(a, c); + assert.equal(a, cacheKey('github', 'GET', 'https://api.github.com/users/flessan')); +}); + +test('fnv1a is deterministic and distinct', () => { + assert.equal(fnv1a('abc'), fnv1a('abc')); + assert.notEqual(fnv1a('abc'), fnv1a('abd')); +}); + +test('storeLiveResponse persists a complete entry', () => { + const key = cacheKey('github', 'GET', 'https://api.github.com/users/flessan'); + const record = fakeRecord(); + const { entry, stored } = storeLiveResponse(key, record, { webUrl: 'https://github.com/flessan', resourceType: 'user' }); + assert.ok(stored); + const read = readEntry(key); + assert.equal(read.endpoint, record.url); + assert.equal(read.status, 200); + assert.equal(read.bodyText, record.bodyText); + assert.equal(read.webUrl, 'https://github.com/flessan'); + assert.equal(read.ttlMs, DEFAULT_TTL_MS); + assert.equal(read.requestHeaders.Accept, 'application/vnd.github+json'); + assert.equal(entry.key, key); +}); + +test('previous entry is archived as a snapshot on replacement', () => { + const key = cacheKey('github', 'GET', 'https://api.github.com/users/flessan'); + storeLiveResponse(key, fakeRecord({ bodyText: '{"v":1}' })); + storeLiveResponse(key, fakeRecord({ bodyText: '{"v":2}' })); + const snapshots = listSnapshots(key); + assert.equal(snapshots.length, 1); + assert.equal(snapshots[0].bodyText, '{"v":1}'); +}); + +test('snapshot ring is capped', () => { + const key = cacheKey('github', 'GET', 'https://api.github.com/x'); + for (let i = 0; i <= MAX_SNAPSHOTS + 2; i += 1) { + storeLiveResponse(key, fakeRecord({ bodyText: `{"v":${i}}` })); + } + assert.equal(listSnapshots(key).length, MAX_SNAPSHOTS); +}); + +test('entryState distinguishes fresh from stale by TTL', () => { + const now = Date.now(); + const fresh = fakeRecord({ fetchedAt: now - 1000 }); + const stale = fakeRecord({ fetchedAt: now - DEFAULT_TTL_MS - 1000 }); + assert.equal(entryState(fresh, now), 'fresh'); + assert.equal(entryState(stale, now), 'stale'); +}); + +test('listEntries returns sorted entries with state', () => { + storeLiveResponse(cacheKey('github', 'GET', 'https://api.github.com/a'), fakeRecord({ fetchedAt: Date.now() - 1000 })); + storeLiveResponse(cacheKey('github', 'GET', 'https://api.github.com/b'), fakeRecord({ fetchedAt: Date.now() })); + const entries = listEntries(); + assert.equal(entries.length, 2); + assert.ok(entries[0].entry.fetchedAt >= entries[1].entry.fetchedAt); + assert.ok(['fresh', 'stale'].includes(entries[0].state)); +}); + +test('deleteEntry removes entry and its snapshots', () => { + const key = cacheKey('github', 'GET', 'https://api.github.com/users/flessan'); + storeLiveResponse(key, fakeRecord()); + storeLiveResponse(key, fakeRecord({ bodyText: '2' })); + deleteEntry(key); + assert.equal(readEntry(key), null); + assert.deepEqual(listSnapshots(key), []); +}); + +test('clearAll wipes cache and snapshots', () => { + storeLiveResponse(cacheKey('github', 'GET', 'https://api.github.com/a'), fakeRecord()); + clearAll(); + assert.deepEqual(listEntries(), []); +}); diff --git a/tests/curl.test.js b/tests/curl.test.js new file mode 100644 index 0000000..3967765 --- /dev/null +++ b/tests/curl.test.js @@ -0,0 +1,25 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildCurlCommand, shellQuote } from '../src/core/curl.js'; + +test('cURL command reflects method, url and headers', () => { + const cmd = buildCurlCommand({ + method: 'GET', + url: 'https://api.github.com/users/flessan', + headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, + }); + assert.ok(cmd.startsWith('curl -sS -X GET')); + assert.ok(cmd.includes("-H 'Accept: application/vnd.github+json'")); + assert.ok(cmd.includes("-H 'X-GitHub-Api-Version: 2022-11-28'")); + assert.ok(cmd.endsWith("'https://api.github.com/users/flessan'")); +}); + +test('cURL output contains no credentials', () => { + const cmd = buildCurlCommand({ method: 'GET', url: 'https://api.github.com/x', headers: { Accept: 'application/json' } }); + assert.ok(!/authorization/i.test(cmd)); + assert.ok(!/token/i.test(cmd)); +}); + +test('shell quoting escapes single quotes', () => { + assert.equal(shellQuote("it's"), `'it'\\''s'`); +}); diff --git a/tests/diff.test.js b/tests/diff.test.js new file mode 100644 index 0000000..2d6a149 --- /dev/null +++ b/tests/diff.test.js @@ -0,0 +1,48 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { diffJson } from '../src/core/diff.js'; + +test('identical values produce no findings', () => { + assert.deepEqual(diffJson({ a: 1 }, { a: 1 }), []); +}); + +test('added, removed and changed fields are detected', () => { + const findings = diffJson({ a: 1, b: 2, d: { x: true } }, { a: 1, c: 3, d: { x: false } }); + const byPath = Object.fromEntries(findings.map((f) => [f.path, f])); + assert.equal(byPath['$.b'].type, 'removed'); + assert.equal(byPath['$.c'].type, 'added'); + assert.equal(byPath['$.d.x'].type, 'changed'); + assert.equal(byPath['$.d.x'].before, true); + assert.equal(byPath['$.d.x'].after, false); +}); + +test('arrays are compared by index (documented behavior)', () => { + const findings = diffJson([1, 2, 3], [1, 9]); + const changed = findings.find((f) => f.path === '$.1'); + assert.equal(changed.type, 'changed'); + const removed = findings.find((f) => f.path === '$.2'); + assert.equal(removed.type, 'removed'); +}); + +test('type change is reported as changed', () => { + const findings = diffJson({ a: { b: 1 } }, { a: 'now a string' }); + assert.equal(findings[0].path, '$.a'); + assert.equal(findings[0].type, 'changed'); +}); + +test('inputs are never mutated', () => { + const a = { nested: { keep: [1, 2] } }; + const b = { nested: { keep: [1, 3] } }; + const aCopy = JSON.stringify(a); + const bCopy = JSON.stringify(b); + diffJson(a, b); + assert.equal(JSON.stringify(a), aCopy); + assert.equal(JSON.stringify(b), bCopy); +}); + +test('findings are capped for huge payloads', () => { + const a = Object.fromEntries(Array.from({ length: 2000 }, (_, i) => [`k${i}`, i])); + const b = Object.fromEntries(Array.from({ length: 2000 }, (_, i) => [`k${i}`, i + 1])); + const findings = diffJson(a, b); + assert.ok(findings.length <= 501); +}); diff --git a/tests/dom-boot.test.js b/tests/dom-boot.test.js new file mode 100644 index 0000000..e5c6015 --- /dev/null +++ b/tests/dom-boot.test.js @@ -0,0 +1,216 @@ +/** + * DOM boot test (happy-dom, dev-only): loads the real index.html, imports + * the real app.js and drives an inspection session end-to-end with a + * mocked fetch. No live provider API is contacted. + * + * app.js boots once per process, so scenarios run sequentially like one + * user session. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { Window } from 'happy-dom'; +import { createFetchMock } from './helpers/mock-fetch.js'; +import { loadFixture } from './helpers/fixtures.js'; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const window = new Window({ url: 'https://user.github.io/gitapi-dev/' }); +globalThis.window = window; +globalThis.document = window.document; +globalThis.location = window.location; +Object.defineProperty(globalThis, 'navigator', { value: window.navigator, configurable: true }); +globalThis.localStorage = window.localStorage; +globalThis.CustomEvent = window.CustomEvent; +globalThis.Event = window.Event; +globalThis.KeyboardEvent = window.KeyboardEvent; +globalThis.HTMLElement = window.HTMLElement; +globalThis.getComputedStyle = window.getComputedStyle.bind(window); + +document.documentElement.innerHTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8') + .replace(/^[\s\S]*?]*>/i, '') + .replace(/<\/html>[\s\S]*$/i, ''); + +const fetchMock = createFetchMock((url) => { + if (String(url).includes('api.github.com/users/flessan')) { + return { + status: 200, statusText: 'OK', + headers: { 'content-type': 'application/json; charset=utf-8', etag: 'W/"fixture"' }, + body: loadFixture('github-user.json'), + }; + } + return { status: 404, statusText: 'Not Found', headers: { 'content-type': 'application/json' }, body: loadFixture('github-404.json') }; +}); +globalThis.fetch = fetchMock; + +const submitForm = () => { + document.getElementById('inspect-form').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); +}; + +test('boot: app renders the empty state on first load', async () => { + await import('../src/app.js'); + await sleep(60); + assert.equal(document.getElementById('empty-state').hidden, false); + assert.equal(document.getElementById('result-area').hidden, true); + assert.equal(document.getElementById('pipeline').hidden, true, 'pipeline hidden before any inspection'); + assert.equal(fetchMock.calls.length, 0, 'no request happens before the user acts'); +}); + +test('inspect: GitHub user URL renders pipeline, LIVE rail and all four tabs', async () => { + document.getElementById('url-input').value = 'https://github.com/flessan'; + submitForm(); + await sleep(120); + + assert.equal(fetchMock.calls.length, 1, 'exactly one direct provider request'); + assert.ok(fetchMock.calls[0].url.startsWith('https://api.github.com/users/flessan')); + assert.equal(fetchMock.calls[0].init.credentials, 'omit', 'credentials never sent'); + + // Pipeline shows every stage with real values. + const pipeline = document.getElementById('pipeline'); + assert.equal(pipeline.hidden, false); + const pipeText = pipeline.textContent; + assert.ok(pipeText.includes('detect'), `pipeline has detect stage: ${pipeText}`); + assert.ok(pipeText.includes('github.com → github'), 'detect stage names host and adapter'); + assert.ok(pipeText.includes('user'), 'parse stage names the resource type'); + assert.ok(pipeText.includes('api.github.com/users/flessan'), 'resolve stage shows the endpoint'); + assert.ok(pipeText.includes('LIVE 200'), 'fetch stage reports live status'); + + // Metadata rail carries the honest state. + const rail = document.getElementById('status-bar').textContent; + assert.ok(rail.includes('LIVE'), `LIVE badge shown: ${rail}`); + assert.ok(rail.includes('200'), 'status 200 shown'); + + assert.equal(document.getElementById('result-area').hidden, false); + for (const id of ['tab-json', 'tab-raw', 'tab-headers', 'tab-request']) { + assert.ok(document.getElementById(id), `${id} exists`); + } + + assert.ok(document.getElementById('panel-json').textContent.includes('"flessan"'), 'JSON view renders login'); + + document.getElementById('tab-raw').click(); + await sleep(20); + assert.ok(document.getElementById('panel-raw').textContent.includes('"public_repos": 4'), 'RAW view shows body'); + + document.getElementById('tab-headers').click(); + await sleep(20); + assert.ok(document.getElementById('panel-headers').textContent.toLowerCase().includes('etag'), 'HEADERS view lists etag'); + + document.getElementById('tab-request').click(); + await sleep(20); + const requestText = document.getElementById('panel-request').textContent; + assert.ok(requestText.includes('GET')); + assert.ok(requestText.includes('X-GitHub-Api-Version: 2022-11-28')); + assert.ok(requestText.toLowerCase().includes('curl'), 'cURL command present'); + assert.ok(requestText.includes('no proxy'), 'direct-request honesty note present'); + + // Endpoint explorer is driven by adapter metadata. + const explorer = document.getElementById('explorer-area'); + assert.equal(explorer.hidden, false, 'explorer visible for a user resource'); + assert.ok(explorer.textContent.includes('Repositories of flessan')); + + // Single-object response has no pagination. + assert.equal(document.getElementById('pagination-bar').hidden, true); +}); + +test('JSON view: search filters and reports matches', async () => { + document.getElementById('tab-json').click(); + await sleep(20); + const search = document.querySelector('.json-search'); + assert.ok(search, 'JSON search input exists'); + search.value = 'public_repos'; + search.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(300); // debounce + const info = document.querySelector('.json-match-info').textContent; + assert.ok(/1 match/.test(info), `expected 1 match, got: ${info}`); + const hit = document.querySelector('.j-hit, .json-leaf-hit'); + assert.ok(hit, 'a match is highlighted'); + search.value = ''; + search.dispatchEvent(new Event('input', { bubbles: true })); + await sleep(300); +}); + +test('cache + history: records are stored locally', async () => { + const keys = []; + for (let i = 0; i < window.localStorage.length; i += 1) keys.push(window.localStorage.key(i)); + assert.ok(keys.some((k) => k.startsWith('gitapitaker.cache.v1.')), `cache stored (${keys.join(', ')})`); + assert.ok(keys.some((k) => k.startsWith('gitapitaker.history.v1')), 'history stored'); + + const history = JSON.parse(window.localStorage.getItem('gitapitaker.history.v1')); + assert.equal(history.length, 1); + assert.equal(history[0].endpoint, 'https://api.github.com/users/flessan'); + assert.equal(history[0].stateLabel, 'LIVE'); + assert.ok(!JSON.stringify(history).includes('bodyText'), 'history never stores bodies'); +}); + +test('guard: an immediate repeat is suppressed and labeled CACHED, not LIVE', async () => { + submitForm(); + await sleep(120); + assert.equal(fetchMock.calls.length, 1, 'second request suppressed by the Request Guard'); + + const rail = document.getElementById('status-bar').textContent; + assert.ok(rail.includes('CACHED'), `CACHED badge shown: ${rail}`); + const guardNote = document.getElementById('guard-note'); + assert.equal(guardNote.hidden, false, 'guard note visible'); + assert.ok(guardNote.textContent.includes('Request Guard')); + assert.ok(guardNote.textContent.includes('suppressed')); +}); + +test('keyboard: number keys switch response tabs', async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true })); + await sleep(20); + assert.equal(document.getElementById('tab-json').getAttribute('aria-selected'), 'true'); + document.dispatchEvent(new KeyboardEvent('keydown', { key: '3', bubbles: true })); + await sleep(20); + assert.equal(document.getElementById('tab-headers').getAttribute('aria-selected'), 'true'); + assert.equal(document.getElementById('panel-headers').hidden, false); +}); + +test('theme: toggle cycles auto -> dark -> light and persists', async () => { + const btn = document.getElementById('theme-toggle'); + assert.ok(btn, 'theme toggle exists'); + btn.click(); + assert.equal(document.documentElement.dataset.theme, 'dark'); + btn.click(); + assert.equal(document.documentElement.dataset.theme, 'light'); + btn.click(); + assert.equal(document.documentElement.dataset.theme, undefined, 'auto removes the override'); + assert.equal(window.localStorage.getItem('gitapitaker.theme.v1'), '"auto"'); +}); + +test('resolver: unsupported host shows staged failure and sends nothing', async () => { + document.getElementById('url-input').value = 'https://unknown.example.com/some/repo'; + submitForm(); + await sleep(60); + + const box = document.getElementById('resolver-error'); + assert.equal(box.hidden, false); + assert.ok(box.textContent.includes('unsupported-provider')); + assert.ok(box.textContent.includes('stage: detect'), 'failure names the failed pipeline stage'); + assert.ok(box.textContent.includes('No request was sent')); + assert.ok(box.textContent.includes('Register a self-hosted instance'), 'quick action offered'); + assert.equal(document.getElementById('result-area').hidden, true); + assert.equal(fetchMock.calls.length, 1, 'still only the one earlier live request'); + + const pipeText = document.getElementById('pipeline').textContent; + assert.ok(pipeText.includes('no adapter'), 'pipeline marks the failed detect stage'); +}); + +test('recovery: inspecting a valid URL after an error works (served from cache within cooldown)', async () => { + document.getElementById('url-input').value = 'https://github.com/flessan'; + submitForm(); + await sleep(120); + + assert.equal(document.getElementById('resolver-error').hidden, true); + assert.equal(document.getElementById('result-area').hidden, false); + const rail = document.getElementById('status-bar').textContent; + assert.ok(rail.includes('CACHED'), `within guard cooldown the cached copy is served: ${rail}`); +}); + +test('share URLs encode only the instruction, never the response', async () => { + const { buildShareUrl, parseShareTarget } = await import('../src/core/share.js'); + const share = buildShareUrl('https://github.com/flessan', { base: 'https://user.github.io/gitapi-dev/' }); + assert.ok(share.startsWith('https://user.github.io/gitapi-dev/#/inspect?u=')); + assert.ok(!share.includes('flessan%22') && !share.includes('public_repos'), 'no response data leaked'); + assert.equal(parseShareTarget(share.slice(share.indexOf('#'))), 'https://github.com/flessan'); +}); diff --git a/tests/errors.test.js b/tests/errors.test.js new file mode 100644 index 0000000..5351e7a --- /dev/null +++ b/tests/errors.test.js @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { interpretHttpStatus, interpretFetchFailure, ResolverError, ResolverErrorCode } from '../src/core/errors.js'; + +test('404 for a github user suggests the org endpoint', () => { + const interp = interpretHttpStatus(404, 'github', [], { resourceType: 'user', params: { login: 'flessan' } }); + assert.equal(interp.title, 'HTTP 404: github did not find this resource'); + assert.ok(interp.causes.some((c) => c.includes('organization'))); + assert.ok(interp.actions.some((a) => a.includes('/orgs/flessan'))); +}); + +test('404 for a github user offers a one-click org quick action', () => { + const interp = interpretHttpStatus(404, 'github', [], { resourceType: 'user', params: { login: 'flessan' } }); + assert.ok(Array.isArray(interp.quickActions) && interp.quickActions.length === 1); + assert.equal(interp.quickActions[0].input, 'https://github.com/orgs/flessan'); +}); + +test('404 for gitlab project mentions URL-encoded paths', () => { + const interp = interpretHttpStatus(404, 'gitlab', [], { resourceType: 'project', params: { fullPath: 'a/b' } }); + assert.ok(interp.causes.some((c) => c.includes('URL-encoded'))); +}); + +test('429 is interpreted as rate limiting', () => { + const interp = interpretHttpStatus(429, 'gitea', []); + assert.ok(interp.title.includes('rate limited')); + assert.ok(interp.actions.some((a) => a.includes('rate-limit') || a.includes('cached'))); +}); + +test('403 with x-ratelimit-remaining: 0 is interpreted as rate limiting', () => { + const interp = interpretHttpStatus(403, 'github', [['X-RateLimit-Remaining', '0'], ['X-RateLimit-Reset', '1893456000']]); + assert.ok(interp.title.includes('rate limited')); +}); + +test('401 mentions authentication', () => { + const interp = interpretHttpStatus(401, 'github', []); + assert.ok(interp.causes.some((c) => c.includes('authentication') || c.includes('token'))); +}); + +test('5xx is attributed to the provider, not GitAPITaker', () => { + const interp = interpretHttpStatus(502, 'gitlab', []); + assert.ok(interp.title.includes('server error')); + assert.ok(interp.causes.some((c) => c.includes('provider API itself failed'))); +}); + +test('unknown 4xx without special handling returns null', () => { + assert.equal(interpretHttpStatus(418, 'github', []), null); +}); + +test('success statuses are not interpreted', () => { + assert.equal(interpretHttpStatus(200, 'github', []), null); +}); + +test('abort is interpreted as timeout/abort', () => { + const err = new Error('aborted'); err.name = 'AbortError'; + const interp = interpretFetchFailure(err); + assert.ok(interp.title.toLowerCase().includes('timed out')); +}); + +test('offline environment gets an offline message', () => { + const interp = interpretFetchFailure(new TypeError('Failed to fetch'), { online: false }); + assert.ok(interp.title.toLowerCase().includes('offline')); +}); + +test('generic fetch failure mentions network/CORS causes', () => { + const interp = interpretFetchFailure(new TypeError('Failed to fetch'), { online: true }); + assert.ok(interp.causes.some((c) => c.includes('CORS'))); +}); + +test('ResolverError carries code and hints', () => { + const err = new ResolverError(ResolverErrorCode.UNSUPPORTED_PROVIDER, 'nope', ['hint']); + assert.equal(err.code, 'unsupported-provider'); + assert.deepEqual(err.hints, ['hint']); + assert.ok(err instanceof Error); +}); diff --git a/tests/fixtures/gitea-repo.json b/tests/fixtures/gitea-repo.json new file mode 100644 index 0000000..d31cc73 --- /dev/null +++ b/tests/fixtures/gitea-repo.json @@ -0,0 +1,17 @@ +{ + "id": 62, + "owner": { "id": 1, "login": "gitea", "full_name": "", "avatar_url": "https://gitea.com/avatars/1" }, + "name": "gitea", + "full_name": "gitea/gitea", + "description": "Git with a cup of tea", + "empty": false, + "private": false, + "fork": false, + "html_url": "https://gitea.com/gitea/gitea", + "clone_url": "https://gitea.com/gitea/gitea.git", + "default_branch": "main", + "stars_count": 250, + "forks_count": 60, + "open_issues_count": 12, + "open_pr_counter": 3 +} diff --git a/tests/fixtures/github-404.json b/tests/fixtures/github-404.json new file mode 100644 index 0000000..34c0024 --- /dev/null +++ b/tests/fixtures/github-404.json @@ -0,0 +1,5 @@ +{ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/users/users#get-a-user", + "status": "404" +} diff --git a/tests/fixtures/github-ratelimit.json b/tests/fixtures/github-ratelimit.json new file mode 100644 index 0000000..308fd4b --- /dev/null +++ b/tests/fixtures/github-ratelimit.json @@ -0,0 +1,4 @@ +{ + "message": "API rate limit exceeded for 198.51.100.7. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.)", + "documentation_url": "https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting" +} diff --git a/tests/fixtures/github-user.json b/tests/fixtures/github-user.json new file mode 100644 index 0000000..f584e18 --- /dev/null +++ b/tests/fixtures/github-user.json @@ -0,0 +1,22 @@ +{ + "login": "flessan", + "id": 101234567, + "node_id": "U_kgDOBjJjZw", + "avatar_url": "https://avatars.githubusercontent.com/u/101234567?v=4", + "html_url": "https://github.com/flessan", + "type": "User", + "site_admin": false, + "name": null, + "company": null, + "blog": "", + "location": null, + "email": null, + "hireable": null, + "bio": null, + "public_repos": 4, + "public_gists": 0, + "followers": 0, + "following": 0, + "created_at": "2022-01-15T10:00:00Z", + "updated_at": "2026-07-01T08:30:00Z" +} diff --git a/tests/fixtures/gitlab-project.json b/tests/fixtures/gitlab-project.json new file mode 100644 index 0000000..682f99f --- /dev/null +++ b/tests/fixtures/gitlab-project.json @@ -0,0 +1,16 @@ +{ + "id": 63291, + "description": "A sample GitLab project fixture", + "name": "gitlab", + "name_with_namespace": "GitLab.org / gitlab", + "path": "gitlab", + "path_with_namespace": "gitlab-org/gitlab", + "default_branch": "master", + "visibility": "public", + "ssh_url_to_repo": "git@gitlab.com:gitlab-org/gitlab.git", + "http_url_to_repo": "https://gitlab.com/gitlab-org/gitlab.git", + "web_url": "https://gitlab.com/gitlab-org/gitlab", + "star_count": 3300, + "forks_count": 6200, + "open_issues_count": 24500 +} diff --git a/tests/fixtures/gitlab-users.json b/tests/fixtures/gitlab-users.json new file mode 100644 index 0000000..83b1245 --- /dev/null +++ b/tests/fixtures/gitlab-users.json @@ -0,0 +1,11 @@ +[ + { + "id": 2581711, + "username": "flessan", + "name": "F Lessan", + "state": "active", + "locked": false, + "avatar_url": "https://secure.gravatar.com/avatar/example", + "web_url": "https://gitlab.com/flessan" + } +] diff --git a/tests/fixtures/malformed-body.txt b/tests/fixtures/malformed-body.txt new file mode 100644 index 0000000..07a84ea --- /dev/null +++ b/tests/fixtures/malformed-body.txt @@ -0,0 +1 @@ +502 Bad Gateway

502 Bad Gateway

\ No newline at end of file diff --git a/tests/fixtures/not-json.txt b/tests/fixtures/not-json.txt new file mode 100644 index 0000000..801c607 --- /dev/null +++ b/tests/fixtures/not-json.txt @@ -0,0 +1 @@ +plain text body that is not JSON at all { broken \ No newline at end of file diff --git a/tests/guard.test.js b/tests/guard.test.js new file mode 100644 index 0000000..5ad7026 --- /dev/null +++ b/tests/guard.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createGuard, DEFAULT_COOLDOWN_MS } from '../src/core/guard.js'; + +function guardAt(startTime = 0, cooldownMs = DEFAULT_COOLDOWN_MS) { + let t = startTime; + const guard = createGuard({ now: () => t, cooldownMs }); + return { + guard, + advance: (ms) => { t += ms; }, + now: () => t, + }; +} + +test('first request for a key goes live', () => { + const { guard } = guardAt(); + const d = guard.decide('k1'); + assert.equal(d.action, 'live'); + assert.equal(d.reason, 'first-request'); +}); + +test('repeat within cooldown is suppressed to cache', () => { + const { guard, advance } = guardAt(); + guard.decide('k1'); + guard.recordLive('k1'); + advance(1000); + const d = guard.decide('k1'); + assert.equal(d.action, 'cache'); + assert.equal(d.reason, 'cooldown'); + assert.equal(d.suppressedCount, 1); + assert.ok(d.nextLiveAt > 0); +}); + +test('suppression counter accumulates per key', () => { + const { guard, advance } = guardAt(); + guard.decide('k'); + guard.recordLive('k'); + advance(500); guard.recordSuppressed('k'); + advance(500); guard.recordSuppressed('k'); + const info = guard.describe('k'); + assert.equal(info.suppressed, 2); + assert.equal(info.cooldownMs, DEFAULT_COOLDOWN_MS); +}); + +test('after cooldown expires, live requests are allowed again', () => { + const { guard, advance } = guardAt(); + guard.decide('k'); + guard.recordLive('k'); + advance(DEFAULT_COOLDOWN_MS + 1); + const d = guard.decide('k'); + assert.equal(d.action, 'live'); + assert.equal(d.reason, 'cooldown-expired'); +}); + +test('force always allows a live request', () => { + const { guard, advance } = guardAt(); + guard.decide('k'); + guard.recordLive('k'); + advance(10); + const d = guard.decide('k', { force: true }); + assert.equal(d.action, 'live'); + assert.equal(d.reason, 'forced'); +}); + +test('keys are independent', () => { + const { guard } = guardAt(); + guard.decide('a'); + guard.recordLive('a'); + assert.equal(guard.decide('b').action, 'live'); +}); + +test('nextLiveAt points at cooldown end', () => { + const { guard, advance } = guardAt(10_000, 5000); + guard.decide('k'); + guard.recordLive('k'); + advance(100); + const d = guard.decide('k'); + assert.equal(d.nextLiveAt, 15_000); +}); diff --git a/tests/helpers/fake-storage.js b/tests/helpers/fake-storage.js new file mode 100644 index 0000000..fa7d426 --- /dev/null +++ b/tests/helpers/fake-storage.js @@ -0,0 +1,12 @@ +/** Map-backed storage implementing the KvStorage interface, for tests. */ +export function makeFakeStorage() { + const map = new Map(); + return { + kind: 'memory', + get: (k) => (map.has(k) ? map.get(k) : null), + set: (k, v) => { map.set(k, String(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); }, + }; +} diff --git a/tests/helpers/fixtures.js b/tests/helpers/fixtures.js new file mode 100644 index 0000000..0d395f9 --- /dev/null +++ b/tests/helpers/fixtures.js @@ -0,0 +1,10 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..', 'fixtures'); + +/** @param {string} name */ +export function loadFixture(name) { + return readFileSync(join(root, name), 'utf8'); +} diff --git a/tests/helpers/mock-fetch.js b/tests/helpers/mock-fetch.js new file mode 100644 index 0000000..ed40583 --- /dev/null +++ b/tests/helpers/mock-fetch.js @@ -0,0 +1,51 @@ +/** + * Fetch mocks for request-layer tests. No live provider API is ever called. + * The mock honors AbortSignal so timeouts are testable. + */ + +/** + * @param {object|Function} responder A response spec or (url, init) => spec. + * Spec: {status?, statusText?, headers?: Record, body?: string, error?: Error, delayMs?: number} + */ +export function createFetchMock(responder) { + const calls = []; + const fn = async (url, init = {}) => { + calls.push({ url, init }); + const spec = typeof responder === 'function' ? responder(url, init) : responder; + if (spec.delayMs) await sleep(spec.delayMs, init.signal); + if (spec.error) throw spec.error; + + const headerMap = new Map(Object.entries(spec.headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)])); + const status = spec.status ?? 200; + return { + status, + ok: status >= 200 && status < 300, + statusText: spec.statusText ?? '', + headers: { + get: (k) => headerMap.get(String(k).toLowerCase()) ?? null, + forEach: (cb) => headerMap.forEach((v, k) => cb(v, k)), + }, + text: async () => spec.body ?? '', + json: async () => JSON.parse(spec.body ?? 'null'), + }; + }; + fn.calls = calls; + return fn; +} + +function sleep(ms, signal) { + return new Promise((resolve, reject) => { + const t = setTimeout(resolve, ms); + signal?.addEventListener('abort', () => { + clearTimeout(t); + const err = new Error('Aborted'); + err.name = 'AbortError'; + reject(err); + }, { once: true }); + }); +} + +/** A fetch that always throws a TypeError, like the browser does on network/CORS failure. */ +export function failingFetch() { + return createFetchMock({ error: new TypeError('Failed to fetch') }); +} diff --git a/tests/history.test.js b/tests/history.test.js new file mode 100644 index 0000000..e3d3369 --- /dev/null +++ b/tests/history.test.js @@ -0,0 +1,53 @@ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { setStorageForTests } from '../src/core/storage.js'; +import { makeFakeStorage } from './helpers/fake-storage.js'; +import { listHistory, addHistory, removeHistory, clearHistory } from '../src/core/history.js'; + +beforeEach(() => setStorageForTests(makeFakeStorage())); + +const entry = (endpoint, extra = {}) => ({ + providerId: 'github', resourceType: 'user', method: 'GET', endpoint, ...extra, +}); + +test('entries are added newest-first', () => { + addHistory(entry('https://api.github.com/users/a')); + addHistory(entry('https://api.github.com/users/b')); + const list = listHistory(); + assert.equal(list.length, 2); + assert.equal(list[0].endpoint, 'https://api.github.com/users/b'); + assert.ok(list[0].at >= list[1].at); +}); + +test('entries are de-duplicated by endpoint and moved to front', () => { + addHistory(entry('https://api.github.com/users/a', { status: 200 })); + addHistory(entry('https://api.github.com/users/b')); + addHistory(entry('https://api.github.com/users/a', { status: 404 })); + const list = listHistory(); + assert.equal(list.length, 2); + assert.equal(list[0].endpoint, 'https://api.github.com/users/a'); + assert.equal(list[0].status, 404); +}); + +test('history is capped at 100 entries', () => { + for (let i = 0; i < 120; i += 1) addHistory(entry(`https://api.github.com/users/u${i}`)); + assert.equal(listHistory().length, 100); + assert.equal(listHistory()[0].endpoint, 'https://api.github.com/users/u119'); +}); + +test('remove and clear work', () => { + addHistory(entry('https://api.github.com/users/a')); + addHistory(entry('https://api.github.com/users/b')); + const [first] = listHistory(); + removeHistory(first.id); + assert.equal(listHistory().length, 1); + clearHistory(); + assert.equal(listHistory().length, 0); +}); + +test('history records never store response bodies', () => { + addHistory(entry('https://api.github.com/users/a', { status: 200, stateLabel: 'LIVE' })); + const stored = JSON.stringify(listHistory()); + assert.ok(!stored.includes('bodyText')); + assert.ok(!stored.includes('headers')); +}); diff --git a/tests/instances.test.js b/tests/instances.test.js new file mode 100644 index 0000000..262ae47 --- /dev/null +++ b/tests/instances.test.js @@ -0,0 +1,81 @@ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { setStorageForTests } from '../src/core/storage.js'; +import { makeFakeStorage } from './helpers/fake-storage.js'; +import { listInstances, addInstance, removeInstance, probeInstance, clearInstancesForTests } from '../src/providers/instances.js'; +import { ResolverError, ResolverErrorCode } from '../src/core/errors.js'; +import { createFetchMock } from './helpers/mock-fetch.js'; + +beforeEach(() => { + setStorageForTests(makeFakeStorage()); + clearInstancesForTests(); +}); + +test('gitea instance gets default /api/v1 base', () => { + const inst = addInstance({ kind: 'gitea', webBase: 'https://git.acme.test' }); + assert.equal(inst.apiBase, 'https://git.acme.test/api/v1'); + assert.equal(listInstances().length, 1); +}); + +test('gitlab instance gets default /api/v4 base', () => { + const inst = addInstance({ kind: 'gitlab', webBase: 'https://gl.acme.test' }); + assert.equal(inst.apiBase, 'https://gl.acme.test/api/v4'); +}); + +test('custom API base is honored and normalized', () => { + const inst = addInstance({ kind: 'gitea', webBase: 'https://git.acme.test', apiBase: 'https://git.acme.test/custom/v1/' }); + assert.equal(inst.apiBase, 'https://git.acme.test/custom/v1'); +}); + +test('invalid URLs are rejected', () => { + assert.throws(() => addInstance({ kind: 'gitea', webBase: 'nope' }), (e) => e instanceof ResolverError && e.code === ResolverErrorCode.INVALID_INSTANCE); + assert.throws(() => addInstance({ kind: 'gitea', webBase: 'https://ok.test', apiBase: '???' }), (e) => e.code === ResolverErrorCode.INVALID_INSTANCE); +}); + +test('built-in provider hosts cannot be overridden', () => { + assert.throws(() => addInstance({ kind: 'gitea', webBase: 'https://github.com' }), (e) => e.code === ResolverErrorCode.INVALID_INSTANCE); + assert.throws(() => addInstance({ kind: 'gitlab', webBase: 'https://gitlab.com' }), (e) => e.code === ResolverErrorCode.INVALID_INSTANCE); +}); + +test('unknown kinds are rejected', () => { + assert.throws(() => addInstance({ kind: 'bitbucket', webBase: 'https://bb.test' }), (e) => e instanceof ResolverError); +}); + +test('re-adding a host replaces the previous entry', () => { + addInstance({ kind: 'gitea', webBase: 'https://git.acme.test', label: 'old' }); + addInstance({ kind: 'gitea', webBase: 'https://git.acme.test/', label: 'new' }); + const list = listInstances(); + assert.equal(list.length, 1); + assert.equal(list[0].label, 'new'); +}); + +test('removeInstance deletes', () => { + const inst = addInstance({ kind: 'gitea', webBase: 'https://git.acme.test' }); + removeInstance(inst.id); + assert.equal(listInstances().length, 0); +}); + +test('probeInstance: gitea version endpoint success', async () => { + const inst = addInstance({ kind: 'gitea', webBase: 'https://git.acme.test' }); + const fetchImpl = createFetchMock({ status: 200, body: '{"version":"1.22.1"}' }); + const probe = await probeInstance(inst, fetchImpl); + assert.equal(probe.ok, true); + assert.ok(probe.detail.includes('1.22.1')); + assert.equal(fetchImpl.calls[0].url, 'https://git.acme.test/api/v1/version'); +}); + +test('probeInstance: gitlab 401 still confirms the API exists', async () => { + const inst = addInstance({ kind: 'gitlab', webBase: 'https://gl.acme.test' }); + const fetchImpl = createFetchMock({ status: 401, body: '{"message":"401 Unauthorized"}' }); + const probe = await probeInstance(inst, fetchImpl); + assert.equal(probe.ok, true); + assert.equal(probe.status, 401); +}); + +test('probeInstance: unreachable instance is reported honestly', async () => { + const inst = addInstance({ kind: 'gitea', webBase: 'https://git.acme.test' }); + const probe = await probeInstance(inst, createFetchMock({ error: new TypeError('Failed to fetch') })); + assert.equal(probe.ok, false); + assert.equal(probe.status, null); + assert.ok(probe.detail.includes('unreachable') || probe.detail.includes('CORS')); +}); diff --git a/tests/jsonsearch.test.js b/tests/jsonsearch.test.js new file mode 100644 index 0000000..c4c7edb --- /dev/null +++ b/tests/jsonsearch.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { findMatches, subtreeHasMatch } from '../src/core/jsonsearch.js'; + +const sample = { + login: 'flessan', + id: 101234567, + public_repos: 4, + nested: { owner: 'someone', tags: ['dns', 'lists'] }, +}; + +test('matches keys case-insensitively', () => { + const { paths } = findMatches(sample, 'LOGIN'); + assert.deepEqual(paths, ['$.login']); +}); + +test('matches string values', () => { + const { paths } = findMatches(sample, 'flessan'); + assert.ok(paths.includes('$.login')); +}); + +test('matches inside arrays with bracket paths', () => { + const { paths } = findMatches(sample, 'dns'); + assert.deepEqual(paths, ['$.nested.tags[0]']); +}); + +test('matches numeric values via string form', () => { + const { paths } = findMatches(sample, '101234567'); + assert.ok(paths.includes('$.id')); +}); + +test('key match reports the key path, value matches report their own', () => { + const { paths } = findMatches(sample, 'owner'); + assert.ok(paths.includes('$.nested.owner')); +}); + +test('empty query matches nothing', () => { + assert.deepEqual(findMatches(sample, ' ').paths, []); +}); + +test('results are capped', () => { + const big = Object.fromEntries(Array.from({ length: 50 }, (_, i) => [`hit${i}`, 'x'])); + const { paths, count } = findMatches(big, 'hit', { limit: 10 }); + assert.equal(paths.length, 10); + assert.equal(count, 10); +}); + +test('subtreeHasMatch covers nested membership', () => { + const matches = ['$.nested.tags[0]', '$.login']; + assert.equal(subtreeHasMatch(matches, '$.nested'), true); + assert.equal(subtreeHasMatch(matches, '$.nested.tags'), true); + assert.equal(subtreeHasMatch(matches, '$.login'), true); + assert.equal(subtreeHasMatch(matches, '$.other'), false); + assert.equal(subtreeHasMatch(matches, '$'), true); +}); diff --git a/tests/pagination.test.js b/tests/pagination.test.js new file mode 100644 index 0000000..c3a516b --- /dev/null +++ b/tests/pagination.test.js @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseLinkHeader, detectPagination, describePagination } from '../src/core/pagination.js'; + +test('parseLinkHeader extracts rel pairs', () => { + const link = '; rel="next", ; rel="last"'; + const parsed = parseLinkHeader(link); + assert.equal(parsed.next, 'https://api.github.com/users/x/repos?page=2'); + assert.equal(parsed.last, 'https://api.github.com/users/x/repos?page=5'); +}); + +test('parseLinkHeader tolerates empty input', () => { + assert.deepEqual(parseLinkHeader(null), {}); + assert.deepEqual(parseLinkHeader(''), {}); + assert.deepEqual(parseLinkHeader('garbage'), {}); +}); + +test('github Link header produces next/prev urls', () => { + const p = detectPagination({ + providerId: 'github', + url: 'https://api.github.com/users/x/repos?page=2', + headers: [['Link', '; rel="next", ; rel="prev"']], + }); + assert.equal(p.mode, 'link'); + assert.equal(p.nextUrl, 'https://api.github.com/users/x/repos?page=3'); + assert.equal(p.prevUrl, 'https://api.github.com/users/x/repos?page=1'); +}); + +test('no Link header means no pagination', () => { + assert.equal(detectPagination({ providerId: 'github', url: 'https://api.github.com/users/x', headers: [] }), null); +}); + +test('gitlab pagination uses x-* headers and rebuilds the page param', () => { + const p = detectPagination({ + providerId: 'gitlab', + url: 'https://gitlab.com/api/v4/projects/1/issues', + headers: [ + ['x-page', '2'], ['x-next-page', '3'], ['x-prev-page', '1'], + ['x-total', '128'], ['x-per-page', '20'], + ], + }); + assert.equal(p.mode, 'headers'); + assert.equal(p.current, 2); + assert.equal(p.total, 128); + assert.equal(p.perPage, 20); + assert.equal(p.nextUrl, 'https://gitlab.com/api/v4/projects/1/issues?page=3'); + assert.equal(p.prevUrl, 'https://gitlab.com/api/v4/projects/1/issues?page=1'); +}); + +test('gitlab first page has no prev url', () => { + const p = detectPagination({ + providerId: 'gitlab', + url: 'https://gitlab.com/api/v4/projects/1/issues', + headers: [['x-page', '1'], ['x-next-page', '2'], ['x-prev-page', ''], ['x-total', '50']], + }); + assert.equal(p.prevUrl, null); + assert.equal(p.nextUrl, 'https://gitlab.com/api/v4/projects/1/issues?page=2'); +}); + +test('gitlab without x-page reports nothing', () => { + assert.equal(detectPagination({ providerId: 'gitlab', url: 'https://x/api/v4/y', headers: [['x-total', '5']] }), null); +}); + +test('describePagination only states what is known', () => { + const gitlab = detectPagination({ + providerId: 'gitlab', url: 'https://x/api/v4/y', + headers: [['x-page', '1'], ['x-total', '42'], ['x-per-page', '20']], + }); + assert.equal(describePagination(gitlab), 'page 1 · 42 items total · 20/page'); + const link = detectPagination({ providerId: 'github', url: 'https://x', headers: [['link', '; rel="next"']] }); + assert.equal(describePagination(link), 'provider-supplied page links (Link header)'); + assert.equal(describePagination(null), ''); +}); diff --git a/tests/providers.gitea.test.js b/tests/providers.gitea.test.js new file mode 100644 index 0000000..5fad793 --- /dev/null +++ b/tests/providers.gitea.test.js @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gitea } from '../src/providers/gitea.js'; +import { ResolverError, ResolverErrorCode } from '../src/core/errors.js'; + +const ctx = { webBase: gitea.defaultWebBase, apiBase: gitea.defaultApiBase }; +const resolve = (path, customCtx = ctx) => gitea.resolve(gitea.parse(new URL(`https://gitea.com${path}`), customCtx), customCtx); + +test('gitea detection', () => { + assert.equal(gitea.match(new URL('https://gitea.com/x')), true); + assert.equal(gitea.match(new URL('https://github.com/x')), false); +}); + +test('user resolves to /api/v1/users/{username}', () => { + assert.equal(resolve('/gitea').url, 'https://gitea.com/api/v1/users/gitea'); +}); + +test('repo resolves to /api/v1/repos/{owner}/{repo}', () => { + assert.equal(resolve('/gitea/gitea').url, 'https://gitea.com/api/v1/repos/gitea/gitea'); +}); + +test('issue resolves to /api/v1/repos/{o}/{r}/issues/{n}', () => { + assert.equal(resolve('/gitea/gitea/issues/123').url, 'https://gitea.com/api/v1/repos/gitea/gitea/issues/123'); +}); + +test('pull request resolves to /api/v1/repos/{o}/{r}/pulls/{n}', () => { + assert.equal(resolve('/gitea/gitea/pulls/9').url, 'https://gitea.com/api/v1/repos/gitea/gitea/pulls/9'); +}); + +test('commit resolves under /git/commits/{sha}', () => { + assert.equal(resolve('/gitea/gitea/commit/deadbeef').url, 'https://gitea.com/api/v1/repos/gitea/gitea/git/commits/deadbeef'); +}); + +test('release by tag uses the plural /releases/tags/{tag} route', () => { + assert.equal(resolve('/gitea/gitea/releases/tag/v1.20.0').url, 'https://gitea.com/api/v1/repos/gitea/gitea/releases/tags/v1.20.0'); +}); + +test('latest release route', () => { + assert.equal(resolve('/gitea/gitea/releases/latest').url, 'https://gitea.com/api/v1/repos/gitea/gitea/releases/latest'); +}); + +test('src/branch/{branch} resolves to branches endpoint', () => { + assert.equal(resolve('/gitea/gitea/src/branch/main').url, 'https://gitea.com/api/v1/repos/gitea/gitea/branches/main'); +}); + +test('src/branch/{branch}/{path} resolves to contents with ref', () => { + assert.equal( + resolve('/gitea/gitea/src/branch/main/README.md').url, + 'https://gitea.com/api/v1/repos/gitea/gitea/contents/README.md?ref=main', + ); +}); + +test('reserved pages rejected; non-numeric issue rejected', () => { + assert.throws(() => resolve('/explore'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE); + assert.throws(() => resolve('/gitea/gitea/issues/xyz'), (e) => e instanceof ResolverError); +}); + +test('custom API base is honored (self-hosted deployments differ)', () => { + const customCtx = { webBase: 'https://git.example.org', apiBase: 'https://git.example.org/custom-api/v1' }; + const parsed = gitea.parse(new URL('https://git.example.org/acme/widgets'), customCtx); + const endpoint = gitea.resolve(parsed, customCtx); + assert.equal(endpoint.url, 'https://git.example.org/custom-api/v1/repos/acme/widgets'); +}); + +test('related resources for repo include core gitea endpoints', () => { + const parsed = gitea.parse(new URL('https://gitea.com/gitea/gitea'), ctx); + const urls = gitea.related(parsed, ctx).map((r) => r.url); + assert.ok(urls.includes('https://gitea.com/api/v1/repos/gitea/gitea/issues')); + assert.ok(urls.includes('https://gitea.com/api/v1/repos/gitea/gitea/pulls')); + assert.ok(urls.includes('https://gitea.com/api/v1/repos/gitea/gitea/releases')); +}); diff --git a/tests/providers.github.test.js b/tests/providers.github.test.js new file mode 100644 index 0000000..cbaa27a --- /dev/null +++ b/tests/providers.github.test.js @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { github } from '../src/providers/github.js'; +import { ResolverError, ResolverErrorCode } from '../src/core/errors.js'; + +const ctx = { webBase: github.defaultWebBase, apiBase: github.defaultApiBase }; +const resolve = (path) => github.resolve(github.parse(new URL(`https://github.com${path}`), ctx), ctx); + +test('github detection', () => { + assert.equal(github.match(new URL('https://github.com/flessan')), true); + assert.equal(github.match(new URL('https://gitlab.com/flessan')), false); +}); + +test('user resolves to /users/{login}', () => { + const endpoint = resolve('/flessan'); + assert.equal(endpoint.url, 'https://api.github.com/users/flessan'); + assert.equal(endpoint.method, 'GET'); + assert.equal(endpoint.headers.Accept, 'application/vnd.github+json'); + assert.equal(endpoint.headers['X-GitHub-Api-Version'], '2022-11-28'); +}); + +test('organization path resolves to /orgs/{org}', () => { + assert.equal(resolve('/orgs/nodejs').url, 'https://api.github.com/orgs/nodejs'); +}); + +test('repository resolves to /repos/{owner}/{repo}', () => { + assert.equal(resolve('/flessan/AdbPureFlow').url, 'https://api.github.com/repos/flessan/AdbPureFlow'); +}); + +test('.git suffix is stripped from repo names', () => { + assert.equal(resolve('/flessan/AdbPureFlow.git').url, 'https://api.github.com/repos/flessan/AdbPureFlow'); +}); + +test('issue resolves to /repos/{o}/{r}/issues/{n}', () => { + assert.equal(resolve('/flessan/AdbPureFlow/issues/12').url, 'https://api.github.com/repos/flessan/AdbPureFlow/issues/12'); +}); + +test('pull web path resolves to /pulls/{n} (singular → plural)', () => { + assert.equal(resolve('/flessan/AdbPureFlow/pull/7').url, 'https://api.github.com/repos/flessan/AdbPureFlow/pulls/7'); +}); + +test('commit and commits paths', () => { + assert.equal(resolve('/flessan/AdbPureFlow/commit/abc1234').url, 'https://api.github.com/repos/flessan/AdbPureFlow/commits/abc1234'); + assert.equal(resolve('/flessan/AdbPureFlow/commits').url, 'https://api.github.com/repos/flessan/AdbPureFlow/commits'); +}); + +test('releases, release-by-tag and latest release', () => { + assert.equal(resolve('/flessan/AdbPureFlow/releases').url, 'https://api.github.com/repos/flessan/AdbPureFlow/releases'); + assert.equal(resolve('/flessan/AdbPureFlow/releases/tag/v1.0.0').url, 'https://api.github.com/repos/flessan/AdbPureFlow/releases/tags/v1.0.0'); + assert.equal(resolve('/flessan/AdbPureFlow/releases/latest').url, 'https://api.github.com/repos/flessan/AdbPureFlow/releases/latest'); +}); + +test('branch via /tree/{branch}', () => { + assert.equal(resolve('/flessan/AdbPureFlow/tree/main').url, 'https://api.github.com/repos/flessan/AdbPureFlow/branches/main'); +}); + +test('tags and branches lists', () => { + assert.equal(resolve('/flessan/AdbPureFlow/tags').url, 'https://api.github.com/repos/flessan/AdbPureFlow/tags'); + assert.equal(resolve('/flessan/AdbPureFlow/branches').url, 'https://api.github.com/repos/flessan/AdbPureFlow/branches'); +}); + +test('blob resolves to contents with ref query', () => { + assert.equal(resolve('/flessan/AdbPureFlow/blob/main/README.md').url, 'https://api.github.com/repos/flessan/AdbPureFlow/contents/README.md?ref=main'); +}); + +test('reserved top-level names are not treated as users', () => { + assert.throws(() => resolve('/features'), (e) => e instanceof ResolverError && e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE); + assert.throws(() => resolve('/topics'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE); +}); + +test('homepage is rejected with a hint', () => { + assert.throws(() => resolve('/'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE && e.hints.length > 0); +}); + +test('non-numeric issue numbers are rejected', () => { + assert.throws(() => resolve('/flessan/AdbPureFlow/issues/abc'), (e) => e instanceof ResolverError); +}); + +test('unmapped repo sections are rejected with docs hint', () => { + assert.throws(() => resolve('/flessan/AdbPureFlow/wiki'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE); +}); + +test('tree with a deep path is honestly rejected (ambiguous ref/path)', () => { + assert.throws(() => resolve('/flessan/AdbPureFlow/tree/main/src/deep'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE); +}); + +test('related resources for a user include core user endpoints', () => { + const parsed = github.parse(new URL('https://github.com/flessan'), ctx); + const urls = github.related(parsed, ctx).map((r) => r.url); + assert.ok(urls.includes('https://api.github.com/users/flessan/repos')); + assert.ok(urls.includes('https://api.github.com/users/flessan/followers')); + assert.ok(urls.includes('https://api.github.com/users/flessan/received_events')); +}); + +test('related resources for a repo include issues, pulls, commits, releases', () => { + const parsed = github.parse(new URL('https://github.com/flessan/AdbPureFlow'), ctx); + const urls = github.related(parsed, ctx).map((r) => r.url); + assert.ok(urls.includes('https://api.github.com/repos/flessan/AdbPureFlow/issues')); + assert.ok(urls.includes('https://api.github.com/repos/flessan/AdbPureFlow/pulls')); + assert.ok(urls.includes('https://api.github.com/repos/flessan/AdbPureFlow/commits')); + assert.ok(urls.includes('https://api.github.com/repos/flessan/AdbPureFlow/releases')); +}); diff --git a/tests/providers.gitlab.test.js b/tests/providers.gitlab.test.js new file mode 100644 index 0000000..161e7f7 --- /dev/null +++ b/tests/providers.gitlab.test.js @@ -0,0 +1,83 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { gitlab } from '../src/providers/gitlab.js'; +import { ResolverError, ResolverErrorCode } from '../src/core/errors.js'; + +const ctx = { webBase: gitlab.defaultWebBase, apiBase: gitlab.defaultApiBase }; +const resolve = (path) => gitlab.resolve(gitlab.parse(new URL(`https://gitlab.com${path}`), ctx), ctx); + +test('gitlab detection', () => { + assert.equal(gitlab.match(new URL('https://gitlab.com/x')), true); + assert.equal(gitlab.match(new URL('https://github.com/x')), false); +}); + +test('single segment resolves to /users?username= (NOT a 1:1 URL mapping)', () => { + const endpoint = resolve('/flessan'); + assert.equal(endpoint.url, 'https://gitlab.com/api/v4/users?username=flessan'); + assert.ok(endpoint.notes.some((n) => n.includes('JSON array'))); +}); + +test('nested group project path is URL-encoded into /projects/{id}', () => { + const endpoint = resolve('/gitlab-org/gitlab'); + assert.equal(endpoint.url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab'); +}); + +test('deeply nested group paths are preserved', () => { + assert.equal( + resolve('/group/subgroup/team/project').url, + 'https://gitlab.com/api/v4/projects/group%2Fsubgroup%2Fteam%2Fproject', + ); +}); + +test('issue via /-/issues/{iid}', () => { + assert.equal(resolve('/gitlab-org/gitlab/-/issues/42').url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/issues/42'); +}); + +test('merge request via /-/merge_requests/{iid}', () => { + assert.equal(resolve('/gitlab-org/gitlab/-/merge_requests/100').url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/100'); +}); + +test('commit via /-/commit/{sha}', () => { + assert.equal(resolve('/gitlab-org/gitlab/-/commit/abc123').url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/repository/commits/abc123'); +}); + +test('release by tag is fully encoded', () => { + assert.equal(resolve('/gitlab-org/gitlab/-/releases/v1.2.3').url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/releases/v1.2.3'); + assert.equal(resolve('/gitlab-org/gitlab/-/releases/release/1.0').url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/releases/release%2F1.0'); +}); + +test('branch via /-/tree/{branch}', () => { + assert.equal(resolve('/gitlab-org/gitlab/-/tree/main').url, 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/repository/branches/main'); +}); + +test('blob resolves to repository/files with ref query', () => { + assert.equal( + resolve('/gitlab-org/gitlab/-/blob/master/README.md').url, + 'https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/repository/files/README.md?ref=master', + ); +}); + +test('reserved site pages are rejected', () => { + assert.throws(() => resolve('/explore'), (e) => e instanceof ResolverError && e.code === ResolverErrorCode.UNSUPPORTED_RESOURCE); +}); + +test('missing project before /-/ is rejected', () => { + assert.throws(() => resolve('/-/issues/1'), (e) => e.code === ResolverErrorCode.MALFORMED_URL); +}); + +test('related resources are empty for users (needs numeric id) but exist for projects', () => { + const user = gitlab.parse(new URL('https://gitlab.com/flessan'), ctx); + assert.deepEqual(gitlab.related(user, ctx), []); + + const project = gitlab.parse(new URL('https://gitlab.com/gitlab-org/gitlab'), ctx); + const urls = gitlab.related(project, ctx).map((r) => r.url); + assert.ok(urls.includes('https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/issues')); + assert.ok(urls.includes('https://gitlab.com/api/v4/projects/gitlab-org%2Fgitlab/merge_requests')); +}); + +test('self-hosted context uses the provided apiBase', () => { + const customCtx = { webBase: 'https://git.example.org', apiBase: 'https://git.example.org/api/v4' }; + const parsed = gitlab.parse(new URL('https://git.example.org/team/project'), customCtx); + const endpoint = gitlab.resolve(parsed, customCtx); + assert.equal(endpoint.url, 'https://git.example.org/api/v4/projects/team%2Fproject'); +}); diff --git a/tests/request.test.js b/tests/request.test.js new file mode 100644 index 0000000..399a2c0 --- /dev/null +++ b/tests/request.test.js @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { executeEndpoint, byteLength, tryParseJson } from '../src/core/request.js'; +import { createFetchMock, failingFetch } from './helpers/mock-fetch.js'; +import { loadFixture } from './helpers/fixtures.js'; + +const endpoint = { + providerId: 'github', + method: 'GET', + url: 'https://api.github.com/users/flessan', + headers: { Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }, +}; + +test('successful request produces an honest live record', async () => { + const body = loadFixture('github-user.json'); + const fetchImpl = createFetchMock({ status: 200, statusText: 'OK', headers: { 'content-type': 'application/json; charset=utf-8', etag: 'W/"abc"' }, body }); + const result = await executeEndpoint(endpoint, { fetchImpl }); + assert.equal(result.ok, true); + const r = result.record; + assert.equal(r.live, true); + assert.equal(r.status, 200); + assert.equal(r.bodyText, body); + assert.equal(r.sizeBytes, byteLength(body)); + assert.equal(r.contentType, 'application/json; charset=utf-8'); + assert.deepEqual(r.headers.find(([k]) => k === 'etag'), ['etag', 'W/"abc"']); + assert.equal(r.requestHeaders.Accept, 'application/vnd.github+json'); + assert.ok(typeof r.durationMs === 'number' && r.durationMs >= 0); + assert.ok(r.fetchedAt <= Date.now() && r.fetchedAt > Date.now() - 5000); +}); + +test('request options never include credentials', async () => { + const fetchImpl = createFetchMock({ status: 200, body: '{}' }); + await executeEndpoint(endpoint, { fetchImpl }); + const init = fetchImpl.calls[0].init; + assert.equal(init.credentials, 'omit'); + assert.equal(init.cache, 'no-store'); + assert.equal(init.method, 'GET'); +}); + +test('provider HTTP errors are still real responses (ok: true, status preserved)', async () => { + const body = loadFixture('github-404.json'); + const fetchImpl = createFetchMock({ status: 404, statusText: 'Not Found', headers: { 'content-type': 'application/json' }, body }); + const result = await executeEndpoint(endpoint, { fetchImpl }); + assert.equal(result.ok, true); + assert.equal(result.record.status, 404); + assert.equal(result.record.bodyText, body); +}); + +test('rate-limit fixture is passed through unchanged', async () => { + const body = loadFixture('github-ratelimit.json'); + const fetchImpl = createFetchMock({ status: 403, statusText: 'rate limit exceeded', headers: { 'x-ratelimit-remaining': '0' }, body }); + const result = await executeEndpoint(endpoint, { fetchImpl }); + assert.equal(result.record.status, 403); + assert.deepEqual(result.record.headers.find(([k]) => k === 'x-ratelimit-remaining'), ['x-ratelimit-remaining', '0']); +}); + +test('non-JSON bodies are preserved verbatim', async () => { + const body = loadFixture('malformed-body.txt'); + const fetchImpl = createFetchMock({ status: 502, statusText: 'Bad Gateway', headers: { 'content-type': 'text/html' }, body }); + const result = await executeEndpoint(endpoint, { fetchImpl }); + assert.equal(result.record.bodyText, body); + const parsed = tryParseJson(result.record.bodyText); + assert.equal(parsed.isJson, false); +}); + +test('network failure returns ok:false with the original error', async () => { + const result = await executeEndpoint(endpoint, { fetchImpl: failingFetch() }); + assert.equal(result.ok, false); + assert.ok(result.error instanceof TypeError); +}); + +test('timeout aborts the request', async () => { + const fetchImpl = createFetchMock({ status: 200, body: '{}', delayMs: 500 }); + const result = await executeEndpoint(endpoint, { fetchImpl, timeoutMs: 20 }); + assert.equal(result.ok, false); + assert.equal(result.error.name, 'AbortError'); +}); + +test('tryParseJson handles valid, invalid and empty bodies', () => { + assert.deepEqual(tryParseJson('{"a":1}'), { isJson: true, value: { a: 1 } }); + assert.equal(tryParseJson('{ broken').isJson, false); + assert.equal(tryParseJson('').isJson, false); + assert.equal(tryParseJson(' ').isJson, false); +}); + +test('byteLength counts UTF-8 bytes', () => { + assert.equal(byteLength('abc'), 3); + assert.equal(byteLength('héllo'), 6); +}); diff --git a/tests/resolver.test.js b/tests/resolver.test.js new file mode 100644 index 0000000..62adcd6 --- /dev/null +++ b/tests/resolver.test.js @@ -0,0 +1,71 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveInput, endpointFromExplorerItem } from '../src/core/resolver.js'; +import { ResolverError, ResolverErrorCode } from '../src/core/errors.js'; + +test('end-to-end: github.com/flessan -> api.github.com/users/flessan', () => { + const { endpoint, parsed, provider } = resolveInput('https://github.com/flessan', { instances: [] }); + assert.equal(provider.id, 'github'); + assert.equal(parsed.resourceType, 'user'); + assert.equal(endpoint.url, 'https://api.github.com/users/flessan'); +}); + +test('shorthand input is accepted through the full pipeline', () => { + const { endpoint } = resolveInput('github.com/flessan/AdbPureFlow', { instances: [] }); + assert.equal(endpoint.url, 'https://api.github.com/repos/flessan/AdbPureFlow'); +}); + +test('issue pipeline', () => { + const { endpoint, parsed } = resolveInput('https://github.com/flessan/AdbPureFlow/issues/12', { instances: [] }); + assert.equal(parsed.resourceType, 'issue'); + assert.equal(endpoint.url, 'https://api.github.com/repos/flessan/AdbPureFlow/issues/12'); +}); + +test('unsupported provider host is rejected with actionable hints', () => { + try { + resolveInput('https://example.com/some/repo', { instances: [] }); + assert.fail('expected ResolverError'); + } catch (err) { + assert.ok(err instanceof ResolverError); + assert.equal(err.code, ResolverErrorCode.UNSUPPORTED_PROVIDER); + assert.ok(err.hints.some((h) => h.includes('Built-in providers'))); + assert.ok(err.hints.some((h) => h.toLowerCase().includes('self-hosted'))); + } +}); + +test('malformed input is rejected before any provider work', () => { + assert.throws(() => resolveInput('::not a url::', { instances: [] }), (e) => e instanceof ResolverError); +}); + +test('empty input produces EMPTY_INPUT', () => { + assert.throws(() => resolveInput('', { instances: [] }), (e) => e.code === ResolverErrorCode.EMPTY_INPUT); +}); + +test('registered self-hosted gitea instance is detected by host', () => { + const instances = [{ + id: 'inst-x', kind: 'gitea', label: 'acme', + webBase: 'https://git.acme.test', apiBase: 'https://git.acme.test/api/v1', + }]; + const { endpoint, detection } = resolveInput('https://git.acme.test/team/widgets', { instances }); + assert.equal(endpoint.url, 'https://git.acme.test/api/v1/repos/team/widgets'); + assert.equal(detection.ctx.instanceId, 'inst-x'); +}); + +test('built-in hosts win over instance registration attempts', () => { + const instances = [{ + id: 'inst-evil', kind: 'gitea', label: 'nope', + webBase: 'https://github.com', apiBase: 'https://evil.example/api/v1', + }]; + const { endpoint } = resolveInput('https://github.com/flessan', { instances }); + assert.equal(endpoint.url, 'https://api.github.com/users/flessan'); +}); + +test('endpointFromExplorerItem builds a provider-correct endpoint', () => { + const { detection } = resolveInput('https://github.com/flessan', { instances: [] }); + const endpoint = endpointFromExplorerItem( + { url: 'https://api.github.com/users/flessan/repos', label: 'Repos', resourceType: 'repos' }, + detection, + ); + assert.equal(endpoint.headers['X-GitHub-Api-Version'], '2022-11-28'); + assert.equal(endpoint.method, 'GET'); +}); diff --git a/tests/share.test.js b/tests/share.test.js new file mode 100644 index 0000000..c69562c --- /dev/null +++ b/tests/share.test.js @@ -0,0 +1,43 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildShareUrl, parseShareTarget, hashRoute } from '../src/core/share.js'; + +test('buildShareUrl encodes only the inspection instruction', () => { + const url = buildShareUrl('https://github.com/flessan', { base: 'https://user.github.io/gitapi-dev/' }); + assert.equal(url, 'https://user.github.io/gitapi-dev/#/inspect?u=https%3A%2F%2Fgithub.com%2Fflessan'); +}); + +test('share URLs work from repository subpaths', () => { + const url = buildShareUrl('https://gitlab.com/gitlab-org/gitlab', { base: 'https://example.com/deep/sub/path/' }); + assert.ok(url.startsWith('https://example.com/deep/sub/path/#/inspect?u=')); +}); + +test('share URLs never embed response data', () => { + const url = buildShareUrl('https://github.com/flessan'); + assert.ok(!url.includes('body')); + assert.ok(!url.includes('token')); + assert.ok(!url.includes('status')); +}); + +test('parseShareTarget round-trips hash URLs', () => { + const target = 'https://github.com/flessan/AdbPureFlow/issues/12'; + const share = buildShareUrl(target, { base: '' }); + const hash = share.slice(share.indexOf('#')); + assert.equal(parseShareTarget(hash), target); +}); + +test('parseShareTarget accepts top-level query form', () => { + assert.equal(parseShareTarget('', '?u=https%3A%2F%2Fgitea.com%2Fgitea%2Fgitea'), 'https://gitea.com/gitea/gitea'); +}); + +test('parseShareTarget returns null when no target present', () => { + assert.equal(parseShareTarget('#/history'), null); + assert.equal(parseShareTarget('', ''), null); +}); + +test('hashRoute parses pages', () => { + assert.equal(hashRoute('#/'), '/'); + assert.equal(hashRoute('#/history'), '/history'); + assert.equal(hashRoute(''), '/'); + assert.equal(hashRoute('#/inspect?u=x'), '/inspect'); +}); diff --git a/tests/theme.test.js b/tests/theme.test.js new file mode 100644 index 0000000..002e807 --- /dev/null +++ b/tests/theme.test.js @@ -0,0 +1,28 @@ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { setStorageForTests } from '../src/core/storage.js'; +import { makeFakeStorage } from './helpers/fake-storage.js'; +import { nextTheme, getTheme, setTheme, THEME_ORDER } from '../src/ui/theme.js'; + +beforeEach(() => setStorageForTests(makeFakeStorage())); + +test('nextTheme cycles auto -> dark -> light -> auto', () => { + assert.equal(nextTheme('auto'), 'dark'); + assert.equal(nextTheme('dark'), 'light'); + assert.equal(nextTheme('light'), 'auto'); +}); + +test('default theme is auto', () => { + assert.equal(getTheme(), 'auto'); +}); + +test('setTheme persists', () => { + setTheme('dark'); + assert.equal(getTheme(), 'dark'); +}); + +test('corrupt stored value falls back to auto', () => { + setStorageForTests({ ...makeFakeStorage(), get: () => '"neon"' }); + assert.equal(getTheme(), 'auto'); + assert.ok(THEME_ORDER.includes(getTheme())); +}); diff --git a/tests/url.test.js b/tests/url.test.js new file mode 100644 index 0000000..beeffab --- /dev/null +++ b/tests/url.test.js @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeInput, joinUrl, encodeFully, encodePathKeepingSlashes, parseBaseUrl } from '../src/core/url.js'; +import { ResolverError, ResolverErrorCode } from '../src/core/errors.js'; + +test('normalizeInput accepts full URLs', () => { + assert.equal(normalizeInput('https://github.com/flessan').toString(), 'https://github.com/flessan'); +}); + +test('normalizeInput accepts shorthand host/path', () => { + assert.equal(normalizeInput('github.com/flessan').toString(), 'https://github.com/flessan'); +}); + +test('normalizeInput strips www, trailing slashes, query and hash', () => { + assert.equal(normalizeInput('www.github.com/flessan/').toString(), 'https://github.com/flessan'); + assert.equal(normalizeInput('https://github.com/flessan?tab=repos#readme').toString(), 'https://github.com/flessan'); +}); + +test('normalizeInput upgrades http to https', () => { + assert.equal(normalizeInput('http://gitlab.com/foo').toString(), 'https://gitlab.com/foo'); +}); + +test('normalizeInput accepts git@ SSH remote form', () => { + assert.equal(normalizeInput('git@github.com:flessan/AdbPureFlow.git').toString(), 'https://github.com/flessan/AdbPureFlow.git'); +}); + +test('normalizeInput rejects empty input', () => { + assert.throws(() => normalizeInput(' '), (e) => e instanceof ResolverError && e.code === ResolverErrorCode.EMPTY_INPUT); +}); + +test('normalizeInput rejects hostless input', () => { + assert.throws(() => normalizeInput('flessan'), (e) => e.code === ResolverErrorCode.MALFORMED_URL); +}); + +test('normalizeInput rejects unsupported schemes', () => { + assert.throws(() => normalizeInput('ftp://github.com/x'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_SCHEME); + assert.throws(() => normalizeInput('javascript:alert(1)'), (e) => e.code === ResolverErrorCode.UNSUPPORTED_SCHEME); +}); + +test('normalizeInput rejects invalid URLs', () => { + assert.throws(() => normalizeInput('https://[::nope'), (e) => e.code === ResolverErrorCode.MALFORMED_URL); +}); + +test('joinUrl joins encoded segments', () => { + assert.equal(joinUrl('https://api.github.com', ['repos', 'a b', 'c']), 'https://api.github.com/repos/a%20b/c'); +}); + +test('encoding helpers', () => { + assert.equal(encodeFully('group/sub/project'), 'group%2Fsub%2Fproject'); + assert.equal(encodePathKeepingSlashes('release/v1.0'), 'release/v1.0'); + assert.equal(encodePathKeepingSlashes('a b/c'), 'a%20b/c'); +}); + +test('parseBaseUrl validates bases', () => { + assert.equal(parseBaseUrl('https://git.example.org').origin, 'https://git.example.org'); + assert.equal(parseBaseUrl('not a url'), null); + assert.equal(parseBaseUrl('ftp://x.y'), null); + assert.equal(parseBaseUrl(''), null); +}); diff --git a/tools/serve.mjs b/tools/serve.mjs new file mode 100644 index 0000000..4368920 --- /dev/null +++ b/tools/serve.mjs @@ -0,0 +1,61 @@ +#!/usr/bin/env node +/** + * Minimal dependency-free static dev server for GitAPITaker. + * Dev-only: GitHub Pages deployment serves the same static files directly. + * + * node tools/serve.mjs → http://localhost:8080 + * PORT=9000 node tools/serve.mjs + */ + +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { extname, join, normalize, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const port = Number(process.env.PORT || 8080); + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', + '.md': 'text/plain; charset=utf-8', +}; + +const server = createServer(async (req, res) => { + try { + const urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname); + let filePath = normalize(join(root, urlPath)); + if (!filePath.startsWith(root + sep) && filePath !== root) { + res.writeHead(403); res.end('Forbidden'); return; + } + if (filePath === root || filePath.endsWith(sep)) filePath = join(filePath, 'index.html'); + let body; + try { + body = await readFile(filePath); + } catch { + res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); + res.end('Not found'); + return; + } + const type = TYPES[extname(filePath).toLowerCase()] ?? 'application/octet-stream'; + res.writeHead(200, { + 'content-type': type, + 'cache-control': 'no-store', + 'cross-origin-opener-policy': 'same-origin', + }); + res.end(body); + } catch (err) { + res.writeHead(500); res.end(String(err)); + } +}); + +server.listen(port, '0.0.0.0', () => { + console.log(`GitAPITaker dev server: http://localhost:${port} (root: ${root})`); +});