diff --git a/.gitignore b/.gitignore index d9bc84a..5d9e1ca 100644 --- a/.gitignore +++ b/.gitignore @@ -104,4 +104,5 @@ dist .tern-port # deno lock -deno.lock \ No newline at end of file +deno.lock +.DS_Store \ No newline at end of file diff --git a/api-reference.ts b/api-reference.ts new file mode 100644 index 0000000..23317b8 --- /dev/null +++ b/api-reference.ts @@ -0,0 +1,59 @@ +import { undent } from "./deps.ts" + +export interface ApiReferenceOptions { + title?: string; + specUrl: string; + theme?: "alternate" | "default" | "moon" | "purple" | "solarized"; + layout?: "classic" | "modern"; + defaultHttpClient?: { + targetKey: string; + clientKey: string; + }; +} + +const SCALAR_CDN_URL = "https://cdn.jsdelivr.net/npm/@scalar/api-reference"; + +export function renderApiReference({ + title = "API Reference", + specUrl, + theme = "default", + layout = "modern", + defaultHttpClient = { targetKey: "shell", clientKey: "curl" }, +}: ApiReferenceOptions): string { + const configuration = JSON.stringify({ + url: specUrl, + theme, + layout, + defaultHttpClient, + hideClientButton: false, + withDefaultFonts: true, + }).replaceAll("<", "\\u003c"); + + return undent` + + + + + + + ${escapeHtml(title)} + + +
+ + + + + `; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} diff --git a/deno.jsonc b/deno.jsonc index 100bc4b..27f3719 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -2,12 +2,13 @@ "exports": "./mod.ts", "tasks": { "build": "deno run -A script/build.ts", - "start": "deno run -A --unstable-kv mod.ts", - "dev": "deno run -A --unstable-kv --watch mod.ts" + "start": "deno serve -A --unstable-kv --parallel mod.ts", + "dev": "deno serve -A --unstable-kv --parallel --watch mod.ts" }, "imports": { "@amoutonbrady/lz-string": "npm:@amoutonbrady/lz-string@^0.1.0", "@oak/oak": "jsr:@oak/oak@^17.2.0", + "@okikio/undent": "jsr:@okikio/undent@^0.3.3", "@shikijs/rehype": "npm:@shikijs/rehype@3.7.0", "@shikijs/twoslash": "npm:@shikijs/twoslash@3.7.0", "@std/path": "jsr:@std/path@^1.1.6", diff --git a/deps.ts b/deps.ts index 65af41a..c0cc4bc 100644 --- a/deps.ts +++ b/deps.ts @@ -19,5 +19,7 @@ export { default as toHtml } from "rehype-stringify"; export { transformerTwoslash } from "@shikijs/twoslash"; +export { undent } from "@okikio/undent"; + export { default as html, h, Fragment } from "https://deno.land/x/htm/mod.ts"; export { default as ColorScheme } from "https://deno.land/x/htm/plugins/color-scheme.ts"; \ No newline at end of file diff --git a/mod.ts b/mod.ts index 389808e..4e0b567 100644 --- a/mod.ts +++ b/mod.ts @@ -3,6 +3,7 @@ import type { TwoSlashOptions } from "./vendor/twoslash.ts"; import { cors, oak, parse, path } from "./deps.ts"; import { TwoslashError, twoslasher } from "./vendor/twoslash.ts"; import JSON5 from "./vendor/json5.ts"; +import { renderApiReference } from "./api-reference.ts"; // Destructure necessary components from oak (similar to Express.js in Node.js) const { Application, Router, send, isHttpError, Status } = oak; @@ -49,14 +50,28 @@ router const { ext } = context.params; context.response.redirect(`/favicon/favicon.${ext}`); }) - // Route openapi.json for a future swagger ui docs route + // Interactive API reference powered by Scalar. + .get("/docs", (context) => { + context.response.type = "text/html"; + context.response.body = renderApiReference({ + title: "TypeScript Analyzer API Reference", + specUrl: "/.well-known/openapi.json", + theme: "default", + layout: "modern", + defaultHttpClient: { targetKey: "shell", clientKey: "curl" }, + }); + }) + .get("/reference", (context) => { + context.response.redirect("/docs"); + }) + // Machine-readable OpenAPI document consumed by Scalar and other clients. .get("/.well-known/openapi.json", async (context) => { - const uint8arr = await Deno.readFile( - join(__dirname, `./static/.well-known/openapi.yaml`), + const source = await Deno.readTextFile( + join(__dirname, "./static/.well-known/openapi.yaml"), ); - context.response.body = await parse( - new TextDecoder().decode(uint8arr), - ) as Record; + + context.response.type = "application/json"; + context.response.body = parse(source) as Record; }) // Route for serving static files .get("/:staticPath(.well-known|favicon)/:fileName", async (context) => { @@ -292,5 +307,10 @@ app.use((context) => { }); // Start the application and make it listen on port 8000 -console.info("CORS-enabled web server listening on port 8000"); -await app.listen({ port: 8000 }); +// console.info("CORS-enabled web server listening on port 8000"); +// await app.listen({ port: 8000 }); + +// Handler module: describes how to handle a request. +export default { + fetch: app.fetch.bind(app), +} satisfies Deno.ServeDefaultExport; diff --git a/tests/api_reference_test.ts b/tests/api_reference_test.ts new file mode 100644 index 0000000..7586e1c --- /dev/null +++ b/tests/api_reference_test.ts @@ -0,0 +1,23 @@ +import { assertEquals, assertStringIncludes } from "jsr:@std/assert@^1.0.14"; +import { renderApiReference } from "../api-reference.ts"; + +Deno.test("renderApiReference points Scalar at the OpenAPI document", () => { + const page = renderApiReference({ + title: "Example API", + specUrl: "/openapi.json", + }); + + assertStringIncludes(page, "Scalar.createApiReference"); + assertStringIncludes(page, '"url":"/openapi.json"'); + assertStringIncludes(page, "Example API"); +}); + +Deno.test("renderApiReference escapes untrusted page titles", () => { + const page = renderApiReference({ + title: "", + specUrl: "/openapi.json", + }); + + assertEquals(page.includes(""), false); + assertStringIncludes(page, "</title><script>alert(1)</script>"); +});