aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 16:37:49 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 16:37:49 -0700
commitc013ed57bf3d7dd83bd59a9b984d87aebde6003c (patch)
tree31d25456496f013f13f3cae1ded376d5323b3200
parent510fd8cbe53abb39cba2c7cbaaefcf2783dc0066 (diff)
downloadgraphiql-c013ed57bf3d7dd83bd59a9b984d87aebde6003c.tar.gz
graphiql-c013ed57bf3d7dd83bd59a9b984d87aebde6003c.zip
Migrate from Deno/JSR to npm publishing
- @sveltejs/package builds dist/ for @eeeooolll/graphiql with three entry points (./, ./component, ./splitter) - Vitest + svelte-check replace Deno test/check; runes shim no longer needed since the Svelte plugin compiles .svelte.ts at runtime - Drop $app/environment dep in Editor.svelte to support non-SvelteKit consumers - Refactor TabBar tab element from nested <button> to role=tab <div> per PLAN.md gotcha; svelte-check flagged the invalid HTML - README now documents npm install, integration patterns for Yoga, Apollo, graphql-modules, Hono/Bun/Deno, plus APQ + keyboard table
Diffstat (limited to '')
-rw-r--r--.gitignore7
-rw-r--r--.npmignore6
-rw-r--r--README.md280
-rw-r--r--deno.json31
-rw-r--r--deno.lock220
-rw-r--r--package.json62
-rw-r--r--source/library/components/Editor.svelte3
-rw-r--r--source/library/components/TabBar.svelte15
-rw-r--r--svelte.config.js5
-rw-r--r--tests/history.test.ts27
-rw-r--r--tests/operations.test.ts43
-rw-r--r--tests/storage.test.ts79
-rw-r--r--tsconfig.json17
-rw-r--r--vitest.config.ts10
14 files changed, 496 insertions, 309 deletions
diff --git a/.gitignore b/.gitignore
index 3b26ea2..88b84bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,11 @@
+# Files
.DS_Store
+*.lock
+package-lock.json
Thumbs.db
+# Directories
.claude
+.svelte-kit
+dist/
+node_modules/
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..51dd034
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,6 @@
+source/
+tests/
+PLAN.md
+.gitignore
+svelte.config.js
+tsconfig.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c8d9132
--- /dev/null
+++ b/README.md
@@ -0,0 +1,280 @@
+# @eeeooolll/graphiql
+
+A Svelte 5 GraphiQL alternative. CodeMirror 6 under the hood, runes-only state, SSR-safe, zero build config for SvelteKit consumers.
+
+Published on npm as [`@eeeooolll/graphiql`](https://www.npmjs.com/package/@eeeooolll/graphiql).
+
+## Install
+
+```sh
+bun add @eeeooolll/graphiql
+```
+
+Requires Svelte 5. The package ships `.svelte` sources — your SvelteKit/Vite build compiles them against your own Svelte version.
+
+## Entry points
+
+- `@eeeooolll/graphiql` — utilities, fetchers, stores, types (TS-only)
+- `@eeeooolll/graphiql/component` — the `GraphiQL` Svelte component (default export)
+- `@eeeooolll/graphiql/splitter` — the `Splitter` Svelte component (default export)
+
+Component entry points are separate from the TS API so SvelteKit/Vite can resolve `.svelte` SFCs through their dedicated bundler hooks.
+
+## Usage
+
+```svelte
+<script lang="ts">
+ import { createHttpFetcher } from "@eeeooolll/graphiql";
+ import GraphiQL from "@eeeooolll/graphiql/component";
+
+ const fetcher = createHttpFetcher({ url: "/graphql" });
+</script>
+
+<GraphiQL {fetcher}/>
+```
+
+Full prop list:
+
+| Prop | Type | Default |
+| ------------------ | ------------------------------- | -------------------- |
+| `fetcher` | `Fetcher` (required) | — |
+| `initialQuery` | `string` | `""` |
+| `namespace` | `string` | `"eol-graphiql"` |
+| `resultFooter` | `Snippet<[{ result: string }]>` | `undefined` |
+| `storage` | `Storage` | `localStorage`-based |
+| `subscriptionMode` | `"append" \| "replace"` | `"append"` |
+| `tabExtras` | `Snippet<[{ tab: Tab }]>` | `undefined` |
+| `theme` | `Extension` (CodeMirror) | `oneDark` |
+| `toolbarExtras` | `Snippet` | `undefined` |
+
+## Integration
+
+The component only needs a `Fetcher` — a function that takes `{ query, variables, operationName, headers }` and returns either a `Promise<FetcherResult>` (HTTP) or an `AsyncIterable<FetcherResult>` (SSE/WS). That's the full seam. Any GraphQL server that speaks HTTP JSON works out of the box via `createHttpFetcher`.
+
+### GraphQL Yoga
+
+```ts
+// server.ts
+import { createYoga, createSchema } from "graphql-yoga";
+
+export const yoga = createYoga({
+ schema: createSchema({
+ resolvers: { Query: { hello: () => "world" } },
+ typeDefs: /* GraphQL */ `type Query { hello: String }`
+ })
+});
+```
+
+```svelte
+<!-- +page.svelte -->
+<script lang="ts">
+ import { createHttpFetcher } from "@eeeooolll/graphiql";
+ import GraphiQL from "@eeeooolll/graphiql/component";
+
+ const fetcher = createHttpFetcher({ url: "/graphql" });
+</script>
+
+<GraphiQL {fetcher}/>
+```
+
+Yoga's `/graphql` endpoint speaks standard JSON; no adapter needed.
+
+### Apollo Server
+
+```ts
+// server.ts
+import { ApolloServer } from "@apollo/server";
+import { startStandaloneServer } from "@apollo/server/standalone";
+
+const server = new ApolloServer({ typeDefs, resolvers });
+const { url } = await startStandaloneServer(server, { listen: { port: 4000 } });
+```
+
+```ts
+const fetcher = createHttpFetcher({
+ headers: { "apollo-require-preflight": "true" },
+ url: "http://localhost:4000/"
+});
+```
+
+The `apollo-require-preflight` header satisfies Apollo's CSRF mitigation for non-browser clients; drop it if you disable that check.
+
+### `graphql-modules`
+
+`graphql-modules` builds a composed schema; you still expose it over HTTP via Yoga, Apollo, or raw `graphql-http`. The GraphiQL wiring is identical — point the fetcher at whatever endpoint you mounted.
+
+```ts
+// modules/app.ts
+import { createApplication, createModule, gql } from "graphql-modules";
+
+const userModule = createModule({
+ id: "user",
+ resolvers: { Query: { me: () => ({ id: "1", name: "Ada" }) } },
+ typeDefs: gql`type Query { me: User } type User { id: ID! name: String! }`
+});
+
+export const app = createApplication({ modules: [userModule] });
+```
+
+```ts
+// server.ts (Yoga host)
+import { createYoga } from "graphql-yoga";
+import { app } from "./modules/app.ts";
+
+export const yoga = createYoga({
+ plugins: [app.createSubscription()],
+ schema: app.createSchemaForApollo()
+});
+```
+
+```svelte
+<script lang="ts">
+ import { createHttpFetcher } from "@eeeooolll/graphiql";
+ import GraphiQL from "@eeeooolll/graphiql/component";
+
+ const fetcher = createHttpFetcher({ url: "/graphql" });
+</script>
+
+<GraphiQL {fetcher}/>
+```
+
+### Hono / Bun / Deno
+
+```ts
+// deno
+import { createHttpFetcher } from "@eeeooolll/graphiql";
+
+const fetcher = createHttpFetcher({
+ fetch: globalThis.fetch,
+ url: "https://countries.trevorblades.com/"
+});
+```
+
+The injectable `fetch` is how you plug in `undici`, a mocked fetch for tests, or a custom one that attaches auth headers.
+
+### Custom headers (auth, tenancy)
+
+Two places to set headers:
+
+- **Per-request, server-wide** — pass `headers` to `createHttpFetcher`. Applied to every request.
+- **Per-tab, user-editable** — use the Headers pane in the UI. Merged on top of fetcher-level headers.
+
+```ts
+const fetcher = createHttpFetcher({
+ headers: { authorization: `Bearer ${token}` },
+ url: "/graphql"
+});
+```
+
+### Subscriptions
+
+**SSE** (`graphql-sse` protocol):
+
+```ts
+import { createSseFetcher, createHttpFetcher } from "@eeeooolll/graphiql";
+
+const http = createHttpFetcher({ url: "/graphql" });
+const sse = createSseFetcher({ url: "/graphql/stream" });
+
+// Dispatch by operation type in a wrapper:
+const fetcher: Fetcher = (req) => /subscription\s/.test(req.query) ? sse(req) : http(req);
+```
+
+**WebSocket** (`graphql-ws` protocol):
+
+```ts
+import { createWsFetcher } from "@eeeooolll/graphiql";
+
+const ws = createWsFetcher({ url: "ws://localhost:4000/graphql" });
+```
+
+Either transport returns an `AsyncIterable<FetcherResult>`; the component handles streaming into the result pane per the `subscriptionMode` prop.
+
+### Custom fetcher
+
+Anything that matches the `Fetcher` signature works. Useful for request batching or injecting trace headers:
+
+```ts
+import type { Fetcher } from "@eeeooolll/graphiql";
+
+const traced: Fetcher = async (req) => {
+ const traceId = crypto.randomUUID();
+
+ const response = await fetch("/graphql", {
+ body: JSON.stringify(req),
+ headers: { "content-type": "application/json", "x-trace-id": traceId },
+ method: "POST"
+ });
+
+ return response.json();
+};
+```
+
+### Persisted queries (APQ)
+
+`createApqFetcher` implements the Apollo Automatic Persisted Queries protocol — first request sends only the SHA-256 hash; on `PersistedQueryNotFound` the fetcher retries with the full query and the server caches the mapping. HTTP-only.
+
+```ts
+import { createApqFetcher } from "@eeeooolll/graphiql";
+
+const fetcher = createApqFetcher({
+ url: "/graphql"
+});
+```
+
+Pass `disable: true` to bypass the two-step dance (full query on every request) for debugging. Hashes are cached per-fetcher in memory; no disk persistence.
+
+## Keyboard shortcuts
+
+| Shortcut | Action |
+| ----------------------------- | --------------- |
+| `Cmd/Ctrl + Enter` | Run query |
+| `Cmd/Ctrl + Shift + Enter` | New tab |
+| `Cmd/Ctrl + Shift + W` | Close tab |
+| `Cmd/Ctrl + Shift + F` | Format query |
+| `Cmd/Ctrl + Alt + Right/Left` | Next / prev tab |
+
+`Cmd+T` and `Cmd+W` aren't used because browsers reserve them; embedders running in Tauri/Electron can remap via `matchShortcut` (exported from the package).
+
+## Session export/import
+
+```ts
+import { validateSessionExport } from "@eeeooolll/graphiql";
+
+const json = JSON.parse(rawText);
+const result = validateSessionExport(json);
+
+if ("error" in result)
+ console.error(result.error);
+else
+ console.log(`${result.tabs.length} tabs ready to import`);
+```
+
+The History panel ships Export/Import buttons that round-trip through this validator. Import accepts version-1 exports, caps at 50 tabs, and rejects strings over 1 MB.
+
+## Theming
+
+CSS custom properties drive the chrome:
+
+- `--graphiql-accent`
+- `--graphiql-bg`
+- `--graphiql-border`
+- `--graphiql-fg`
+- `--graphiql-font`
+- `--graphiql-muted`
+- `--graphiql-panel`
+
+The editor theme is a separate CodeMirror `Extension` passed via the `theme` prop. Ships with `oneDark` (default) and `lightTheme`:
+
+```svelte
+<script lang="ts">
+ import { lightTheme } from "@eeeooolll/graphiql";
+ import GraphiQL from "@eeeooolll/graphiql/component";
+</script>
+
+<GraphiQL {fetcher} theme={lightTheme}/>
+```
+
+## License
+
+MIT
diff --git a/deno.json b/deno.json
deleted file mode 100644
index b4d79a1..0000000
--- a/deno.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "compilerOptions": {
- "lib": ["deno.window", "dom", "dom.iterable"],
- "strict": true
- },
- "exports": "./source/library/index.ts",
- "imports": {
- "@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.18.0",
- "@codemirror/commands": "npm:@codemirror/commands@^6.7.0",
- "@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1",
- "@codemirror/language": "npm:@codemirror/language@^6.10.0",
- "@codemirror/state": "npm:@codemirror/state@^6.4.0",
- "@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.0",
- "@codemirror/view": "npm:@codemirror/view@^6.34.0",
- "@lezer/highlight": "npm:@lezer/highlight@^1.2.0",
- "cm6-graphql": "npm:cm6-graphql@^0.2.1",
- "codemirror": "npm:codemirror@^6.0.1",
- "graphql": "npm:graphql@^16.8.0",
- "graphql-sse": "npm:graphql-sse@^2.5.3",
- "graphql-ws": "npm:graphql-ws@^5.16.0"
- },
- "name": "@eol/graphiql",
- "publish": {
- "include": ["source/library/**/*", "README.md", "LICENSE"]
- },
- "tasks": {
- "check": "deno check source/library/**/*.ts",
- "test": "deno test --allow-env --location=http://localhost/ tests/"
- },
- "version": "0.1.0"
-}
diff --git a/deno.lock b/deno.lock
deleted file mode 100644
index 0081354..0000000
--- a/deno.lock
+++ /dev/null
@@ -1,220 +0,0 @@
-{
- "version": "5",
- "specifiers": {
- "jsr:@std/assert@1": "1.0.19",
- "jsr:@std/internal@^1.0.12": "1.0.13",
- "npm:@codemirror/autocomplete@^6.18.0": "6.20.1",
- "npm:@codemirror/commands@^6.7.0": "6.10.3",
- "npm:@codemirror/lang-json@^6.0.1": "6.0.2",
- "npm:@codemirror/language@^6.10.0": "6.12.3",
- "npm:@codemirror/state@^6.4.0": "6.6.0",
- "npm:@codemirror/theme-one-dark@^6.1.0": "6.1.3",
- "npm:@codemirror/view@^6.34.0": "6.41.1",
- "npm:@lezer/highlight@^1.2.0": "1.2.3",
- "npm:cm6-graphql@~0.2.1": "0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2",
- "npm:codemirror@^6.0.1": "6.0.2",
- "npm:graphql-sse@^2.5.3": "2.6.0_graphql@16.13.2",
- "npm:graphql-ws@^5.16.0": "5.16.2_graphql@16.13.2",
- "npm:graphql@^16.8.0": "16.13.2"
- },
- "jsr": {
- "@std/assert@1.0.19": {
- "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
- "dependencies": [
- "jsr:@std/internal"
- ]
- },
- "@std/internal@1.0.13": {
- "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0"
- }
- },
- "npm": {
- "@codemirror/autocomplete@6.20.1": {
- "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
- "dependencies": [
- "@codemirror/language",
- "@codemirror/state",
- "@codemirror/view",
- "@lezer/common"
- ]
- },
- "@codemirror/commands@6.10.3": {
- "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
- "dependencies": [
- "@codemirror/language",
- "@codemirror/state",
- "@codemirror/view",
- "@lezer/common"
- ]
- },
- "@codemirror/lang-json@6.0.2": {
- "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
- "dependencies": [
- "@codemirror/language",
- "@lezer/json"
- ]
- },
- "@codemirror/language@6.12.3": {
- "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
- "dependencies": [
- "@codemirror/state",
- "@codemirror/view",
- "@lezer/common",
- "@lezer/highlight",
- "@lezer/lr",
- "style-mod"
- ]
- },
- "@codemirror/lint@6.9.5": {
- "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
- "dependencies": [
- "@codemirror/state",
- "@codemirror/view",
- "crelt"
- ]
- },
- "@codemirror/search@6.7.0": {
- "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
- "dependencies": [
- "@codemirror/state",
- "@codemirror/view",
- "crelt"
- ]
- },
- "@codemirror/state@6.6.0": {
- "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
- "dependencies": [
- "@marijn/find-cluster-break"
- ]
- },
- "@codemirror/theme-one-dark@6.1.3": {
- "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
- "dependencies": [
- "@codemirror/language",
- "@codemirror/state",
- "@codemirror/view",
- "@lezer/highlight"
- ]
- },
- "@codemirror/view@6.41.1": {
- "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==",
- "dependencies": [
- "@codemirror/state",
- "crelt",
- "style-mod",
- "w3c-keyname"
- ]
- },
- "@lezer/common@1.5.2": {
- "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="
- },
- "@lezer/highlight@1.2.3": {
- "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
- "dependencies": [
- "@lezer/common"
- ]
- },
- "@lezer/json@1.0.3": {
- "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
- "dependencies": [
- "@lezer/common",
- "@lezer/highlight",
- "@lezer/lr"
- ]
- },
- "@lezer/lr@1.4.10": {
- "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
- "dependencies": [
- "@lezer/common"
- ]
- },
- "@marijn/find-cluster-break@1.0.2": {
- "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
- },
- "cm6-graphql@0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2": {
- "integrity": "sha512-FIAFHn6qyiXChTz3Pml0NgTM8LyyXs8QfP2iPG7MLA8Xi83WuVlkGG5PDs+DDeEVabHkLIZmcyNngQlxLXKk6A==",
- "dependencies": [
- "@codemirror/autocomplete",
- "@codemirror/language",
- "@codemirror/lint",
- "@codemirror/state",
- "@codemirror/view",
- "@lezer/highlight",
- "graphql",
- "graphql-language-service"
- ]
- },
- "codemirror@6.0.2": {
- "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
- "dependencies": [
- "@codemirror/autocomplete",
- "@codemirror/commands",
- "@codemirror/language",
- "@codemirror/lint",
- "@codemirror/search",
- "@codemirror/state",
- "@codemirror/view"
- ]
- },
- "crelt@1.0.6": {
- "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
- },
- "debounce-promise@3.1.2": {
- "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg=="
- },
- "graphql-language-service@5.5.0_graphql@16.13.2": {
- "integrity": "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q==",
- "dependencies": [
- "debounce-promise",
- "graphql",
- "nullthrows",
- "vscode-languageserver-types"
- ],
- "bin": true
- },
- "graphql-sse@2.6.0_graphql@16.13.2": {
- "integrity": "sha512-BXT5Rjv9UFunjQsmN9WWEIq+TFNhgYibgwo1xkXLxzguQVyOd6paJ4v5DlL9K5QplS0w74bhF+aUiqaGXZBaug==",
- "dependencies": [
- "graphql"
- ]
- },
- "graphql-ws@5.16.2_graphql@16.13.2": {
- "integrity": "sha512-E1uccsZxt/96jH/OwmLPuXMACILs76pKF2i3W861LpKBCYtGIyPQGtWLuBLkND4ox1KHns70e83PS4te50nvPQ==",
- "dependencies": [
- "graphql"
- ]
- },
- "graphql@16.13.2": {
- "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="
- },
- "nullthrows@1.1.1": {
- "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw=="
- },
- "style-mod@4.1.3": {
- "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="
- },
- "vscode-languageserver-types@3.17.5": {
- "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="
- },
- "w3c-keyname@2.2.8": {
- "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
- }
- },
- "workspace": {
- "dependencies": [
- "npm:@codemirror/autocomplete@^6.18.0",
- "npm:@codemirror/commands@^6.7.0",
- "npm:@codemirror/lang-json@^6.0.1",
- "npm:@codemirror/language@^6.10.0",
- "npm:@codemirror/state@^6.4.0",
- "npm:@codemirror/theme-one-dark@^6.1.0",
- "npm:@codemirror/view@^6.34.0",
- "npm:@lezer/highlight@^1.2.0",
- "npm:cm6-graphql@~0.2.1",
- "npm:codemirror@^6.0.1",
- "npm:graphql-sse@^2.5.3",
- "npm:graphql-ws@^5.16.0",
- "npm:graphql@^16.8.0"
- ]
- }
-}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8621132
--- /dev/null
+++ b/package.json
@@ -0,0 +1,62 @@
+{
+ "dependencies": {
+ "@codemirror/autocomplete": "^6.18.0",
+ "@codemirror/commands": "^6.7.0",
+ "@codemirror/lang-json": "^6.0.1",
+ "@codemirror/language": "^6.10.0",
+ "@codemirror/state": "^6.4.0",
+ "@codemirror/theme-one-dark": "^6.1.0",
+ "@codemirror/view": "^6.34.0",
+ "@lezer/highlight": "^1.2.0",
+ "cm6-graphql": "^0.2.1",
+ "codemirror": "^6.0.1",
+ "graphql": "^16.8.0",
+ "graphql-sse": "^2.5.3",
+ "graphql-ws": "^5.16.0"
+ },
+ "description": "A Svelte 5 GraphiQL alternative.",
+ "devDependencies": {
+ "@sveltejs/package": "^2.3.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "sass-embedded": "^1.79.0",
+ "svelte": "^5.0.0",
+ "svelte-check": "^4.0.0",
+ "typescript": "^5.5.0",
+ "vite": "^5.4.0",
+ "vitest": "^2.1.0"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./component": {
+ "types": "./dist/GraphiQL.svelte.d.ts",
+ "svelte": "./dist/GraphiQL.svelte",
+ "default": "./dist/GraphiQL.svelte"
+ },
+ "./splitter": {
+ "types": "./dist/components/Splitter.svelte.d.ts",
+ "svelte": "./dist/components/Splitter.svelte",
+ "default": "./dist/components/Splitter.svelte"
+ }
+ },
+ "files": ["dist"],
+ "license": "MIT",
+ "name": "@eeeooolll/graphiql",
+ "peerDependencies": {
+ "svelte": "^5.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "scripts": {
+ "check": "svelte-check --tsconfig tsconfig.json",
+ "package": "svelte-package -i source/library -o dist",
+ "prepublishOnly": "bun run package",
+ "publish": "bun run package && bun publish",
+ "test": "vitest run"
+ },
+ "type": "module",
+ "version": "0.1.0"
+}
diff --git a/source/library/components/Editor.svelte b/source/library/components/Editor.svelte
index f2bf82d..393812a 100644
--- a/source/library/components/Editor.svelte
+++ b/source/library/components/Editor.svelte
@@ -1,7 +1,6 @@
<script lang="ts">
/*** IMPORT ------------------------------------------- ***/
- import { browser } from "$app/environment";
import { onMount } from "svelte";
import type { Extension } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
@@ -33,7 +32,7 @@
let view = $state<EditorView | null>(null);
onMount(() => {
- if (!browser)
+ if (typeof globalThis.document === "undefined")
return;
let disposed = false;
diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte
index 9c34f20..0bfec02 100644
--- a/source/library/components/TabBar.svelte
+++ b/source/library/components/TabBar.svelte
@@ -130,13 +130,22 @@
}
</style>
-<div class="tabbar">
+<div class="tabbar" role="tablist">
{#each tabs as tab (tab.id)}
- <button
+ <div
+ aria-selected={tab.id === activeId}
class="tab"
class:active={tab.id === activeId}
ondblclick={() => startEditing(tab)}
onclick={() => onSelect(tab.id)}
+ onkeydown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect(tab.id);
+ }
+ }}
+ role="tab"
+ tabindex="0"
>
{#if editingId === tab.id}
<input
@@ -157,7 +166,7 @@
class="close"
onclick={(e) => handleClose(e, tab.id)}
>×</button>
- </button>
+ </div>
{/each}
<button aria-label="New tab" class="add" onclick={onAdd}>+</button>
</div>
diff --git a/svelte.config.js b/svelte.config.js
new file mode 100644
index 0000000..c56f5d3
--- /dev/null
+++ b/svelte.config.js
@@ -0,0 +1,5 @@
+import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
+
+export default {
+ preprocess: vitePreprocess()
+};
diff --git a/tests/history.test.ts b/tests/history.test.ts
index ecd7785..a08c014 100644
--- a/tests/history.test.ts
+++ b/tests/history.test.ts
@@ -1,9 +1,10 @@
+
/*** IMPORT ------------------------------------------- ***/
-import { assertEquals } from "jsr:@std/assert@^1.0.0";
+import { expect, test } from "vitest";
/*** UTILITY ------------------------------------------ ***/
@@ -21,12 +22,12 @@ function entry(id: string, timestamp: number, favorite = false): Entry {
/*** TESTS -------------------------------------------- ***/
-Deno.test("evict keeps everything when under cap", () => {
+test("evict keeps everything when under cap", () => {
const entries = [entry("a", 3), entry("b", 2), entry("c", 1)];
- assertEquals(evict(entries, 5), entries);
+ expect(evict(entries, 5)).toEqual(entries);
});
-Deno.test("evict drops the oldest non-favorites above cap", () => {
+test("evict drops the oldest non-favorites above cap", () => {
const entries = [
entry("a", 5),
entry("b", 4),
@@ -35,10 +36,10 @@ Deno.test("evict drops the oldest non-favorites above cap", () => {
entry("e", 1)
];
const kept = evict(entries, 3);
- assertEquals(kept.map((e) => e.id), ["a", "b", "c"]);
+ expect(kept.map((e) => e.id)).toEqual(["a", "b", "c"]);
});
-Deno.test("evict never drops favorites", () => {
+test("evict never drops favorites", () => {
const entries = [
entry("a", 10),
entry("b", 9),
@@ -48,11 +49,11 @@ Deno.test("evict never drops favorites", () => {
];
const kept = evict(entries, 3);
- assertEquals(kept.some((e) => e.id === "fav-old"), true);
- assertEquals(kept.length, 3);
+ expect(kept.some((e) => e.id === "fav-old")).toEqual(true);
+ expect(kept.length).toEqual(3);
});
-Deno.test("evict can exceed cap when favorites alone do so", () => {
+test("evict can exceed cap when favorites alone do so", () => {
const entries = [
entry("fav-1", 5, true),
entry("fav-2", 4, true),
@@ -61,11 +62,11 @@ Deno.test("evict can exceed cap when favorites alone do so", () => {
];
const kept = evict(entries, 2);
- assertEquals(kept.length, 3);
- assertEquals(kept.every((e) => e.favorite), true);
+ expect(kept.length).toEqual(3);
+ expect(kept.every((e) => e.favorite)).toEqual(true);
});
-Deno.test("evict sorts by timestamp descending", () => {
+test("evict sorts by timestamp descending", () => {
const entries = [
entry("c", 1),
entry("a", 3),
@@ -73,5 +74,5 @@ Deno.test("evict sorts by timestamp descending", () => {
entry("d", 0)
];
const kept = evict(entries, 3);
- assertEquals(kept.map((e) => e.id), ["a", "b", "c"]);
+ expect(kept.map((e) => e.id)).toEqual(["a", "b", "c"]);
});
diff --git a/tests/operations.test.ts b/tests/operations.test.ts
index 99357ea..14fe768 100644
--- a/tests/operations.test.ts
+++ b/tests/operations.test.ts
@@ -1,9 +1,10 @@
+
/*** IMPORT ------------------------------------------- ***/
-import { assertEquals } from "jsr:@std/assert@^1.0.0";
+import { expect, test } from "vitest";
/*** UTILITY ------------------------------------------ ***/
@@ -11,54 +12,54 @@ import { deriveTitle, parseOperations } from "../source/library/graphql/operatio
/*** TESTS -------------------------------------------- ***/
-Deno.test("parseOperations returns empty for blank query", () => {
- assertEquals(parseOperations(""), []);
- assertEquals(parseOperations(" "), []);
+test("parseOperations returns empty for blank query", () => {
+ expect(parseOperations("")).toEqual([]);
+ expect(parseOperations(" ")).toEqual([]);
});
-Deno.test("parseOperations returns empty on syntax error", () => {
- assertEquals(parseOperations("query { ..."), []);
+test("parseOperations returns empty on syntax error", () => {
+ expect(parseOperations("query { ...")).toEqual([]);
});
-Deno.test("parseOperations captures a single named query", () => {
+test("parseOperations captures a single named query", () => {
const ops = parseOperations("query Foo { viewer { id } }");
- assertEquals(ops, [{ name: "Foo", type: "query" }]);
+ expect(ops).toEqual([{ name: "Foo", type: "query" }]);
});
-Deno.test("parseOperations returns null name for anonymous ops", () => {
+test("parseOperations returns null name for anonymous ops", () => {
const ops = parseOperations("{ viewer { id } }");
- assertEquals(ops, [{ name: null, type: "query" }]);
+ expect(ops).toEqual([{ name: null, type: "query" }]);
});
-Deno.test("parseOperations captures multiple operations", () => {
+test("parseOperations captures multiple operations", () => {
const ops = parseOperations(`
query Foo { a }
mutation Bar { b }
subscription Baz { c }
`);
- assertEquals(ops, [
+ expect(ops).toEqual([
{ name: "Foo", type: "query" },
{ name: "Bar", type: "mutation" },
{ name: "Baz", type: "subscription" }
]);
});
-Deno.test("deriveTitle prefers the first operation name", () => {
+test("deriveTitle prefers the first operation name", () => {
const ops = parseOperations("query Foo { a }");
- assertEquals(deriveTitle("query Foo { a }", ops), "Foo");
+ expect(deriveTitle("query Foo { a }", ops)).toEqual("Foo");
});
-Deno.test("deriveTitle falls back to operation type", () => {
+test("deriveTitle falls back to operation type", () => {
const ops = parseOperations("mutation { a }");
- assertEquals(deriveTitle("mutation { a }", ops), "mutation");
+ expect(deriveTitle("mutation { a }", ops)).toEqual("mutation");
});
-Deno.test("deriveTitle falls back to the first 20 chars when unparsable", () => {
+test("deriveTitle falls back to the first 20 chars when unparsable", () => {
const query = "this is not valid graphql at all";
- assertEquals(deriveTitle(query, parseOperations(query)), "this is not valid gr");
+ expect(deriveTitle(query, parseOperations(query))).toEqual("this is not valid gr");
});
-Deno.test("deriveTitle returns 'untitled' for empty input", () => {
- assertEquals(deriveTitle("", []), "untitled");
- assertEquals(deriveTitle(" ", []), "untitled");
+test("deriveTitle returns 'untitled' for empty input", () => {
+ expect(deriveTitle("", [])).toEqual("untitled");
+ expect(deriveTitle(" ", [])).toEqual("untitled");
});
diff --git a/tests/storage.test.ts b/tests/storage.test.ts
index 7d6ba73..434a67d 100644
--- a/tests/storage.test.ts
+++ b/tests/storage.test.ts
@@ -1,9 +1,10 @@
+
/*** IMPORT ------------------------------------------- ***/
-import { assertEquals } from "jsr:@std/assert@^1.0.0";
+import { beforeAll, expect, test } from "vitest";
/*** UTILITY ------------------------------------------ ***/
@@ -12,34 +13,74 @@ import {
createMemoryStorage
} from "../source/library/state/storage.ts";
+/*** HELPERS ------------------------------------------ ***/
+
+function installLocalStorage(): void {
+ if (typeof globalThis.localStorage !== "undefined")
+ return;
+
+ const store = new Map<string, string>();
+
+ const shim: Storage = {
+ clear(): void {
+ store.clear();
+ },
+ getItem(key: string): string | null {
+ return store.has(key) ? store.get(key) ?? null : null;
+ },
+ key(index: number): string | null {
+ return Array.from(store.keys())[index] ?? null;
+ },
+ get length(): number {
+ return store.size;
+ },
+ removeItem(key: string): void {
+ store.delete(key);
+ },
+ setItem(key: string, value: string): void {
+ store.set(key, String(value));
+ }
+ };
+
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: shim,
+ writable: true
+ });
+}
+
+beforeAll(() => {
+ installLocalStorage();
+});
+
/*** TESTS -------------------------------------------- ***/
-Deno.test("memory storage round-trips objects", () => {
+test("memory storage round-trips objects", () => {
const storage = createMemoryStorage();
storage.set("k", { hello: "world" });
- assertEquals(storage.get<{ hello: string }>("k"), { hello: "world" });
+ expect(storage.get<{ hello: string }>("k")).toEqual({ hello: "world" });
});
-Deno.test("memory storage returns null for missing keys", () => {
+test("memory storage returns null for missing keys", () => {
const storage = createMemoryStorage();
- assertEquals(storage.get("missing"), null);
+ expect(storage.get("missing")).toEqual(null);
});
-Deno.test("memory storage remove clears a key", () => {
+test("memory storage remove clears a key", () => {
const storage = createMemoryStorage();
storage.set("k", 42);
storage.remove("k");
- assertEquals(storage.get("k"), null);
+ expect(storage.get("k")).toEqual(null);
});
-Deno.test("memory storage instances are isolated", () => {
+test("memory storage instances are isolated", () => {
const a = createMemoryStorage();
const b = createMemoryStorage();
a.set("shared", 1);
- assertEquals(b.get("shared"), null);
+ expect(b.get("shared")).toEqual(null);
});
-Deno.test("local storage namespaces keys", () => {
+test("local storage namespaces keys", () => {
globalThis.localStorage.clear();
const alpha = createLocalStorage("alpha");
@@ -48,15 +89,15 @@ Deno.test("local storage namespaces keys", () => {
alpha.set("shared", { tag: "a" });
beta.set("shared", { tag: "b" });
- assertEquals(alpha.get<{ tag: string }>("shared"), { tag: "a" });
- assertEquals(beta.get<{ tag: string }>("shared"), { tag: "b" });
- assertEquals(globalThis.localStorage.getItem("alpha:shared"), JSON.stringify({ tag: "a" }));
- assertEquals(globalThis.localStorage.getItem("beta:shared"), JSON.stringify({ tag: "b" }));
+ expect(alpha.get<{ tag: string }>("shared")).toEqual({ tag: "a" });
+ expect(beta.get<{ tag: string }>("shared")).toEqual({ tag: "b" });
+ expect(globalThis.localStorage.getItem("alpha:shared")).toEqual(JSON.stringify({ tag: "a" }));
+ expect(globalThis.localStorage.getItem("beta:shared")).toEqual(JSON.stringify({ tag: "b" }));
globalThis.localStorage.clear();
});
-Deno.test("local storage remove respects the namespace", () => {
+test("local storage remove respects the namespace", () => {
globalThis.localStorage.clear();
const alpha = createLocalStorage("alpha");
@@ -66,18 +107,18 @@ Deno.test("local storage remove respects the namespace", () => {
beta.set("k", 2);
alpha.remove("k");
- assertEquals(alpha.get("k"), null);
- assertEquals(beta.get<number>("k"), 2);
+ expect(alpha.get("k")).toEqual(null);
+ expect(beta.get<number>("k")).toEqual(2);
globalThis.localStorage.clear();
});
-Deno.test("local storage returns null on malformed JSON", () => {
+test("local storage returns null on malformed JSON", () => {
globalThis.localStorage.clear();
globalThis.localStorage.setItem("alpha:bad", "not-json");
const alpha = createLocalStorage("alpha");
- assertEquals(alpha.get("bad"), null);
+ expect(alpha.get("bad")).toEqual(null);
globalThis.localStorage.clear();
});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..6bed1c6
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["DOM", "DOM.Iterable", "ES2022"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "target": "ES2022",
+ "verbatimModuleSyntax": true
+ },
+ "include": ["source/library/**/*.ts", "source/library/**/*.svelte"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..c24a061
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,10 @@
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ plugins: [svelte({ hot: false })],
+ test: {
+ environment: "node",
+ include: ["tests/**/*.test.ts"]
+ }
+});