diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-24 16:37:49 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-24 16:37:49 -0700 |
| commit | c013ed57bf3d7dd83bd59a9b984d87aebde6003c (patch) | |
| tree | 31d25456496f013f13f3cae1ded376d5323b3200 | |
| parent | 510fd8cbe53abb39cba2c7cbaaefcf2783dc0066 (diff) | |
| download | graphiql-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-- | .gitignore | 7 | ||||
| -rw-r--r-- | .npmignore | 6 | ||||
| -rw-r--r-- | README.md | 280 | ||||
| -rw-r--r-- | deno.json | 31 | ||||
| -rw-r--r-- | deno.lock | 220 | ||||
| -rw-r--r-- | package.json | 62 | ||||
| -rw-r--r-- | source/library/components/Editor.svelte | 3 | ||||
| -rw-r--r-- | source/library/components/TabBar.svelte | 15 | ||||
| -rw-r--r-- | svelte.config.js | 5 | ||||
| -rw-r--r-- | tests/history.test.ts | 27 | ||||
| -rw-r--r-- | tests/operations.test.ts | 43 | ||||
| -rw-r--r-- | tests/storage.test.ts | 79 | ||||
| -rw-r--r-- | tsconfig.json | 17 | ||||
| -rw-r--r-- | vitest.config.ts | 10 |
14 files changed, 496 insertions, 309 deletions
@@ -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"] + } +}); |