aboutsummaryrefslogtreecommitdiff
path: root/source/library/components/DocExplorer
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--source/library/components/DocExplorer.svelte226
-rw-r--r--source/library/components/DocExplorer/FieldView.svelte87
-rw-r--r--source/library/components/DocExplorer/TypeLink.svelte42
-rw-r--r--source/library/components/DocExplorer/TypeView.svelte199
4 files changed, 554 insertions, 0 deletions
diff --git a/source/library/components/DocExplorer.svelte b/source/library/components/DocExplorer.svelte
new file mode 100644
index 0000000..536cb2a
--- /dev/null
+++ b/source/library/components/DocExplorer.svelte
@@ -0,0 +1,226 @@
+<script lang="ts">
+ import FieldView from "./DocExplorer/FieldView.svelte";
+ import TypeView from "./DocExplorer/TypeView.svelte";
+ import {
+ isInputObjectType,
+ isInterfaceType,
+ isObjectType
+ } from "graphql";
+ import type {
+ GraphQLField,
+ GraphQLInputField,
+ GraphQLNamedType,
+ GraphQLSchema
+ } from "graphql";
+
+ type NavEntry =
+ | { kind: "field"; fieldName: string; typeName: string }
+ | { kind: "type"; name: string };
+
+ type Props = {
+ schema: GraphQLSchema;
+ };
+
+ let { schema }: Props = $props();
+
+ let stack = $state<NavEntry[]>([]);
+
+ const current = $derived<NavEntry | null>(stack.length > 0 ? stack[stack.length - 1] : null);
+
+ const rootTypes = $derived.by(() => {
+ const out: { label: string; type: GraphQLNamedType }[] = [];
+ const q = schema.getQueryType();
+ const m = schema.getMutationType();
+ const s = schema.getSubscriptionType();
+
+ if (q)
+ out.push({ label: "Query", type: q });
+
+ if (m)
+ out.push({ label: "Mutation", type: m });
+
+ if (s)
+ out.push({ label: "Subscription", type: s });
+
+ return out;
+ });
+
+ const currentType = $derived.by<GraphQLNamedType | null>(() => {
+ if (!current)
+ return null;
+
+ const name = current.kind === "type" ? current.name : current.typeName;
+ return schema.getType(name) ?? null;
+ });
+
+ const currentField = $derived.by<GraphQLField<unknown, unknown> | GraphQLInputField | null>(() => {
+ if (!current || current.kind !== "field" || !currentType)
+ return null;
+
+ if (isObjectType(currentType) || isInterfaceType(currentType) || isInputObjectType(currentType))
+ return currentType.getFields()[current.fieldName] ?? null;
+
+ return null;
+ });
+
+ function crumbLabel(entry: NavEntry): string {
+ return entry.kind === "type" ? entry.name : entry.fieldName;
+ }
+
+ function gotoRoot() {
+ stack = [];
+ }
+
+ function gotoIndex(index: number) {
+ stack = stack.slice(0, index + 1);
+ }
+
+ function pushType(name: string) {
+ if (!schema.getType(name))
+ return;
+
+ stack = [...stack, { kind: "type", name }];
+ }
+
+ function pushField(fieldName: string) {
+ const typeName = current?.kind === "type" ? current.name : null;
+
+ if (!typeName)
+ return;
+
+ stack = [...stack, { fieldName, kind: "field", typeName }];
+ }
+</script>
+
+<style lang="scss">
+ .explorer {
+ background: var(--graphiql-panel, #252526);
+ border-left: 1px solid var(--graphiql-border, #333);
+ display: grid;
+ grid-template-rows: auto 1fr;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .breadcrumbs {
+ align-items: center;
+ border-bottom: 1px solid var(--graphiql-border, #333);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.8125rem;
+ gap: 0.25rem;
+ padding: 0.5rem 0.75rem;
+ }
+
+ .crumb {
+ background: none;
+ border: none;
+ color: var(--graphiql-link, #79b8ff);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ &.current {
+ color: var(--graphiql-fg, #d4d4d4);
+ cursor: default;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .separator {
+ color: var(--graphiql-muted, #858585);
+ }
+
+ .body {
+ min-height: 0;
+ overflow-y: auto;
+ }
+
+ .root {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ }
+
+ .section-label {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ }
+
+ .root-list {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ .root-link {
+ background: none;
+ border: none;
+ color: var(--graphiql-link, #79b8ff);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 0.875rem;
+ padding: 0;
+ text-align: left;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .empty {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ padding: 0.75rem 1rem;
+ }
+</style>
+
+<div class="explorer">
+ <div class="breadcrumbs">
+ <button
+ class="crumb"
+ class:current={stack.length === 0}
+ onclick={gotoRoot}>Docs</button>
+ {#each stack as entry, i}
+ <span class="separator">/</span>
+ <button
+ class="crumb"
+ class:current={i === stack.length - 1}
+ onclick={() => gotoIndex(i)}>{crumbLabel(entry)}</button>
+ {/each}
+ </div>
+ <div class="body">
+ {#if stack.length === 0}
+ <div class="root">
+ <div class="section-label">Root Types</div>
+ <div class="root-list">
+ {#each rootTypes as entry}
+ <button class="root-link" onclick={() => pushType(entry.type.name)}>
+ {entry.label}: {entry.type.name}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {:else if currentField}
+ <FieldView field={currentField} onNavigate={pushType}/>
+ {:else if currentType}
+ <TypeView
+ onNavigateField={pushField}
+ onNavigateType={pushType}
+ type={currentType}/>
+ {:else}
+ <div class="empty">Type not found in schema.</div>
+ {/if}
+ </div>
+</div>
diff --git a/source/library/components/DocExplorer/FieldView.svelte b/source/library/components/DocExplorer/FieldView.svelte
new file mode 100644
index 0000000..71d215c
--- /dev/null
+++ b/source/library/components/DocExplorer/FieldView.svelte
@@ -0,0 +1,87 @@
+<script lang="ts">
+ import TypeLink from "./TypeLink.svelte";
+ import type { GraphQLField, GraphQLInputField } from "graphql";
+
+ type Props = {
+ field: GraphQLField<unknown, unknown> | GraphQLInputField;
+ onNavigate: (typeName: string) => void;
+ };
+
+ let { field, onNavigate }: Props = $props();
+
+ const args = $derived("args" in field ? field.args : []);
+</script>
+
+<style lang="scss">
+ .field {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ }
+
+ .heading {
+ font-size: 0.95rem;
+ font-weight: 600;
+ }
+
+ .section-label {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ }
+
+ .description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ }
+
+ .args {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ .arg {
+ font-size: 0.8125rem;
+ }
+
+ .arg-name {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+
+ .arg-description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.75rem;
+ margin-left: 1rem;
+ margin-top: 0.125rem;
+ }
+</style>
+
+<div class="field">
+ <div class="heading">{field.name}</div>
+ {#if field.description}
+ <div class="description">{field.description}</div>
+ {/if}
+ <div>
+ <div class="section-label">Type</div>
+ <TypeLink {onNavigate} type={field.type}/>
+ </div>
+ {#if args.length > 0}
+ <div>
+ <div class="section-label">Arguments</div>
+ <div class="args">
+ {#each args as arg}
+ <div class="arg">
+ <span class="arg-name">{arg.name}</span>:
+ <TypeLink {onNavigate} type={arg.type}/>
+ {#if arg.description}
+ <div class="arg-description">{arg.description}</div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/source/library/components/DocExplorer/TypeLink.svelte b/source/library/components/DocExplorer/TypeLink.svelte
new file mode 100644
index 0000000..253d16e
--- /dev/null
+++ b/source/library/components/DocExplorer/TypeLink.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { getNamedType, isListType, isNonNullType } from "graphql";
+ import type { GraphQLType } from "graphql";
+
+ type Props = {
+ onNavigate: (typeName: string) => void;
+ type: GraphQLType;
+ };
+
+ let { onNavigate, type }: Props = $props();
+
+ function label(t: GraphQLType): string {
+ if (isNonNullType(t))
+ return `${label(t.ofType)}!`;
+
+ if (isListType(t))
+ return `[${label(t.ofType)}]`;
+
+ return t.name;
+ }
+
+ const named = $derived(getNamedType(type));
+ const text = $derived(label(type));
+</script>
+
+<style lang="scss">
+ .link {
+ background: none;
+ border: none;
+ color: var(--graphiql-link, #79b8ff);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+</style>
+
+<button class="link" onclick={() => onNavigate(named.name)}>{text}</button>
diff --git a/source/library/components/DocExplorer/TypeView.svelte b/source/library/components/DocExplorer/TypeView.svelte
new file mode 100644
index 0000000..31a1ca3
--- /dev/null
+++ b/source/library/components/DocExplorer/TypeView.svelte
@@ -0,0 +1,199 @@
+<script lang="ts">
+ import TypeLink from "./TypeLink.svelte";
+ import {
+ isEnumType,
+ isInputObjectType,
+ isInterfaceType,
+ isObjectType,
+ isScalarType,
+ isUnionType
+ } from "graphql";
+ import type { GraphQLNamedType } from "graphql";
+
+ type Props = {
+ onNavigateField: (fieldName: string) => void;
+ onNavigateType: (typeName: string) => void;
+ type: GraphQLNamedType;
+ };
+
+ let { onNavigateField, onNavigateType, type }: Props = $props();
+
+ const fields = $derived.by(() => {
+ if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))
+ return Object.values(type.getFields());
+
+ return [];
+ });
+
+ const interfaces = $derived.by(() => {
+ if (isObjectType(type) || isInterfaceType(type))
+ return type.getInterfaces();
+
+ return [];
+ });
+
+ const unionMembers = $derived.by(() => {
+ if (isUnionType(type))
+ return type.getTypes();
+
+ return [];
+ });
+
+ const enumValues = $derived.by(() => {
+ if (isEnumType(type))
+ return type.getValues();
+
+ return [];
+ });
+
+ const kindLabel = $derived.by(() => {
+ if (isObjectType(type))
+ return "type";
+
+ if (isInterfaceType(type))
+ return "interface";
+
+ if (isUnionType(type))
+ return "union";
+
+ if (isEnumType(type))
+ return "enum";
+
+ if (isInputObjectType(type))
+ return "input";
+
+ if (isScalarType(type))
+ return "scalar";
+
+ return "";
+ });
+</script>
+
+<style lang="scss">
+ .type {
+ display: grid;
+ gap: 0.75rem;
+ padding: 0.75rem 1rem;
+ }
+
+ .heading {
+ font-size: 0.95rem;
+ font-weight: 600;
+ }
+
+ .kind {
+ color: var(--graphiql-muted, #858585);
+ font-weight: normal;
+ margin-right: 0.375rem;
+ }
+
+ .description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ }
+
+ .section-label {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.7rem;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.25rem;
+ text-transform: uppercase;
+ }
+
+ .list {
+ display: grid;
+ gap: 0.375rem;
+ }
+
+ .entry {
+ font-size: 0.8125rem;
+ }
+
+ .field-button {
+ background: none;
+ border: none;
+ color: var(--graphiql-fg, #d4d4d4);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: inherit;
+ padding: 0;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .entry-description {
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.75rem;
+ margin-left: 1rem;
+ margin-top: 0.125rem;
+ }
+</style>
+
+<div class="type">
+ <div class="heading">
+ {#if kindLabel}<span class="kind">{kindLabel}</span>{/if}{type.name}
+ </div>
+ {#if type.description}
+ <div class="description">{type.description}</div>
+ {/if}
+ {#if interfaces.length > 0}
+ <div>
+ <div class="section-label">Implements</div>
+ <div class="list">
+ {#each interfaces as iface}
+ <div class="entry">
+ <TypeLink onNavigate={onNavigateType} type={iface}/>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {#if fields.length > 0}
+ <div>
+ <div class="section-label">Fields</div>
+ <div class="list">
+ {#each fields as field}
+ <div class="entry">
+ <button
+ class="field-button"
+ onclick={() => onNavigateField(field.name)}>{field.name}</button>:
+ <TypeLink onNavigate={onNavigateType} type={field.type}/>
+ {#if field.description}
+ <div class="entry-description">{field.description}</div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {#if unionMembers.length > 0}
+ <div>
+ <div class="section-label">Members</div>
+ <div class="list">
+ {#each unionMembers as member}
+ <div class="entry">
+ <TypeLink onNavigate={onNavigateType} type={member}/>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ {#if enumValues.length > 0}
+ <div>
+ <div class="section-label">Values</div>
+ <div class="list">
+ {#each enumValues as value}
+ <div class="entry">
+ <span>{value.name}</span>
+ {#if value.description}
+ <div class="entry-description">{value.description}</div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>