Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions dist/src/docs.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* The API's own webpage — served from the deployed worker at `/` (to
* browsers, via content negotiation) and at `/docs` (always). Deployed
* content belongs to the api.interscript.org deployment, not the main
* website. Self-contained HTML: no external assets, system font stack.
*/
export declare function docsPage(openapiPath: string): string;
87 changes: 87 additions & 0 deletions dist/src/docs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* The API's own webpage — served from the deployed worker at `/` (to
* browsers, via content negotiation) and at `/docs` (always). Deployed
* content belongs to the api.interscript.org deployment, not the main
* website. Self-contained HTML: no external assets, system font stack.
*/
const ENDPOINTS = [
{ method: "GET", path: "/v1/info", note: "version + capability summary" },
{ method: "GET", path: "/v1/maps", note: "every transliteration system code" },
{ method: "GET", path: "/v1/maps/{code}", note: "one system's compiled map (JSON IR)" },
{ method: "POST", path: "/v1/transliterate", note: "{system, input} → {output}" },
{ method: "POST", path: "/v1/detect", note: "{input, output} → ranked systems" },
{ method: "GET", path: "/v1/models", note: "neural model index (IMF v1)" },
{ method: "GET", path: "/v1/models/{id}", note: "one model's metadata" },
{ method: "POST", path: "/v1/infer", note: "{model, input} → {output}" },
{ method: "POST", path: "/graphql", note: "GraphQL endpoint (introspection enabled)" },
{ method: "GET", path: "/openapi.json", note: "OpenAPI 3.1 document" },
];
function esc(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function docsPage(openapiPath) {
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");
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interscript API</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.25rem 4rem;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f6f3ec; color: #1a1d1f;
}
@media (prefers-color-scheme: dark) { body { background: #14171a; color: #eceae4; } }
main { max-width: 44rem; margin: 0 auto; }
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 .35rem; }
.tagline { margin: 0 0 2rem; color: #5c6470; }
.brand { color: #008075; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0 2rem; font-size: .95rem; }
th, td { text-align: left; padding: .55rem .7rem; border-bottom: 1px solid rgba(128,128,128,.25); vertical-align: top; }
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: #5c6470; }
code { font: .9em ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.m { font-weight: 700; font-size: .78rem; letter-spacing: .05em; color: #008075; }
.links a { margin-right: 1.4rem; }
a { color: #008075; }
section { margin-top: 2.5rem; }
pre { background: rgba(128,128,128,.12); padding: 1rem; border-radius: 6px; overflow-x: auto; }
</style>
</head>
<body>
<main>
<h1><span class="brand">Interscript</span> API</h1>
<p class="tagline">Authority-backed transliteration for every script — REST v1 + GraphQL on the edge.</p>

<table>
<thead><tr><th>Method</th><th>Endpoint</th><th>Behavior</th></tr></thead>
<tbody>
${rows}
</tbody>
</table>

<section>
<h2>Quick start</h2>
<pre><code>curl -X POST https://api.interscript.org/v1/transliterate \\
-H 'Content-Type: application/json' \\
-d '{"system":"alalc-ara-Arab-Latn-1997","input":"السلام عليكم"}'</code></pre>
</section>

<section>
<h2>Machine-readable</h2>
<p class="links">
<a href="${esc(openapiPath)}">OpenAPI 3.1</a>
<a href="/v1/models">Model index</a>
<a href="/v1/maps">System codes</a>
<a href="https://www.interscript.org">Interscript project</a>
<a href="https://github.com/interscript/api">Source (BSD-2-Clause)</a>
</p>
</section>
</main>
</body>
</html>
`;
}
71 changes: 67 additions & 4 deletions dist/src/index.js

Large diffs are not rendered by default.

37 changes: 21 additions & 16 deletions dist/src/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,31 @@ import { INFER_TIMEOUT_MS, LIMITS } from "./limits.js";
import { bundledSystemCodes } from "./engine.js";
import { getModel, listModels, MODELS_INDEX_VERSION } from "./models.js";
import { OPENAPI } from "./openapi.js";
import { docsPage } from "./docs.js";
function errorResponse(status, code, message) {
return Response.json({ error: { code, message } }, { status });
}
export const rest = new Hono();
rest.get("/", (c) => c.json({
name: "Interscript API",
version: "v1",
openapi: "/openapi.json",
endpoints: [
"GET /v1/info",
"GET /v1/maps",
"GET /v1/maps/{code}",
"POST /v1/transliterate",
"POST /v1/detect",
"GET /v1/models",
"GET /v1/models/{id}",
"POST /v1/infer",
"POST /graphql",
],
}));
const wantsHtml = (accept) => (accept ?? "").toLowerCase().includes("text/html");
rest.get("/docs", (c) => c.html(docsPage("/openapi.json")));
rest.get("/", (c) => wantsHtml(c.req.header("accept"))
? c.html(docsPage("/openapi.json"))
: c.json({
name: "Interscript API",
version: "v1",
openapi: "/openapi.json",
endpoints: [
"GET /v1/info",
"GET /v1/maps",
"GET /v1/maps/{code}",
"POST /v1/transliterate",
"POST /v1/detect",
"GET /v1/models",
"GET /v1/models/{id}",
"POST /v1/infer",
"POST /graphql",
],
}));
rest.get("/openapi.json", (c) => c.json(OPENAPI));
rest.get("/v1/info", (c) => c.json({
api_version: "1.0.0",
Expand Down
18 changes: 18 additions & 0 deletions dist/test/rest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ describe("REST index + docs", () => {
const body = (await res.json());
expect(body.endpoints).toContain("POST /v1/infer");
});
it("GET / serves the docs page to browsers, JSON to API clients", async () => {
const html = await get("/", { accept: "text/html,application/xhtml+xml" });
expect(html.status).toBe(200);
expect(html.headers.get("content-type")).toContain("text/html");
const page = await html.text();
expect(page).toContain("<!doctype html>");
expect(page).toMatch(/POST<\/span><\/td><td><code>\/v1\/transliterate/);
const json = await get("/");
expect(json.headers.get("content-type")).toContain("application/json");
});
it("GET /docs always serves the docs page", async () => {
const res = await get("/docs");
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/html");
const page = await res.text();
expect(page).toContain("openapi.json");
expect(page).toContain("/graphql");
});
it("GET /openapi.json serves a 3.1 document covering all routes", async () => {
const res = await get("/openapi.json");
expect(res.status).toBe(200);
Expand Down
93 changes: 93 additions & 0 deletions src/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* The API's own webpage — served from the deployed worker at `/` (to
* browsers, via content negotiation) and at `/docs` (always). Deployed
* content belongs to the api.interscript.org deployment, not the main
* website. Self-contained HTML: no external assets, system font stack.
*/

const ENDPOINTS: { method: string; path: string; note: string }[] = [
{ method: "GET", path: "/v1/info", note: "version + capability summary" },
{ method: "GET", path: "/v1/maps", note: "every transliteration system code" },
{ method: "GET", path: "/v1/maps/{code}", note: "one system's compiled map (JSON IR)" },
{ method: "POST", path: "/v1/transliterate", note: "{system, input} → {output}" },
{ method: "POST", path: "/v1/detect", note: "{input, output} → ranked systems" },
{ method: "GET", path: "/v1/models", note: "neural model index (IMF v1)" },
{ method: "GET", path: "/v1/models/{id}", note: "one model's metadata" },
{ method: "POST", path: "/v1/infer", note: "{model, input} → {output}" },
{ method: "POST", path: "/graphql", note: "GraphQL endpoint (introspection enabled)" },
{ method: "GET", path: "/openapi.json", note: "OpenAPI 3.1 document" },
]

function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}

export function docsPage(openapiPath: string): string {
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")
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interscript API</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 2.5rem 1.25rem 4rem;
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #f6f3ec; color: #1a1d1f;
}
@media (prefers-color-scheme: dark) { body { background: #14171a; color: #eceae4; } }
main { max-width: 44rem; margin: 0 auto; }
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 .35rem; }
.tagline { margin: 0 0 2rem; color: #5c6470; }
.brand { color: #008075; }
table { border-collapse: collapse; width: 100%; margin: 1rem 0 2rem; font-size: .95rem; }
th, td { text-align: left; padding: .55rem .7rem; border-bottom: 1px solid rgba(128,128,128,.25); vertical-align: top; }
th { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; color: #5c6470; }
code { font: .9em ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
.m { font-weight: 700; font-size: .78rem; letter-spacing: .05em; color: #008075; }
.links a { margin-right: 1.4rem; }
a { color: #008075; }
section { margin-top: 2.5rem; }
pre { background: rgba(128,128,128,.12); padding: 1rem; border-radius: 6px; overflow-x: auto; }
</style>
</head>
<body>
<main>
<h1><span class="brand">Interscript</span> API</h1>
<p class="tagline">Authority-backed transliteration for every script — REST v1 + GraphQL on the edge.</p>

<table>
<thead><tr><th>Method</th><th>Endpoint</th><th>Behavior</th></tr></thead>
<tbody>
${rows}
</tbody>
</table>

<section>
<h2>Quick start</h2>
<pre><code>curl -X POST https://api.interscript.org/v1/transliterate \\
-H 'Content-Type: application/json' \\
-d '{"system":"alalc-ara-Arab-Latn-1997","input":"السلام عليكم"}'</code></pre>
</section>

<section>
<h2>Machine-readable</h2>
<p class="links">
<a href="${esc(openapiPath)}">OpenAPI 3.1</a>
<a href="/v1/models">Model index</a>
<a href="/v1/maps">System codes</a>
<a href="https://www.interscript.org">Interscript project</a>
<a href="https://github.com/interscript/api">Source (BSD-2-Clause)</a>
</p>
</section>
</main>
</body>
</html>
`
}
40 changes: 24 additions & 16 deletions src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,38 @@ import { INFER_TIMEOUT_MS, LIMITS } from "./limits.js"
import { bundledSystemCodes } from "./engine.js"
import { getModel, listModels, MODELS_INDEX_VERSION } from "./models.js"
import { OPENAPI } from "./openapi.js"
import { docsPage } from "./docs.js"

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

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

const wantsHtml = (accept: string | undefined): boolean =>
(accept ?? "").toLowerCase().includes("text/html")

rest.get("/docs", (c) => c.html(docsPage("/openapi.json")))

rest.get("/", (c) =>
c.json({
name: "Interscript API",
version: "v1",
openapi: "/openapi.json",
endpoints: [
"GET /v1/info",
"GET /v1/maps",
"GET /v1/maps/{code}",
"POST /v1/transliterate",
"POST /v1/detect",
"GET /v1/models",
"GET /v1/models/{id}",
"POST /v1/infer",
"POST /graphql",
],
}),
wantsHtml(c.req.header("accept"))
? c.html(docsPage("/openapi.json"))
: c.json({
name: "Interscript API",
version: "v1",
openapi: "/openapi.json",
endpoints: [
"GET /v1/info",
"GET /v1/maps",
"GET /v1/maps/{code}",
"POST /v1/transliterate",
"POST /v1/detect",
"GET /v1/models",
"GET /v1/models/{id}",
"POST /v1/infer",
"POST /graphql",
],
}),
)

rest.get("/openapi.json", (c) => c.json(OPENAPI))
Expand Down
21 changes: 21 additions & 0 deletions test/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ describe("REST index + docs", () => {
expect(body.endpoints).toContain("POST /v1/infer")
})

it("GET / serves the docs page to browsers, JSON to API clients", async () => {
const html = await get("/", { accept: "text/html,application/xhtml+xml" })
expect(html.status).toBe(200)
expect(html.headers.get("content-type")).toContain("text/html")
const page = await html.text()
expect(page).toContain("<!doctype html>")
expect(page).toMatch(/POST<\/span><\/td><td><code>\/v1\/transliterate/)

const json = await get("/")
expect(json.headers.get("content-type")).toContain("application/json")
})

it("GET /docs always serves the docs page", async () => {
const res = await get("/docs")
expect(res.status).toBe(200)
expect(res.headers.get("content-type")).toContain("text/html")
const page = await res.text()
expect(page).toContain("openapi.json")
expect(page).toContain("/graphql")
})

it("GET /openapi.json serves a 3.1 document covering all routes", async () => {
const res = await get("/openapi.json")
expect(res.status).toBe(200)
Expand Down