Skip to content

Commit 8331b91

Browse files
committed
feat: self-hosted API docs page — / (content-negotiated) + /docs
1 parent 755912e commit 8331b91

8 files changed

Lines changed: 338 additions & 36 deletions

File tree

‎dist/src/docs.d.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* The API's own webpage — served from the deployed worker at `/` (to
3+
* browsers, via content negotiation) and at `/docs` (always). Deployed
4+
* content belongs to the api.interscript.org deployment, not the main
5+
* website. Self-contained HTML: no external assets, system font stack.
6+
*/
7+
export declare function docsPage(openapiPath: string): string;

‎dist/src/docs.js‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* The API's own webpage — served from the deployed worker at `/` (to
3+
* browsers, via content negotiation) and at `/docs` (always). Deployed
4+
* content belongs to the api.interscript.org deployment, not the main
5+
* website. Self-contained HTML: no external assets, system font stack.
6+
*/
7+
const ENDPOINTS = [
8+
{ method: "GET", path: "/v1/info", note: "version + capability summary" },
9+
{ method: "GET", path: "/v1/maps", note: "every transliteration system code" },
10+
{ method: "GET", path: "/v1/maps/{code}", note: "one system's compiled map (JSON IR)" },
11+
{ method: "POST", path: "/v1/transliterate", note: "{system, input} → {output}" },
12+
{ method: "POST", path: "/v1/detect", note: "{input, output} → ranked systems" },
13+
{ method: "GET", path: "/v1/models", note: "neural model index (IMF v1)" },
14+
{ method: "GET", path: "/v1/models/{id}", note: "one model's metadata" },
15+
{ method: "POST", path: "/v1/infer", note: "{model, input} → {output}" },
16+
{ method: "POST", path: "/graphql", note: "GraphQL endpoint (introspection enabled)" },
17+
{ method: "GET", path: "/openapi.json", note: "OpenAPI 3.1 document" },
18+
];
19+
function esc(s) {
20+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
21+
}
22+
export function docsPage(openapiPath) {
23+
const rows = ENDPOINTS.map((e) => `<tr><td><span class="m">${esc(e.method)}</span></td><td><code>${esc(e.path)}</code></td><td>${esc(e.note)}</td></tr>`).join("\n");
24+
return `<!doctype html>
25+
<html lang="en">
26+
<head>
27+
<meta charset="utf-8">
28+
<meta name="viewport" content="width=device-width, initial-scale=1">
29+
<title>Interscript API</title>
30+
<style>
31+
:root { color-scheme: light dark; }
32+
* { box-sizing: border-box; }
33+
body {
34+
margin: 0; padding: 2.5rem 1.25rem 4rem;
35+
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
36+
background: #f6f3ec; color: #1a1d1f;
37+
}
38+
@media (prefers-color-scheme: dark) { body { background: #14171a; color: #eceae4; } }
39+
main { max-width: 44rem; margin: 0 auto; }
40+
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 .35rem; }
41+
.tagline { margin: 0 0 2rem; color: #5c6470; }
42+
.brand { color: #008075; }
43+
table { border-collapse: collapse; width: 100%; margin: 1rem 0 2rem; font-size: .95rem; }
44+
th, td { text-align: left; padding: .55rem .7rem; border-bottom: 1px solid rgba(128,128,128,.25); vertical-align: top; }
45+
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: #5c6470; }
46+
code { font: .9em ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
47+
.m { font-weight: 700; font-size: .78rem; letter-spacing: .05em; color: #008075; }
48+
.links a { margin-right: 1.4rem; }
49+
a { color: #008075; }
50+
section { margin-top: 2.5rem; }
51+
pre { background: rgba(128,128,128,.12); padding: 1rem; border-radius: 6px; overflow-x: auto; }
52+
</style>
53+
</head>
54+
<body>
55+
<main>
56+
<h1><span class="brand">Interscript</span> API</h1>
57+
<p class="tagline">Authority-backed transliteration for every script — REST v1 + GraphQL on the edge.</p>
58+
59+
<table>
60+
<thead><tr><th>Method</th><th>Endpoint</th><th>Behavior</th></tr></thead>
61+
<tbody>
62+
${rows}
63+
</tbody>
64+
</table>
65+
66+
<section>
67+
<h2>Quick start</h2>
68+
<pre><code>curl -X POST https://api.interscript.org/v1/transliterate \\
69+
-H 'Content-Type: application/json' \\
70+
-d '{"system":"alalc-ara-Arab-Latn-1997","input":"السلام عليكم"}'</code></pre>
71+
</section>
72+
73+
<section>
74+
<h2>Machine-readable</h2>
75+
<p class="links">
76+
<a href="${esc(openapiPath)}">OpenAPI 3.1</a>
77+
<a href="/v1/models">Model index</a>
78+
<a href="/v1/maps">System codes</a>
79+
<a href="https://www.interscript.org">Interscript project</a>
80+
<a href="https://github.com/interscript/api">Source (BSD-2-Clause)</a>
81+
</p>
82+
</section>
83+
</main>
84+
</body>
85+
</html>
86+
`;
87+
}

