Skip to content

Commit 6b35f36

Browse files
dani-polaniclaude
andcommitted
feat: add /api/align endpoint and API reference page
POST /api/align accepts {lines, alignments} — returns a ?data= URL with text and word links pre-filled. GET /api/align?lines= for simple lines-only links. OpenAPI schema at /api/align/openapi.json. Public API reference page at /api. All endpoints support CORS. Alignments are [lineA, wordA, lineB, wordB] tuples (0-based, adjacent lines only). Uses encodeState() from the existing serialization layer — no additional backend needed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7a1688b commit 6b35f36

4 files changed

Lines changed: 536 additions & 0 deletions

File tree

bitext/src/routes/api/+page.svelte

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
<script lang="ts">
2+
import { page } from '$app/state';
3+
import { resolve } from '$app/paths';
4+
import { ALIGNER_SITE_HOST } from '$lib/brand.js';
5+
import SiteFooter from '$lib/components/layout/SiteFooter.svelte';
6+
7+
const TITLE = 'API';
8+
const DESCRIPTION =
9+
'Word Aligner API: generate a pre-filled alignment link by posting text lines and optional word-pair data. Free, no auth required.';
10+
11+
const canonical = $derived(page.url.origin + page.url.pathname);
12+
const apiBase = $derived(page.url.origin);
13+
14+
const linkClass =
15+
'font-medium text-primary-700 underline decoration-primary-700/40 underline-offset-2 hover:text-primary-800 hover:decoration-primary-800 dark:text-primary-400 dark:decoration-primary-400/50 dark:hover:text-primary-300';
16+
17+
const headingClass = 'font-heading mt-10 text-xl font-semibold text-gray-900 dark:text-white';
18+
19+
const codeClass =
20+
'rounded bg-gray-100 px-1.5 py-0.5 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200';
21+
22+
const preClass =
23+
'overflow-x-auto rounded-md border border-gray-200 bg-gray-50 p-4 font-mono text-sm leading-relaxed text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200';
24+
</script>
25+
26+
<svelte:head>
27+
<title>{TITLE} · Word Aligner</title>
28+
<meta name="description" content={DESCRIPTION} />
29+
<link rel="canonical" href={canonical} />
30+
<meta name="robots" content="index,follow" />
31+
<meta property="og:type" content="website" />
32+
<meta property="og:title" content="{TITLE} · Word Aligner" />
33+
<meta property="og:description" content={DESCRIPTION} />
34+
<meta property="og:url" content={canonical} />
35+
</svelte:head>
36+
37+
<main
38+
class="mx-auto w-full max-w-3xl min-w-0 px-4 pt-4 pb-16 leading-relaxed text-gray-700 sm:px-6 md:pt-6 md:pb-20 dark:text-gray-300"
39+
>
40+
<header class="mb-8 border-b border-gray-200 pb-6 dark:border-gray-700">
41+
<nav class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
42+
<a href={resolve('/')} class={linkClass}>← Word Aligner</a>
43+
<span class="text-gray-400 dark:text-gray-500" aria-hidden="true">·</span>
44+
<a href={resolve('/about')} class={linkClass}>About</a>
45+
</nav>
46+
</header>
47+
48+
<h1 class="font-heading text-3xl font-semibold tracking-tight text-gray-900 sm:text-4xl dark:text-white">
49+
API
50+
</h1>
51+
<p class="mt-4 text-lg text-gray-600 dark:text-gray-400">
52+
One endpoint: send text lines and optional alignment pairs, get back a shareable Word Aligner
53+
URL. No API key, no sign-up.
54+
</p>
55+
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
56+
OpenAPI schema: <a href={resolve('/api/align/openapi.json')} class={linkClass}
57+
>/api/align/openapi.json</a
58+
>
59+
</p>
60+
61+
<nav
62+
class="mt-8 rounded-md border border-gray-200 bg-gray-50 px-4 py-3 text-sm dark:border-gray-700 dark:bg-gray-800/60"
63+
aria-label="On this page"
64+
>
65+
<p class="m-0 font-medium text-gray-900 dark:text-white">On this page</p>
66+
<ul class="mt-2 list-none space-y-1 p-0">
67+
<li>
68+
<a
69+
href="#post-api-align"
70+
class="block py-1 text-gray-700 underline decoration-gray-400/40 underline-offset-2 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
71+
>POST /api/align</a
72+
>
73+
</li>
74+
<li>
75+
<a
76+
href="#get-api-align"
77+
class="block py-1 text-gray-700 underline decoration-gray-400/40 underline-offset-2 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
78+
>GET /api/align (lines only)</a
79+
>
80+
</li>
81+
<li>
82+
<a
83+
href="#word-indices"
84+
class="block py-1 text-gray-700 underline decoration-gray-400/40 underline-offset-2 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
85+
>Word indices and tokenization</a
86+
>
87+
</li>
88+
<li>
89+
<a
90+
href="#errors"
91+
class="block py-1 text-gray-700 underline decoration-gray-400/40 underline-offset-2 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white"
92+
>Errors</a
93+
>
94+
</li>
95+
</ul>
96+
</nav>
97+
98+
<h2 id="post-api-align" class={headingClass}>POST /api/align</h2>
99+
<p class="mt-3">
100+
The main endpoint. Returns a URL to Word Aligner with the given lines and alignment links
101+
pre-filled.
102+
</p>
103+
104+
<h3 class="mt-6 font-semibold text-gray-900 dark:text-white">Request</h3>
105+
<p class="mt-2 text-sm"><span class={codeClass}>Content-Type: application/json</span></p>
106+
107+
<div class="mt-4 overflow-x-auto rounded-md border border-gray-200 dark:border-gray-700">
108+
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
109+
<thead class="bg-gray-50 dark:bg-gray-800/60">
110+
<tr>
111+
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Field</th>
112+
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Type</th>
113+
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300"
114+
>Description</th
115+
>
116+
</tr>
117+
</thead>
118+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60">
119+
<tr>
120+
<td class="px-4 py-2 font-mono text-gray-800 dark:text-gray-200">lines</td>
121+
<td class="px-4 py-2 text-gray-600 dark:text-gray-400">string[] (required)</td>
122+
<td class="px-4 py-2 text-gray-700 dark:text-gray-300"
123+
>Text lines, top to bottom. 1–8 lines.</td
124+
>
125+
</tr>
126+
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
127+
<td class="px-4 py-2 font-mono text-gray-800 dark:text-gray-200">alignments</td>
128+
<td class="px-4 py-2 text-gray-600 dark:text-gray-400">[int,int,int,int][] (optional)</td>
129+
<td class="px-4 py-2 text-gray-700 dark:text-gray-300"
130+
>Word-alignment pairs as <span class={codeClass}>[lineA, wordA, lineB, wordB]</span>. Lines
131+
A and B must be adjacent (<span class={codeClass}>|A−B| = 1</span>). Indices are 0-based.</td
132+
>
133+
</tr>
134+
</tbody>
135+
</table>
136+
</div>
137+
138+
<h3 class="mt-6 font-semibold text-gray-900 dark:text-white">Response</h3>
139+
<pre class="{preClass} mt-3">{`{ "url": "https://${ALIGNER_SITE_HOST}/?data=..." }`}</pre>
140+
141+
<h3 class="mt-6 font-semibold text-gray-900 dark:text-white">Example</h3>
142+
<pre class="{preClass} mt-3">{`curl -X POST ${apiBase}/api/align \\
143+
-H "Content-Type: application/json" \\
144+
-d '{
145+
"lines": ["Hello world", "Bonjour le monde"],
146+
"alignments": [
147+
[0, 0, 1, 0],
148+
[0, 1, 1, 2]
149+
]
150+
}'`}</pre>
151+
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
152+
This links "Hello" → "Bonjour" (word 0 of line 0 to word 0 of line 1) and "world" → "monde"
153+
(word 1 of line 0 to word 2 of line 1 — "le" is word 1, "monde" is word 2).
154+
</p>
155+
156+
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
157+
Response:
158+
</p>
159+
<pre class="{preClass} mt-2">{`{ "url": "https://${ALIGNER_SITE_HOST}/?data=..." }`}</pre>
160+
161+
<h2 id="get-api-align" class={headingClass}>GET /api/align</h2>
162+
<p class="mt-3">
163+
Simple variant for quick links: pass lines as repeated query parameters. No alignments. Useful
164+
for opening the editor pre-filled via a link.
165+
</p>
166+
167+
<pre class="{preClass} mt-4">{`GET /api/align?lines=Hello+world&lines=Bonjour+le+monde`}</pre>
168+
169+
<p class="mt-3 text-sm">
170+
<strong class="text-gray-900 dark:text-white">Parameters:</strong>
171+
<span class={codeClass}>lines</span> — repeat for each line (1–8).
172+
</p>
173+
174+
<h2 id="word-indices" class={headingClass}>Word indices and tokenization</h2>
175+
<p class="mt-3">
176+
Words are counted from 0, left to right as written. The default split rules: whitespace always
177+
splits, and the characters <span class={codeClass}>.</span>
178+
<span class={codeClass}>-</span>
179+
<span class={codeClass}>|</span> also create word boundaries. Punctuation is not split into
180+
separate tokens by default.
181+
</p>
182+
<p class="mt-3">Example — <em>"Bonjour le monde"</em>:</p>
183+
<div class="mt-3 overflow-x-auto rounded-md border border-gray-200 dark:border-gray-700">
184+
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
185+
<thead class="bg-gray-50 dark:bg-gray-800/60">
186+
<tr>
187+
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Index</th>
188+
<th class="px-4 py-2 text-left font-semibold text-gray-700 dark:text-gray-300">Word</th>
189+
</tr>
190+
</thead>
191+
<tbody class="divide-y divide-gray-100 dark:divide-gray-700/60">
192+
<tr>
193+
<td class="px-4 py-2 font-mono text-gray-800 dark:text-gray-200">0</td>
194+
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">Bonjour</td>
195+
</tr>
196+
<tr class="bg-gray-50/50 dark:bg-gray-800/20">
197+
<td class="px-4 py-2 font-mono text-gray-800 dark:text-gray-200">1</td>
198+
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">le</td>
199+
</tr>
200+
<tr>
201+
<td class="px-4 py-2 font-mono text-gray-800 dark:text-gray-200">2</td>
202+
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">monde</td>
203+
</tr>
204+
</tbody>
205+
</table>
206+
</div>
207+
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
208+
If you are unsure about word counts, use the GET endpoint without alignments first — open the
209+
returned URL and count the word boxes in the editor.
210+
</p>
211+
212+
<h2 id="errors" class={headingClass}>Errors</h2>
213+
<p class="mt-3">
214+
Errors return HTTP 400 with a JSON body:
215+
</p>
216+
<pre class="{preClass} mt-3">{`{ "error": "alignments[0]: lines 0 and 2 are not adjacent" }`}</pre>
217+
218+
<p class="mt-3 text-sm text-gray-500 dark:text-gray-400">
219+
All endpoints support CORS (<span class={codeClass}>Access-Control-Allow-Origin: *</span>).
220+
</p>
221+
222+
<SiteFooter class="mt-12" />
223+
</main>
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { json } from '@sveltejs/kit';
2+
import type { RequestHandler } from './$types.js';
3+
import { encodeState } from '$lib/serialization/encode.js';
4+
import { tokenize, tokenizeOptionsFromVisualSettings } from '$lib/domain/tokens.js';
5+
import { createConnectionId, type Connection } from '$lib/domain/alignment.js';
6+
import { assignColorsInOrder } from '$lib/domain/palettes.js';
7+
import {
8+
defaultVisualSettingsV2,
9+
SCHEMA_VERSION,
10+
type AppStateV2,
11+
type LineV2
12+
} from '$lib/serialization/schema.js';
13+
14+
const CORS = {
15+
'Access-Control-Allow-Origin': '*',
16+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
17+
'Access-Control-Allow-Headers': 'Content-Type'
18+
};
19+
20+
const DEFAULT_FONT = { family: 'Inter', source: 'google' as const };
21+
const DEFAULT_TEXT_SIZE_PX = 36;
22+
const DEFAULT_WORD_GAP_PX = 14;
23+
24+
type AlignmentTuple = [number, number, number, number];
25+
26+
interface AlignRequest {
27+
lines: string[];
28+
alignments?: AlignmentTuple[];
29+
}
30+
31+
function parseBody(body: unknown): { ok: AlignRequest } | { err: string } {
32+
if (!body || typeof body !== 'object') return { err: 'Body must be a JSON object' };
33+
const b = body as Record<string, unknown>;
34+
35+
if (!Array.isArray(b.lines) || b.lines.length === 0)
36+
return { err: '"lines" must be a non-empty array' };
37+
38+
const lines: string[] = [];
39+
for (const l of b.lines) {
40+
if (typeof l !== 'string') return { err: 'Each element of "lines" must be a string' };
41+
lines.push(l);
42+
}
43+
if (lines.length > 8) return { err: 'Maximum 8 lines allowed' };
44+
45+
const alignments: AlignmentTuple[] = [];
46+
if (b.alignments !== undefined) {
47+
if (!Array.isArray(b.alignments)) return { err: '"alignments" must be an array' };
48+
for (const a of b.alignments) {
49+
if (
50+
!Array.isArray(a) ||
51+
a.length !== 4 ||
52+
a.some((x) => typeof x !== 'number' || !Number.isInteger(x))
53+
)
54+
return { err: 'Each alignment must be [lineA, wordA, lineB, wordB] (integers)' };
55+
alignments.push(a as AlignmentTuple);
56+
}
57+
}
58+
59+
return { ok: { lines, alignments } };
60+
}
61+
62+
function buildAlignUrl(
63+
origin: string,
64+
req: AlignRequest
65+
): { url: string } | { err: string } {
66+
const settings = defaultVisualSettingsV2();
67+
const tzOpts = tokenizeOptionsFromVisualSettings(settings);
68+
const { lines, alignments = [] } = req;
69+
70+
const lineObjects: LineV2[] = lines.map((rawText, i) => ({
71+
id: `l${i}`,
72+
rawText,
73+
font: { ...DEFAULT_FONT },
74+
textSizePx: DEFAULT_TEXT_SIZE_PX,
75+
gapWordPx: DEFAULT_WORD_GAP_PX
76+
}));
77+
78+
const tokensByLine = lineObjects.map((line) => tokenize(line.rawText, line.id, tzOpts));
79+
const colors = assignColorsInOrder(settings.palette, Math.max(alignments.length, 1));
80+
81+
const connections: Connection[] = [];
82+
for (let idx = 0; idx < alignments.length; idx++) {
83+
const [lineA, wordA, lineB, wordB] = alignments[idx]!;
84+
85+
if (lineA < 0 || lineA >= lines.length)
86+
return { err: `alignments[${idx}]: lineA=${lineA} out of range (0–${lines.length - 1})` };
87+
if (lineB < 0 || lineB >= lines.length)
88+
return { err: `alignments[${idx}]: lineB=${lineB} out of range (0–${lines.length - 1})` };
89+
if (Math.abs(lineA - lineB) !== 1)
90+
return {
91+
err: `alignments[${idx}]: lines ${lineA} and ${lineB} are not adjacent (connections only allowed between adjacent lines)`
92+
};
93+
94+
const upperIdx = Math.min(lineA, lineB);
95+
const lowerIdx = Math.max(lineA, lineB);
96+
const upperWordIdx = lineA < lineB ? wordA : wordB;
97+
const lowerWordIdx = lineA < lineB ? wordB : wordA;
98+
99+
const upperTokens = tokensByLine[upperIdx]!;
100+
const lowerTokens = tokensByLine[lowerIdx]!;
101+
102+
if (upperWordIdx < 0 || upperWordIdx >= upperTokens.length)
103+
return {
104+
err: `alignments[${idx}]: word ${upperWordIdx} out of range for line ${upperIdx} ("${lines[upperIdx]}" has ${upperTokens.length} word(s))`
105+
};
106+
if (lowerWordIdx < 0 || lowerWordIdx >= lowerTokens.length)
107+
return {
108+
err: `alignments[${idx}]: word ${lowerWordIdx} out of range for line ${lowerIdx} ("${lines[lowerIdx]}" has ${lowerTokens.length} word(s))`
109+
};
110+
111+
connections.push({
112+
id: createConnectionId(),
113+
upperTokenId: upperTokens[upperWordIdx]!.id,
114+
lowerTokenId: lowerTokens[lowerWordIdx]!.id,
115+
color: colors[idx % colors.length]
116+
});
117+
}
118+
119+
const state: AppStateV2 = {
120+
v: SCHEMA_VERSION,
121+
project: { lines: lineObjects, pairControls: [], linePairGaps: [], connections },
122+
settings
123+
};
124+
125+
const dataParam = encodeState(state);
126+
const u = new URL('/', origin);
127+
u.searchParams.set('data', dataParam);
128+
return { url: u.toString() };
129+
}
130+
131+
export const OPTIONS: RequestHandler = () => new Response(null, { status: 204, headers: CORS });
132+
133+
export const GET: RequestHandler = ({ url }) => {
134+
const lines = url.searchParams.getAll('lines');
135+
if (!lines.length)
136+
return json(
137+
{ error: 'Provide at least one ?lines= parameter' },
138+
{ status: 400, headers: CORS }
139+
);
140+
if (lines.length > 8)
141+
return json({ error: 'Maximum 8 lines allowed' }, { status: 400, headers: CORS });
142+
143+
const result = buildAlignUrl(url.origin, { lines });
144+
if ('err' in result) return json({ error: result.err }, { status: 400, headers: CORS });
145+
return json(result, { headers: CORS });
146+
};
147+
148+
export const POST: RequestHandler = async ({ request, url }) => {
149+
let body: unknown;
150+
try {
151+
body = await request.json();
152+
} catch {
153+
return json({ error: 'Invalid JSON' }, { status: 400, headers: CORS });
154+
}
155+
156+
const parsed = parseBody(body);
157+
if ('err' in parsed) return json({ error: parsed.err }, { status: 400, headers: CORS });
158+
159+
const result = buildAlignUrl(url.origin, parsed.ok);
160+
if ('err' in result) return json({ error: result.err }, { status: 400, headers: CORS });
161+
return json(result, { headers: CORS });
162+
};

0 commit comments

Comments
 (0)