Public: anyone can read this; the crew can change it.
A GraphQL schema (`.graphql`, `.gql`) opens as its types: each type, input and enum with its fields and their types #11287
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/11224-a-graphql-schema-graphql-gql-opens-as-it → main 5 commits fork 3 threads · 3 unresolved
8 files
changed +491
−0
docs/features/file-graphql.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # A GraphQL schema opens as its types | |
| 2 | ||
| 3 | `.graphql` and `.gql` files open as blocks instead of raw text. Source is one tap away (`?view=source`). | |
| 4 | ||
| 5 | - **Blocks:** one per `type`, `input`, `interface` and `enum` (and `extend` forms); each field is a row with its arguments, type and directives. `Query`, `Mutation` and `Subscription` come first. | |
| 6 | - **Anything else** (`union`, `scalar`, `schema`, `directive`, operations, a body the reader cannot parse) is one plain row with its text, never an error. | |
| 7 | - **Fallback:** a file with no type, input, interface or enum shows as text with one line saying why. | |
| 8 | - **Cap:** 500 blocks and 500 fields per block, then `more not shown`. Files over 1 MB open as text. | |
| 9 | - Every name is escaped, drawn on the server; no script, no dependency. | |
| 10 | ||
| 11 | Code: `src/web/render/graphql.ts`, `src/web/render/kinds.ts`. Test: `test/render-graphql.test.ts`. |
src/tui/screens/workspace.ts +31
| @@ -63,6 +63,7 @@ import { CASE_CAP, parseJunit } from "../../web/render/junit.ts"; | ||
| 63 | 63 | import { parseOpenApi } from "../../web/render/openapi.ts"; |
| 64 | 64 | import { parseDockerfile } from "../../web/render/dockerfile.ts"; |
| 65 | 65 | import { parseWorkflow } from "../../web/render/workflow.ts"; |
| 66 | import { parseGraphql } from "../../web/render/graphql.ts"; | |
| 66 | 67 | import { parseSql } from "../../web/render/sql.ts"; |
| 67 | 68 | import { RESULT_CAP, parseSarif } from "../../web/render/sarif.ts"; |
| 68 | 69 | import { RECORD_CAP, parseLcov, worstFirst, type Ratio } from "../../web/render/lcov.ts"; |
| @@ -536,6 +537,31 @@ function sqlTable(source: string): Table | string { | ||
| 536 | 537 | return { header: ["Table", "Column", "Type", "Null", "Key", "Default"], rows, more: doc.total - doc.statements.length, delimiter: ",", columns: 6 }; |
| 537 | 538 | } |
| 538 | 539 | |
| 540 | /** A `.graphql` or `.gql` file by name. */ | |
| 541 | function isGraphqlPath(path: string): boolean { | |
| 542 | return kindByName(path) === "graphql"; | |
| 543 | } | |
| 544 | ||
| 545 | /** | |
| 546 | * A GraphQL schema as a table: a row per field or enum value of each block (roots first), one row | |
| 547 | * per other definition; the reason when it is not drawn. Shares the web's parser. | |
| 548 | */ | |
| 549 | function graphqlTable(source: string): Table | string { | |
| 550 | const doc = parseGraphql(source); | |
| 551 | if (doc === null) return "not drawn as a schema: this file holds no type, input, interface or enum to draw"; | |
| 552 | const dash = (v: string): string => (v === "" ? "-" : v); | |
| 553 | const rows = doc.blocks.flatMap((b) => | |
| 554 | b.kind === "other" | |
| 555 | ? [["-", b.text, "-", "-", "-"]] | |
| 556 | : [ | |
| 557 | ...(b.header === "" ? [] : [[`${b.extend ? "extend " : ""}${b.kind} ${b.name}`, "-", "-", "-", b.header]]), | |
| 558 | ...b.fields.map((f) => [`${b.extend ? "extend " : ""}${b.kind} ${b.name}`, f.name, dash(f.args), dash(f.type), dash(f.note)]), | |
| 559 | ...(b.fields.length === 0 && b.header === "" ? [[`${b.kind} ${b.name}`, "-", "-", "-", "-"]] : []), | |
| 560 | ], | |
| 561 | ); | |
| 562 | return { header: ["Block", "Field", "Arguments", "Type", "Directives"], rows, more: doc.total - doc.blocks.length, delimiter: ",", columns: 5 }; | |
| 563 | } | |
| 564 | ||
| 539 | 565 | /** A JUnit report, by the web's own rule (file name, or root element). */ |
| 540 | 566 | function isJunitPath(path: string, source: Uint8Array): boolean { |
| 541 | 567 | return /\.xml$/i.test(path) && fileKind(path, source.subarray(0, 4096)) === "junit"; |
| @@ -805,6 +831,11 @@ class Tab { | ||
| 805 | 831 | this.table = typeof schema === "string" ? null : schema; |
| 806 | 832 | this.fault = typeof schema === "string" ? schema : ""; |
| 807 | 833 | if (this.table !== null) this.grid = layoutTable(this.table); |
| 834 | } else if (isGraphqlPath(this.path)) { | |
| 835 | const schema = graphqlTable(new TextDecoder().decode(source)); | |
| 836 | this.table = typeof schema === "string" ? null : schema; | |
| 837 | this.fault = typeof schema === "string" ? schema : ""; | |
| 838 | if (this.table !== null) this.grid = layoutTable(this.table); | |
| 808 | 839 | } else if (isJunitPath(this.path, source)) { |
| 809 | 840 | const results = junitTable(new TextDecoder().decode(source)); |
| 810 | 841 | this.table = typeof results === "string" ? null : results; |
src/web/render/graphql.ts added +300
| @@ -0,0 +1,300 @@ | ||
| 1 | /** | |
| 2 | * A `.graphql` or `.gql` schema as its shape: one block per `type`, `input`, | |
| 3 | * `interface` and `enum` (and their `extend` forms), each field one row with | |
| 4 | * its type and arguments. `Query`, `Mutation` and `Subscription` come first. | |
| 5 | * The reader is its own and small: it drops comments, then walks definitions | |
| 6 | * at the top level. Anything it does not understand (`union`, `scalar`, | |
| 7 | * `schema`, `directive`, operations, a body it cannot read) is one plain row | |
| 8 | * with its text, never an error. A file with no definition at all is not | |
| 9 | * drawn: the source view shows with the reason. | |
| 10 | * Every name is escaped by the template; at most `BLOCK_CAP` blocks and | |
| 11 | * `FIELD_CAP` rows per block are drawn. | |
| 12 | */ | |
| 13 | ||
| 14 | import { html, type Html } from "../html.ts"; | |
| 15 | import { fileSize } from "../ui.ts"; | |
| 16 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 17 | ||
| 18 | export const BLOCK_CAP = 500; | |
| 19 | export const FIELD_CAP = 500; | |
| 20 | const TEXT_CAP = 300; | |
| 21 | ||
| 22 | export interface GqlField { | |
| 23 | readonly name: string; | |
| 24 | readonly args: string; | |
| 25 | readonly type: string; | |
| 26 | readonly note: string; | |
| 27 | } | |
| 28 | ||
| 29 | export type GqlBlock = | |
| 30 | | { readonly kind: "type" | "input" | "interface" | "enum"; readonly name: string; readonly extend: boolean; readonly header: string; readonly fields: readonly GqlField[]; readonly more: number } | |
| 31 | | { readonly kind: "other"; readonly text: string }; | |
| 32 | ||
| 33 | export interface GqlDoc { | |
| 34 | readonly blocks: readonly GqlBlock[]; | |
| 35 | readonly total: number; | |
| 36 | } | |
| 37 | ||
| 38 | const SHAPES = new Set(["type", "input", "interface", "enum"]); | |
| 39 | const DEFINITIONS = new Set(["type", "input", "interface", "enum", "union", "scalar", "schema", "directive", "extend", "fragment", "query", "mutation", "subscription"]); | |
| 40 | const ROOTS = ["Query", "Mutation", "Subscription"]; | |
| 41 | ||
| 42 | /** Remove `#` comments outside strings; strings are kept whole. */ | |
| 43 | function stripComments(s: string): string { | |
| 44 | let out = ""; | |
| 45 | for (let i = 0; i < s.length; i++) { | |
| 46 | const ch = s[i]!; | |
| 47 | if (s.startsWith('"""', i)) { | |
| 48 | let end = i + 3; | |
| 49 | while (end < s.length && !(s.startsWith('"""', end) && s[end - 1] !== "\\")) end++; | |
| 50 | out += s.slice(i, end + 3); | |
| 51 | i = end + 2; | |
| 52 | } else if (ch === '"') { | |
| 53 | let j = i + 1; | |
| 54 | while (j < s.length && s[j] !== '"' && s[j] !== "\n") j += s[j] === "\\" ? 2 : 1; | |
| 55 | out += s.slice(i, j + 1); | |
| 56 | i = j; | |
| 57 | } else if (ch === "#") { | |
| 58 | while (i < s.length && s[i] !== "\n") i++; | |
| 59 | out += "\n"; | |
| 60 | } else out += ch; | |
| 61 | } | |
| 62 | return out; | |
| 63 | } | |
| 64 | ||
| 65 | /** Index just past a string starting at `i` (which holds a quote). */ | |
| 66 | function stringEnd(s: string, i: number): number { | |
| 67 | if (s.startsWith('"""', i)) { | |
| 68 | let end = i + 3; | |
| 69 | while (end < s.length && !(s.startsWith('"""', end) && s[end - 1] !== "\\")) end++; | |
| 70 | return Math.min(s.length, end + 3); | |
| 71 | } | |
| 72 | let j = i + 1; | |
| 73 | while (j < s.length && s[j] !== '"' && s[j] !== "\n") j += s[j] === "\\" ? 2 : 1; | |
| 74 | return Math.min(s.length, j + 1); | |
| 75 | } | |
| 76 | ||
| 77 | /** Index just past the bracket group opening at `i`, or -1 when it never closes. */ | |
| 78 | function groupEnd(s: string, i: number): number { | |
| 79 | let depth = 0; | |
| 80 | for (let j = i; j < s.length; j++) { | |
| 81 | const ch = s[j]!; | |
| 82 | if (ch === '"') j = stringEnd(s, j) - 1; | |
| 83 | else if (ch === "(" || ch === "{" || ch === "[") depth++; | |
| 84 | else if ((ch === ")" || ch === "}" || ch === "]") && --depth === 0) return j + 1; | |
| 85 | } | |
| 86 | return -1; | |
| 87 | } | |
| 88 | ||
| 89 | const WS = /[\s,]/; | |
| 90 | const NAME = /^[A-Za-z_][A-Za-z0-9_]*/; | |
| 91 | const squash = (s: string): string => s.replace(/\s+/g, " ").trim(); | |
| 92 | const clip = (s: string): string => (s.length > TEXT_CAP ? s.slice(0, TEXT_CAP) + "…" : s); | |
| 93 | ||
| 94 | function skipSpace(s: string, i: number): number { | |
| 95 | while (i < s.length && WS.test(s[i]!)) i++; | |
| 96 | return i; | |
| 97 | } | |
| 98 | ||
| 99 | /** Directives `@name(args)` from `i`; the text of them and where they end. */ | |
| 100 | function directives(s: string, i: number): { text: string; end: number } { | |
| 101 | const start = i; | |
| 102 | for (;;) { | |
| 103 | let j = skipSpace(s, i); | |
| 104 | if (s[j] !== "@") return { text: squash(s.slice(start, i)), end: i }; | |
| 105 | j++; | |
| 106 | const n = NAME.exec(s.slice(j, j + 128)); | |
| 107 | if (n === null) return { text: squash(s.slice(start, i)), end: i }; | |
| 108 | j += n[0].length; | |
| 109 | if (s[j] === "(") { | |
| 110 | const e = groupEnd(s, j); | |
| 111 | if (e === -1) return { text: squash(s.slice(start, i)), end: i }; | |
| 112 | j = e; | |
| 113 | } | |
| 114 | i = j; | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | /** Read the fields of a body (without braces). Null when it is not in a shape this reader knows. */ | |
| 119 | function fields(body: string, isEnum: boolean): GqlField[] | null { | |
| 120 | const out: GqlField[] = []; | |
| 121 | let i = skipSpace(body, 0); | |
| 122 | while (i < body.length) { | |
| 123 | if (body[i] === '"') { | |
| 124 | i = skipSpace(body, stringEnd(body, i)); | |
| 125 | continue; | |
| 126 | } | |
| 127 | const n = NAME.exec(body.slice(i, i + 256)); | |
| 128 | if (n === null) return null; | |
| 129 | const name = n[0]; | |
| 130 | i += name.length; | |
| 131 | let args = ""; | |
| 132 | let type = ""; | |
| 133 | if (!isEnum) { | |
| 134 | let j = skipSpace(body, i); | |
| 135 | if (body[j] === "(") { | |
| 136 | const e = groupEnd(body, j); | |
| 137 | if (e === -1) return null; | |
| 138 | args = squash(body.slice(j + 1, e - 1)); | |
| 139 | j = skipSpace(body, e); | |
| 140 | } | |
| 141 | if (body[j] !== ":") return null; | |
| 142 | j = skipSpace(body, j + 1); | |
| 143 | // the type is a name in brackets and bangs; the next field's name must not be eaten | |
| 144 | const m = /^(?:\[+\s*)*[A-Za-z_][A-Za-z0-9_]*(?:\s*!)?(?:\s*\]\s*!?)*/.exec(body.slice(j, j + 512)); | |
| 145 | if (m === null) return null; | |
| 146 | type = m[0].replace(/\s+/g, ""); | |
| 147 | i = j + m[0].length; | |
| 148 | const k = skipSpace(body, i); | |
| 149 | if (body[k] === "=") { | |
| 150 | // a default value, shown with the type | |
| 151 | let v = skipSpace(body, k + 1); | |
| 152 | if (body[v] === "[" || body[v] === "{") { | |
| 153 | const e = groupEnd(body, v); | |
| 154 | if (e === -1) return null; | |
| 155 | v = e; | |
| 156 | } else if (body[v] === '"') v = stringEnd(body, v); | |
| 157 | else { | |
| 158 | const w = /^[^\s,@]+/.exec(body.slice(v, v + 256)); | |
| 159 | v += w === null ? 0 : w[0].length; | |
| 160 | } | |
| 161 | type += " " + squash(body.slice(k, v)); | |
| 162 | i = v; | |
| 163 | } | |
| 164 | } | |
| 165 | const d = directives(body, i); | |
| 166 | i = skipSpace(body, d.end); | |
| 167 | out.push({ name, args, type, note: d.text }); | |
| 168 | if (out.length >= FIELD_CAP + 1) break; | |
| 169 | } | |
| 170 | return out; | |
| 171 | } | |
| 172 | ||
| 173 | /** Where an unrecognised definition ends: before the next definition keyword that starts a line. */ | |
| 174 | function otherEnd(s: string, i: number): number { | |
| 175 | let j = i; | |
| 176 | while (j < s.length) { | |
| 177 | const ch = s[j]!; | |
| 178 | if (ch === '"') j = stringEnd(s, j); | |
| 179 | else if (ch === "{" || ch === "(" || ch === "[") { | |
| 180 | const e = groupEnd(s, j); | |
| 181 | j = e === -1 ? s.length : e; | |
| 182 | } else if (ch === "\n") { | |
| 183 | const k = skipSpace(s, j + 1); | |
| 184 | const w = NAME.exec(s.slice(k, k + 32)); | |
| 185 | const prev = s.slice(i, j).trimEnd(); | |
| 186 | if (w !== null && DEFINITIONS.has(w[0]) && !/[=|&]$/.test(prev) && !/^extend$/.test(prev.split(/\s+/).pop() ?? "")) return j; | |
| 187 | if (w !== null && /^["{]/.test(s[k] ?? "")) return j; | |
| 188 | j++; | |
| 189 | } else j++; | |
| 190 | } | |
| 191 | return s.length; | |
| 192 | } | |
| 193 | ||
| 194 | /** Read a GraphQL schema. Pure; never throws; null when it holds no definition. */ | |
| 195 | export function parseGraphql(source: string): GqlDoc | null { | |
| 196 | const s = stripComments(source); | |
| 197 | const blocks: GqlBlock[] = []; | |
| 198 | let total = 0; | |
| 199 | let i = skipSpace(s, 0); | |
| 200 | while (i < s.length) { | |
| 201 | // a description belongs to the definition after it | |
| 202 | if (s[i] === '"') { | |
| 203 | i = skipSpace(s, stringEnd(s, i)); | |
| 204 | continue; | |
| 205 | } | |
| 206 | const start = i; | |
| 207 | const word = NAME.exec(s.slice(i, i + 32))?.[0] ?? ""; | |
| 208 | let block: GqlBlock | null = null; | |
| 209 | let end = -1; | |
| 210 | let k = i; | |
| 211 | let extend = false; | |
| 212 | let kw = word; | |
| 213 | if (word === "extend") { | |
| 214 | extend = true; | |
| 215 | k = skipSpace(s, i + word.length); | |
| 216 | kw = NAME.exec(s.slice(k, k + 32))?.[0] ?? ""; | |
| 217 | } | |
| 218 | if (SHAPES.has(kw)) { | |
| 219 | k = skipSpace(s, k + kw.length); | |
| 220 | const name = NAME.exec(s.slice(k, k + 256))?.[0]; | |
| 221 | if (name !== undefined) { | |
| 222 | k += name.length; | |
| 223 | // `implements A & B` and directives, until the body | |
| 224 | let h = k; | |
| 225 | const continues = (at: number): boolean => s[at] === "\n" && /^\s*(&|implements\b|@)/.test(s.slice(at, at + 40)); | |
| 226 | while (h < s.length && s[h] !== "{" && (s[h] !== "\n" || continues(h))) { | |
| 227 | if (s[h] === "(") { | |
| 228 | const e = groupEnd(s, h); | |
| 229 | if (e === -1) break; | |
| 230 | h = e; | |
| 231 | } else if (s[h] === '"') h = stringEnd(s, h); | |
| 232 | else h++; | |
| 233 | } | |
| 234 | const header = squash(s.slice(k, h)); | |
| 235 | if (s[h] === "{") { | |
| 236 | const e = groupEnd(s, h); | |
| 237 | if (e !== -1) { | |
| 238 | const list = fields(s.slice(h + 1, e - 1), kw === "enum"); | |
| 239 | if (list !== null) { | |
| 240 | block = { kind: kw as "type", name, extend, header, fields: list.slice(0, FIELD_CAP), more: Math.max(0, list.length - FIELD_CAP) }; | |
| 241 | end = e; | |
| 242 | } | |
| 243 | } | |
| 244 | } else { | |
| 245 | // a type with no body: still one block, with no rows | |
| 246 | block = { kind: kw as "type", name, extend, header, fields: [], more: 0 }; | |
| 247 | end = h; | |
| 248 | } | |
| 249 | } | |
| 250 | } | |
| 251 | if (block === null) { | |
| 252 | end = otherEnd(s, i + Math.max(1, word.length)); | |
| 253 | block = { kind: "other", text: clip(squash(s.slice(start, end))) }; | |
| 254 | } | |
| 255 | total++; | |
| 256 | if (blocks.length < BLOCK_CAP) blocks.push(block); | |
| 257 | i = skipSpace(s, Math.max(end, i + 1)); | |
| 258 | } | |
| 259 | if (!blocks.some((b) => b.kind !== "other")) return null; | |
| 260 | const rank = (b: GqlBlock): number => { | |
| 261 | const r = b.kind === "other" ? -1 : ROOTS.indexOf(b.name); | |
| 262 | return r === -1 ? ROOTS.length : r; | |
| 263 | }; | |
| 264 | const sorted = blocks.map((b, n) => ({ b, n })).sort((a, c) => rank(a.b) - rank(c.b) || a.n - c.n).map((x) => x.b); | |
| 265 | return { blocks: sorted, total }; | |
| 266 | } | |
| 267 | ||
| 268 | const cell = (v: string): string => (v === "" ? "—" : v); | |
| 269 | ||
| 270 | function block(b: GqlBlock): Html { | |
| 271 | if (b.kind === "other") return html`<div class="table-scroll"><table class="table" data-dense aria-label="Definition"><tbody><tr><td><code>${b.text}</code></td></tr></tbody></table></div>`; | |
| 272 | const label = `${b.extend ? "extend " : ""}${b.kind}`; | |
| 273 | const head = html`<h3><span class="muted small">${label}</span> <code>${b.name}</code></h3>${b.header === "" ? html`` : html`<p class="muted small"><code>${b.header}</code></p>`}`; | |
| 274 | if (b.fields.length === 0) return head; | |
| 275 | const isEnum = b.kind === "enum"; | |
| 276 | // a column nobody uses is not drawn: the table stays narrow on a phone | |
| 277 | const notes = b.fields.some((f) => f.note !== ""); | |
| 278 | const noteCol = (f: GqlField): Html => (notes ? html`<td>${cell(f.note)}</td>` : html``); | |
| 279 | const noteHead = notes ? html`<th scope="col">Directives</th>` : html``; | |
| 280 | const rows = b.fields.map((f) => | |
| 281 | isEnum | |
| 282 | ? html`<tr><th scope="row"><code>${f.name}</code></th>${noteCol(f)}</tr>` | |
| 283 | : html`<tr><th scope="row"><code>${f.name}</code></th><td>${f.args === "" ? "—" : html`<code>${f.args}</code>`}</td><td><code>${f.type}</code></td>${noteCol(f)}</tr>`, | |
| 284 | ); | |
outdated
Both doors: no terminal view for this kind. Mirror the sql one in src/tui/screens/workspace.ts and test it. |
||
| 285 | const cols = isEnum | |
| 286 | ? html`<th scope="col">Value</th>${noteHead}` | |
| 287 | : html`<th scope="col">Field</th><th scope="col">Arguments</th><th scope="col">Type</th>${noteHead}`; | |
| 288 | const more = b.more > 0 ? html`<p class="muted small">${b.more.toLocaleString()} more not shown</p>` : html``; | |
| 289 | return html`${head}<div class="table-scroll"><table class="table" data-dense aria-label="${b.name} fields"><thead><tr>${cols}</tr></thead><tbody>${rows}</tbody></table></div>${more}`; | |
| 290 | } | |
| 291 | ||
| 292 | export function graphql(input: RenderInput): Rendered { | |
| 293 | const doc = parseGraphql(new TextDecoder().decode(input.body)); | |
| 294 | if (doc === null) return { content: html``, lines: 0, fallback: "This file holds no type, input, interface or enum to draw." }; | |
| 295 | const more = doc.total - doc.blocks.length; | |
| 296 | const foot = more > 0 ? html`<p class="muted small">${more.toLocaleString()} more not shown</p>` : html``; | |
| 297 | const content = html`<div class="graphql-view">${doc.blocks.map(block)}${foot}</div>`; | |
| 298 | const n = doc.total; | |
| 299 | return { content, lines: 0, summary: `${n.toLocaleString()} ${n === 1 ? "definition" : "definitions"} · ${fileSize(input.size)}` }; | |
| 300 | } |
src/web/render/kinds.ts +4
| @@ -47,6 +47,7 @@ export type FileKind = | ||
| 47 | 47 | | "workflow" |
| 48 | 48 | | "compose" |
| 49 | 49 | | "sql" |
| 50 | | "graphql" | |
| 50 | 51 | | "text" |
| 51 | 52 | | "binary"; |
| 52 | 53 | |
| @@ -92,6 +93,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 92 | 93 | workflow: 1 * MB, |
| 93 | 94 | compose: 1 * MB, |
| 94 | 95 | sql: 1 * MB, |
| 96 | graphql: 1 * MB, | |
| 95 | 97 | text: 1 * MB, |
| 96 | 98 | binary: 0, |
| 97 | 99 | }; |
| @@ -216,6 +218,8 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 216 | 218 | sarif: "sarif", |
| 217 | 219 | lcov: "lcov", |
| 218 | 220 | sql: "sql", |
| 221 | graphql: "graphql", | |
| 222 | gql: "graphql", | |
| 219 | 223 | }; |
| 220 | 224 | |
| 221 | 225 | /** The three example names; a plain `.env` is never one. */ |
src/web/render/renderers.ts +2
| @@ -12,6 +12,7 @@ import { config } from "./config.ts"; | ||
| 12 | 12 | import { isFormConfig } from "./config-format.ts"; |
| 13 | 13 | import { envView } from "./env.ts"; |
| 14 | 14 | import { sqlView } from "./sql.ts"; |
| 15 | import { graphql } from "./graphql.ts"; | |
| 15 | 16 | import { eml } from "./eml.ts"; |
| 16 | 17 | import { csv } from "./csv.ts"; |
| 17 | 18 | import { geojson } from "./geojson.ts"; |
| @@ -106,6 +107,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 106 | 107 | junit, |
| 107 | 108 | env: envView, |
| 108 | 109 | sql: sqlView, |
| 110 | graphql, | |
| 109 | 111 | openapi, |
| 110 | 112 | makefile, |
| 111 | 113 | dockerfile, |
test/render-graphql.test.ts added +118
| @@ -0,0 +1,118 @@ | ||
| 1 | /** A GraphQL schema as its types: type, enum, input, arguments, roots first, plain rows, escaping, caps, the registry. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { BLOCK_CAP, graphql, parseGraphql } from "../src/web/render/graphql.ts"; | |
| 4 | import { fileKind, kindByName, RENDER_CAP } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const draw = (text: string) => graphql({ path: "schema.graphql", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 9 | const out = (r: ReturnType<typeof draw>): string => String(r.content); | |
| 10 | ||
| 11 | const SCHEMA = `# the schema | |
| 12 | """A user.""" | |
| 13 | type User implements Node & Entity @key(fields: "id") { | |
| 14 | id: ID! | |
| 15 | "their name" | |
| 16 | name(format: String = "full", upper: Boolean): String | |
| 17 | friends(first: Int! = 10, after: String): [User!]! | |
| 18 | old: String @deprecated(reason: "no") | |
| 19 | } | |
| 20 | enum Role { ADMIN | |
| 21 | GUEST @deprecated } | |
| 22 | input NewUser { | |
| 23 | name: String! | |
| 24 | tags: [String!] = ["a", "b"] | |
| 25 | } | |
| 26 | union Result = User | Error | |
| 27 | scalar Date | |
| 28 | type Query { | |
| 29 | user(id: ID!): User | |
| 30 | } | |
| 31 | type Mutation { addUser(input: NewUser!): User } | |
| 32 | `; | |
| 33 | ||
| 34 | describe("graphql", () => { | |
| 35 | test("registered for .graphql and .gql", () => { | |
| 36 | expect(fileKind("a/schema.graphql", new Uint8Array())).toBe("graphql"); | |
| 37 | expect(kindByName("x.gql")).toBe("graphql"); | |
| 38 | expect(RENDERERS.graphql).toBe(graphql); | |
| 39 | expect(RENDER_CAP.graphql).toBe(1_000_000); | |
| 40 | }); | |
| 41 | ||
| 42 | test("a type, an enum, an input and a field with arguments", () => { | |
| 43 | const doc = parseGraphql(SCHEMA)!; | |
| 44 | expect(doc.blocks.map((b) => (b.kind === "other" ? "other" : b.name))).toEqual(["Query", "Mutation", "User", "Role", "NewUser", "other", "other"]); | |
| 45 | const user = doc.blocks.find((b) => b.kind === "type" && b.name === "User"); | |
| 46 | if (user?.kind !== "type") throw new Error("type"); | |
| 47 | expect(user.header).toContain("implements Node & Entity"); | |
| 48 | expect(user.fields.map((f) => [f.name, f.args, f.type])).toEqual([ | |
| 49 | ["id", "", "ID!"], | |
| 50 | ["name", 'format: String = "full", upper: Boolean', "String"], | |
| 51 | ["friends", "first: Int! = 10, after: String", "[User!]!"], | |
| 52 | ["old", "", "String"], | |
| 53 | ]); | |
| 54 | expect(user.fields[3]!.note).toContain("@deprecated"); | |
| 55 | const en = doc.blocks.find((b) => b.kind === "enum"); | |
| 56 | if (en?.kind !== "enum") throw new Error("enum"); | |
| 57 | expect(en.fields.map((f) => f.name)).toEqual(["ADMIN", "GUEST"]); | |
| 58 | const inp = doc.blocks.find((b) => b.kind === "input"); | |
| 59 | if (inp?.kind !== "input") throw new Error("input"); | |
| 60 | expect(inp.fields.map((f) => [f.name, f.type])).toEqual([["name", "String!"], ["tags", '[String!] = ["a", "b"]']]); | |
| 61 | const others = doc.blocks.filter((b) => b.kind === "other").map((b) => (b.kind === "other" ? b.text : "")); | |
| 62 | expect(others).toEqual(["union Result = User | Error", "scalar Date"]); | |
| 63 | const r = draw(SCHEMA); | |
| 64 | expect(r.summary).toContain("7 definitions"); | |
| 65 | expect(out(r)).toContain("[User!]!"); | |
| 66 | }); | |
| 67 | ||
| 68 | test("an unreadable body is one plain row, not an error", () => { | |
| 69 | const doc = parseGraphql("type A { ??? }\ntype B { x: Int }")!; | |
| 70 | expect(doc.blocks.map((b) => b.kind)).toEqual(["other", "type"]); | |
| 71 | }); | |
| 72 | ||
| 73 | test("a file with no definition falls back", () => { | |
| 74 | expect(draw("just words").fallback).toBeDefined(); | |
| 75 | expect(draw("").fallback).toBeDefined(); | |
| 76 | }); | |
| 77 | ||
| 78 | test("every name is escaped", () => { | |
| 79 | const r = draw(`type Query { f(a: String = "<img src=x onerror=1>"): Int @x(v: "<script>") }\nscalar S_<b>\nunion <i> = A`); | |
| 80 | const h = out(r); | |
| 81 | expect(h).not.toContain("<img"); | |
| 82 | expect(h).not.toContain("<script>"); | |
| 83 | expect(h).not.toContain("<b>"); | |
| 84 | expect(h).not.toContain("<i>"); | |
| 85 | expect(h).toContain("<"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("blocks are capped", () => { | |
| 89 | const text = Array.from({ length: BLOCK_CAP + 5 }, (_, n) => `type T${n} { a: Int }`).join("\n"); | |
| 90 | const doc = parseGraphql(text)!; | |
| 91 | expect(doc.blocks.length).toBe(BLOCK_CAP); | |
| 92 | expect(doc.total).toBe(BLOCK_CAP + 5); | |
| 93 | expect(out(draw(text))).toContain("more not shown"); | |
| 94 | }); | |
| 95 | ||
outdated
Add a test that a file over RENDER_CAP.graphql opens as text, and one for |
||
| 96 | test("an extend type renders as a block marked extend", () => { | |
| 97 | const doc = parseGraphql("extend type Query @x { more: Int }\ntype A { a: Int }")!; | |
| 98 | const q = doc.blocks[0]!; | |
| 99 | if (q.kind !== "type") throw new Error("type"); | |
| 100 | expect([q.extend, q.name, q.header, q.fields.map((f) => f.name)]).toEqual([true, "Query", "@x", ["more"]]); | |
| 101 | expect(out(draw("extend type Query { more: Int }"))).toContain("extend type"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a type-header directive is kept in the header line", () => { | |
| 105 | expect(out(draw('type A @key(fields: "id") { a: Int }'))).toContain("@key(fields:"); | |
| 106 | }); | |
| 107 | ||
| 108 | test("a file over the cap opens as text", () => { | |
| 109 | const blob = (size: number) => ({ kind: "text", size, body: enc("type A { a: Int }") }) as never; | |
| 110 | expect(rendererFor("a.graphql", blob(RENDER_CAP.graphql))?.render).toBe(graphql); | |
| 111 | expect(rendererFor("a.graphql", blob(RENDER_CAP.graphql + 1))).toBeUndefined(); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the Directives column is drawn only when a field has one", () => { | |
| 115 | expect(out(draw("type A { a: Int }"))).not.toContain("Directives"); | |
| 116 | expect(out(draw("type A { a: Int @deprecated }"))).toContain("Directives"); | |
| 117 | }); | |
| 118 | }); |
test/render-kinds.test.ts +2
| @@ -82,6 +82,8 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 82 | 82 | gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"], |
| 83 | 83 | gco: [enc("G28\n"), "gcode", "application/octet-stream"], |
| 84 | 84 | sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"], |
| 85 | graphql: [enc("type A { b: Int }\n"), "graphql", "application/octet-stream"], | |
| 86 | gql: [enc("type A { b: Int }\n"), "graphql", "application/octet-stream"], | |
| 85 | 87 | sarif: [enc('{"runs":[]}'), "sarif", "application/octet-stream"], |
| 86 | 88 | lcov: [enc("SF:a.ts\nend_of_record\n"), "lcov", "application/octet-stream"], |
| 87 | 89 | }; |
test/tui-workspace.test.ts +23
| @@ -681,6 +681,29 @@ describe("the terminal workspace", () => { | ||
| 681 | 681 | } |
| 682 | 682 | }); |
| 683 | 683 | |
| 684 | test("a .graphql file opens as its types, roots first, and s shows the source", async () => { | |
| 685 | const made = await createRepo(forge, alice, { name: "gqlrepo", visibility: "public", description: "api" }); | |
| 686 | if (!made.ok) throw new Error("repo failed"); | |
| 687 | const gql = 'type User @key(fields: "id") { id: ID! }\ntype Query { user(id: ID!): User }\nscalar Date'; | |
| 688 | await commitToBranch(forge, alice, made.value, { message: "seed", files: [{ path: "api/schema.graphql", content: gql }] }); | |
| 689 | const h = boot(); | |
| 690 | try { | |
| 691 | await h.settle(); | |
| 692 | await open(h, "gqlrepo"); | |
| 693 | await openFile(h, "api/schema.graphql"); | |
| 694 | const text = h.text(); | |
| 695 | expect(text).toContain("type Query"); | |
| 696 | expect(text).toContain("user"); | |
| 697 | expect(text).toContain("ID!"); | |
| 698 | expect(text).toContain("scalar Date"); | |
| 699 | expect(text.indexOf("type Query")).toBeLessThan(text.indexOf("type User")); | |
| 700 | await h.press("s"); | |
| 701 | expect(h.text()).toContain("type Query {"); | |
| 702 | } finally { | |
| 703 | h.stop(); | |
| 704 | } | |
| 705 | }); | |
| 706 | ||
| 684 | 707 | test("a JUnit report opens as its totals and failing cases, sharing the web parser", async () => { |
| 685 | 708 | const made = await createRepo(forge, alice, { name: "junitrepo", visibility: "public", description: "tests" }); |
| 686 | 709 | if (!made.ok) throw new Error("repo failed"); |
Principle 7/3: each block is an h3, a muted header line and a four-column table. On a 360px phone Field/Arguments/Type/Directives sit in a scroll box. Consider dropping the Directives column when no field has one.