‎dist/src/index.js‎

Lines changed: 67 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎dist/src/rest.js‎

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,26 +22,31 @@ import { INFER_TIMEOUT_MS, LIMITS } from "./limits.js";
2222
import { bundledSystemCodes } from "./engine.js";
2323
import { getModel, listModels, MODELS_INDEX_VERSION } from "./models.js";
2424
import { OPENAPI } from "./openapi.js";
25+
import { docsPage } from "./docs.js";
2526
function errorResponse(status, code, message) {
2627
return Response.json({ error: { code, message } }, { status });
2728
}
2829
export const rest = new Hono();
29-
rest.get("/", (c) => c.json({
30-
name: "Interscript API",
31-
version: "v1",
32-
openapi: "/openapi.json",
33-
endpoints: [
34-
"GET /v1/info",
35-
"GET /v1/maps",
36-
"GET /v1/maps/{code}",
37-
"POST /v1/transliterate",
38-
"POST /v1/detect",
39-
"GET /v1/models",
40-
"GET /v1/models/{id}",
41-
"POST /v1/infer",
42-
"POST /graphql",
43-
],
44-
}));
30+
const wantsHtml = (accept) => (accept ?? "").toLowerCase().includes("text/html");
31+
rest.get("/docs", (c) => c.html(docsPage("/openapi.json")));
32+
rest.get("/", (c) => wantsHtml(c.req.header("accept"))
33+
? c.html(docsPage("/openapi.json"))
34+
: c.json({
35+
name: "Interscript API",
36+
version: "v1",
37+
openapi: "/openapi.json",
38+
endpoints: [
39+
"GET /v1/info",
40+
"GET /v1/maps",
41+
"GET /v1/maps/{code}",
42+
"POST /v1/transliterate",
43+
"POST /v1/detect",
44+
"GET /v1/models",
45+
"GET /v1/models/{id}",
46+
"POST /v1/infer",
47+
"POST /graphql",
48+
],
49+
}));
4550
rest.get("/openapi.json", (c) => c.json(OPENAPI));
4651
rest.get("/v1/info", (c) => c.json({
4752
api_version: "1.0.0",

‎dist/test/rest.test.js‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,24 @@ describe("REST index + docs", () => {
3434
const body = (await res.json());
3535
expect(body.endpoints).toContain("POST /v1/infer");
3636
});
37+
it("GET / serves the docs page to browsers, JSON to API clients", async () => {
38+
const html = await get("/", { accept: "text/html,application/xhtml+xml" });
39+
expect(html.status).toBe(200);
40+
expect(html.headers.get("content-type")).toContain("text/html");
41+
const page = await html.text();
42+
expect(page).toContain("<!doctype html>");
43+
expect(page).toMatch(/POST<\/span><\/td><td><code>\/v1\/transliterate/);
44+
const json = await get("/");
45+
expect(json.headers.get("content-type")).toContain("application/json");
46+
});
47+
it("GET /docs always serves the docs page", async () => {
48+
const res = await get("/docs");
49+
expect(res.status).toBe(200);
50+
expect(res.headers.get("content-type")).toContain("text/html");
51+
const page = await res.text();
52+
expect(page).toContain("openapi.json");
53+
expect(page).toContain("/graphql");
54+
});
3755
it("GET /openapi.json serves a 3.1 document covering all routes", async () => {
3856
const res = await get("/openapi.json");
3957
expect(res.status).toBe(200);

‎src/docs.ts‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
/**
2+
* The API's own webpage — served from the deployed worker at `/` (to
3+
* browsers, via content negotiation) and at `/docs` (always). Deployed
4+
* content belongs to the api.interscript.org deployment, not the main
5+
* website. Self-contained HTML: no external assets, system font stack.
6+
*/
7+
8+
const ENDPOINTS: { method: string; path: string; note: string }[] = [
9+
{ method: "GET", path: "/v1/info", note: "version + capability summary" },
10+
{ method: "GET", path: "/v1/maps", note: "every transliteration system code" },
11+
{ method: "GET", path: "/v1/maps/{code}", note: "one system's compiled map (JSON IR)" },
12+
{ method: "POST", path: "/v1/transliterate", note: "{system, input} → {output}" },
13+
{ method: "POST", path: "/v1/detect", note: "{input, output} → ranked systems" },
14+
{ method: "GET", path: "/v1/models", note: "neural model index (IMF v1)" },
15+
{ method: "GET", path: "/v1/models/{id}", note: "one model's metadata" },
16+
{ method: "POST", path: "/v1/infer", note: "{model, input} → {output}" },
17+
{ method: "POST", path: "/graphql", note: "GraphQL endpoint (introspection enabled)" },
18+
{ method: "GET", path: "/openapi.json", note: "OpenAPI 3.1 document" },
19+
]
20+
21+
function esc(s: string): string {
22+
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
23+
}
24+
25+
export function docsPage(openapiPath: string): string {
26+
const rows = ENDPOINTS.map(
27+
(e) =>
28+
`<tr><td><span class="m">${esc(e.method)}</span></td><td><code>${esc(e.path)}</code></td><td>${esc(e.note)}</td></tr>`,
29+
).join("\n")
30+
return `<!doctype html>
31+
<html lang="en">
32+
<head>
33+
<meta charset="utf-8">
34+
<meta name="viewport" content="width=device-width, initial-scale=1">
35+
<title>Interscript API</title>
36+
<style>
37+
:root { color-scheme: light dark; }
38+
* { box-sizing: border-box; }
39+
body {
40+
margin: 0; padding: 2.5rem 1.25rem 4rem;
41+
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
42+
background: #f6f3ec; color: #1a1d1f;
43+
}
44+
@media (prefers-color-scheme: dark) { body { background: #14171a; color: #eceae4; } }
45+
main { max-width: 44rem; margin: 0 auto; }
46+
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 .35rem; }
47+
.tagline { margin: 0 0 2rem; color: #5c6470; }
48+
.brand { color: #008075; }
49+
table { border-collapse: collapse; width: 100%; margin: 1rem 0 2rem; font-size: .95rem; }
50+
th, td { text-align: left; padding: .55rem .7rem; border-bottom: 1px solid rgba(128,128,128,.25); vertical-align: top; }
51+
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: #5c6470; }
52+
code { font: .9em ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
53+
.m { font-weight: 700; font-size: .78rem; letter-spacing: .05em; color: #008075; }
54+
.links a { margin-right: 1.4rem; }
55+
a { color: #008075; }
56+
section { margin-top: 2.5rem; }
57+
pre { background: rgba(128,128,128,.12); padding: 1rem; border-radius: 6px; overflow-x: auto; }
58+
</style>
59+
</head>
60+
<body>
61+
<main>
62+
<h1><span class="brand">Interscript</span> API</h1>
63+
<p class="tagline">Authority-backed transliteration for every script — REST v1 + GraphQL on the edge.</p>
64+
65+
<table>
66+
<thead><tr><th>Method</th><th>Endpoint</th><th>Behavior</th></tr></thead>
67+
<tbody>
68+
${rows}
69+
</tbody>
70+
</table>
71+
72+
<section>
73+
<h2>Quick start</h2>
74+
<pre><code>curl -X POST https://api.interscript.org/v1/transliterate \\
75+
-H 'Content-Type: application/json' \\
76+
-d '{"system":"alalc-ara-Arab-Latn-1997","input":"السلام عليكم"}'</code></pre>
77+
</section>
78+
79+
<section>
80+
<h2>Machine-readable</h2>
81+
<p class="links">
82+
<a href="${esc(openapiPath)}">OpenAPI 3.1</a>
83+
<a href="/v1/models">Model index</a>
84+
<a href="/v1/maps">System codes</a>
85+
<a href="https://www.interscript.org">Interscript project</a>
86+
<a href="https://github.com/interscript/api">Source (BSD-2-Clause)</a>
87+
</p>
88+
</section>
89+
</main>
90+
</body>
91+
</html>
92+
`
93+
}

‎src/rest.ts‎

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,38 @@ import { INFER_TIMEOUT_MS, LIMITS } from "./limits.js"
2828
import { bundledSystemCodes } from "./engine.js"
2929
import { getModel, listModels, MODELS_INDEX_VERSION } from "./models.js"
3030
import { OPENAPI } from "./openapi.js"
31+
import { docsPage } from "./docs.js"
3132

3233
function errorResponse(status: number, code: string, message: string) {
3334
return Response.json({ error: { code, message } }, { status })
3435
}
3536

3637
export const rest = new Hono<{ Bindings: Env }>()
3738

39+
const wantsHtml = (accept: string | undefined): boolean =>
40+
(accept ?? "").toLowerCase().includes("text/html")
41+
42+
rest.get("/docs", (c) => c.html(docsPage("/openapi.json")))
43+
3844
rest.get("/", (c) =>
39-
c.json({
40-
name: "Interscript API",
41-
version: "v1",
42-
openapi: "/openapi.json",
43-
endpoints: [
44-
"GET /v1/info",
45-
"GET /v1/maps",
46-
"GET /v1/maps/{code}",
47-
"POST /v1/transliterate",
48-
"POST /v1/detect",
49-
"GET /v1/models",
50-
"GET /v1/models/{id}",
51-
"POST /v1/infer",
52-
"POST /graphql",
53-
],
54-
}),
45+
wantsHtml(c.req.header("accept"))
46+
? c.html(docsPage("/openapi.json"))
47+
: c.json({
48+
name: "Interscript API",
49+
version: "v1",
50+
openapi: "/openapi.json",
51+
endpoints: [
52+
"GET /v1/info",
53+
"GET /v1/maps",
54+
"GET /v1/maps/{code}",
55+
"POST /v1/transliterate",
56+
"POST /v1/detect",
57+
"GET /v1/models",
58+
"GET /v1/models/{id}",
59+
"POST /v1/infer",
60+
"POST /graphql",
61+
],
62+
}),
5563
)
5664

5765
rest.get("/openapi.json", (c) => c.json(OPENAPI))

‎test/rest.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,27 @@ describe("REST index + docs", () => {
5050
expect(body.endpoints).toContain("POST /v1/infer")
5151
})
5252

53+
it("GET / serves the docs page to browsers, JSON to API clients", async () => {
54+
const html = await get("/", { accept: "text/html,application/xhtml+xml" })
55+
expect(html.status).toBe(200)
56+
expect(html.headers.get("content-type")).toContain("text/html")
57+
const page = await html.text()
58+
expect(page).toContain("<!doctype html>")
59+
expect(page).toMatch(/POST<\/span><\/td><td><code>\/v1\/transliterate/)
60+
61+
const json = await get("/")
62+
expect(json.headers.get("content-type")).toContain("application/json")
63+
})
64+
65+
it("GET /docs always serves the docs page", async () => {
66+
const res = await get("/docs")
67+
expect(res.status).toBe(200)
68+
expect(res.headers.get("content-type")).toContain("text/html")
69+
const page = await res.text()
70+
expect(page).toContain("openapi.json")
71+
expect(page).toContain("/graphql")
72+
})
73+
5374
it("GET /openapi.json serves a 3.1 document covering all routes", async () => {
5475
const res = await get("/openapi.json")
5576
expect(res.status).toBe(200)

0 commit comments

Comments
 (0)