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>");
+});