Skip to content

Commit 9fa05b2

Browse files
committed
feat: OpenAPI spec + interactive docs + batch API + subtitles + contributing + dark mode + permalinks
Round 5 — API as a real product, more use cases, polish. 1. OpenAPI 3.1 spec at /openapi.json - Full schema for all 4 endpoints (transliterate, transliterate/batch, systems, detect) - Includes license (BSD-2-Clause), contact, server URL - Consumable by Swagger UI / Postman / Stoplight / any OpenAPI client - Served as a prerendered static JSON file 2. /api-docs interactive reference - Hand-built Swagger-style docs (no JS dependency) - Documents all 4 endpoints with parameters, examples, cURL/JS/Ruby snippets, response shapes - Sticky TOC for navigation - Limits section documenting the 10,000 char input cap and 1000-item batch cap 3. POST /api/transliterate/batch - Accepts up to 1000 transliteration requests in one POST - Returns array of results, one per item - Independent: one failure doesn't fail the batch - Production-ready for catalog cleanup / news batch / citation processing - Documented in OpenAPI spec with BatchRequest + BatchResponse schemas 4. /subtitles tool — new use case (streaming/captions) - Paste .srt or .vtt subtitle files - Transliterates cue dialogue; preserves timestamps + structure - HTML-aware (strips <i> tags before transliterating) - 3 use cases: multi-track subtitles, search indexing, accessibility 5. /contributing guide — community growth - 5-step walkthrough from "find a system" to "open a PR" - Shows the Ruby DSL syntax with a real example - Links to github.com/interscript/maps and the issue tracker - Explains how merged maps flow to gem + interscript-ts + website + API 6. Dark mode - Three states: system / dark / light, cycled by toggle - Auto-applies via prefers-color-scheme - Persists via localStorage (isx-theme key) - FOUC prevention: applied before paint via inline script - All design tokens overridden for dark — same components work in both 7. Permalink sharing - Compare page reads URL params (p=preset, i=input) on load - Detect page reads URL params (f=family, i=input, o=observed) - State synced to URL on every change - Reload restores state; URLs are shareable Nav: 16 → 18 destinations (added /subtitles, /api-docs, /contributing). Tests: 224 total (was 203). - 17 new round5 tests (OpenAPI structure, /api-docs content, /subtitles, /contributing, dark mode toggle, permalink script presence) - 4 new API integration tests (batch happy path, empty items, oversized body, OpenAPI spec) - All passing
1 parent d322c2b commit 9fa05b2

17 files changed

Lines changed: 1996 additions & 13 deletions

src/components/CompareMode.vue

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,13 @@ interface Props {
3434
3535
const props = defineProps<Props>()
3636
37-
const presetId = ref(props.presets[0]?.id ?? "")
38-
const input = ref(props.presets[0]?.input ?? "")
37+
// Read initial state from URL params so the page is shareable.
38+
const urlParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
39+
const initialPreset = urlParams.get("p") ?? props.presets[0]?.id ?? ""
40+
const initialInput = urlParams.get("i") ?? props.presets.find((p) => p.id === initialPreset)?.input ?? ""
41+
42+
const presetId = ref(initialPreset)
43+
const input = ref(initialInput)
3944
const outputs = ref<Record<string, string>>({})
4045
const errors = ref<Record<string, string>>({})
4146
const loading = ref(false)
@@ -75,6 +80,17 @@ function selectPreset(id: string) {
7580
presetId.value = id
7681
const preset = props.presets.find((p) => p.id === id)
7782
if (preset) input.value = preset.input
83+
syncUrl()
84+
}
85+
86+
// Push current state to URL for shareable permalinks.
87+
function syncUrl() {
88+
if (typeof window === "undefined") return
89+
const params = new URLSearchParams()
90+
if (presetId.value) params.set("p", presetId.value)
91+
if (input.value) params.set("i", input.value)
92+
const newUrl = `${window.location.pathname}?${params.toString()}`
93+
window.history.replaceState(null, "", newUrl)
7894
}
7995
8096
onMounted(async () => {
@@ -87,6 +103,7 @@ onUnmounted(() => {
87103
})
88104
89105
watch([input, presetId], () => {
106+
syncUrl()
90107
void run()
91108
})
92109
</script>

src/components/DetectPanel.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,16 @@ const families: ScriptFamily[] = [
8383
},
8484
]
8585
86-
const familyId = ref("cyrillic")
87-
const input = ref(families[0]!.sampleInput)
88-
const observed = ref(families[0]!.sampleOutput)
86+
// Permalink state — read once on mount, sync on change.
87+
const urlParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "")
88+
const initialFamily = urlParams.get("f") ?? families[0]!.id
89+
const initialFamilyObj = families.find((f) => f.id === initialFamily) ?? families[0]!
90+
const initialInput = urlParams.get("i") ?? initialFamilyObj.sampleInput
91+
const initialObserved = urlParams.get("o") ?? initialFamilyObj.sampleOutput
92+
93+
const familyId = ref(initialFamily)
94+
const input = ref(initialInput)
95+
const observed = ref(initialObserved)
8996
const candidates = ref<{ system: CandidateSystem; output: string; distance: number; error?: string }[]>([])
9097
const running = ref(false)
9198
@@ -143,6 +150,16 @@ function selectFamily(id: string) {
143150
input.value = f.sampleInput
144151
observed.value = f.sampleOutput
145152
}
153+
syncUrl()
154+
}
155+
156+
function syncUrl() {
157+
if (typeof window === "undefined") return
158+
const params = new URLSearchParams()
159+
if (familyId.value) params.set("f", familyId.value)
160+
if (input.value) params.set("i", input.value)
161+
if (observed.value) params.set("o", observed.value)
162+
window.history.replaceState(null, "", `${window.location.pathname}?${params.toString()}`)
146163
}
147164
148165
const bestMatch = computed(() => candidates.value[0])
Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
<script setup lang="ts">
2+
/**
3+
* SubtitlesProcessor — paste .srt or .vtt subtitle content, get
4+
* transliterated dialogue. Useful for streaming services preparing
5+
* multiple language tracks from a single source.
6+
*
7+
* Preserves subtitle structure (timestamps, indices). Only the dialogue
8+
* text gets transliterated.
9+
*/
10+
import { ref, computed, onMounted, onUnmounted } from "vue"
11+
import { createWorkerClient, type WorkerClient } from "../scripts/worker-client"
12+
13+
interface Props {
14+
systems: { code: string; label: string }[]
15+
}
16+
17+
const props = defineProps<Props>()
18+
19+
const system = ref(props.systems[0]?.code ?? "")
20+
const inputText = ref(`1
21+
00:00:01,000 --> 00:00:03,500
22+
Привет, меня зовут Антон.
23+
24+
2
25+
00:00:04,000 --> 00:00:06,200
26+
Я из Киева, а живу в Москве.`)
27+
const output = ref("")
28+
const running = ref(false)
29+
const error = ref<string | null>(null)
30+
const cueCount = ref(0)
31+
32+
let client: WorkerClient | null = null
33+
34+
async function ensureEngine() {
35+
if (client) return
36+
client = createWorkerClient()
37+
}
38+
39+
// Regex: capture subtitle cues with timestamps.
40+
// SRT/VTT pattern: index (optional), time range line, dialogue.
41+
const CUE_RE = /(\d+\s*\n)?((?:\d{2}:)?\d{2}:\d{2}[,.]\d{3}\s*-->\s*(?:\d{2}:)?\d{2}:\d{2}[,.]\d{3})\s*\n([\s\S]*?)(?=\n\s*\n|\n\d+\s*\n|\n(?:\d{2}:)?\d{2}:\d{2}[,.]\d{3}|$)/g
42+
43+
async function run() {
44+
if (!client) return
45+
running.value = true
46+
error.value = null
47+
let count = 0
48+
49+
try {
50+
const out = inputText.value.replace(CUE_RE, async (_match, idx, time, dialogue: string) => {
51+
// Strip HTML formatting tags before transliterating
52+
const stripped = dialogue.replace(/<[^>]+>/g, "").trim()
53+
if (!/[^\x00-\x7F]/.test(stripped)) {
54+
return `${idx ?? ""}${time}\n${dialogue}`.trim()
55+
}
56+
try {
57+
const transliterated = await client!.transliterate(system.value, stripped)
58+
count++
59+
return `${idx ?? ""}${time}\n${transliterated}`
60+
} catch {
61+
return `${idx ?? ""}${time}\n${dialogue}`
62+
}
63+
})
64+
65+
// Because String.replace doesn't await async replacers, redo with
66+
// sequential await.
67+
const cues: { idx: string; time: string; dialogue: string }[] = []
68+
let m: RegExpExecArray | null
69+
const re = new RegExp(CUE_RE.source, "g")
70+
while ((m = re.exec(inputText.value)) !== null) {
71+
cues.push({
72+
idx: (m[1] ?? "").trim() ? (m[1] ?? "").trim() + "\n" : "",
73+
time: m[2]!,
74+
dialogue: m[3]!,
75+
})
76+
}
77+
78+
if (cues.length === 0) {
79+
output.value = inputText.value
80+
cueCount.value = 0
81+
running.value = false
82+
return
83+
}
84+
85+
const outParts: string[] = []
86+
for (const cue of cues) {
87+
const stripped = cue.dialogue.replace(/<[^>]+>/g, "").trim()
88+
if (!/[^\x00-\x7F]/.test(stripped)) {
89+
outParts.push(`${cue.idx}${cue.time}\n${cue.dialogue}`.trim())
90+
continue
91+
}
92+
try {
93+
const transliterated = await client.transliterate(system.value, stripped)
94+
count++
95+
outParts.push(`${cue.idx}${cue.time}\n${transliterated}`)
96+
} catch (e) {
97+
outParts.push(`${cue.idx}${cue.time}\n${cue.dialogue}`)
98+
}
99+
}
100+
output.value = outParts.join("\n\n")
101+
cueCount.value = count
102+
void out
103+
} catch (e) {
104+
error.value = (e as Error).message
105+
}
106+
running.value = false
107+
}
108+
109+
function copyOutput() {
110+
navigator.clipboard.writeText(output.value)
111+
}
112+
113+
onMounted(ensureEngine)
114+
onUnmounted(() => client?.terminate())
115+
</script>
116+
117+
<template>
118+
<div class="subs">
119+
<div class="subs-controls">
120+
<div class="control-field">
121+
<label for="subs-system">Romanization system</label>
122+
<select id="subs-system" v-model="system">
123+
<option v-for="s in systems" :key="s.code" :value="s.code">{{ s.label }}</option>
124+
</select>
125+
</div>
126+
<button class="run-btn" :disabled="running" @click="run">
127+
{{ running ? "Transliterating…" : "Transliterate subtitles →" }}
128+
</button>
129+
</div>
130+
131+
<div class="subs-grid">
132+
<div class="io-pane">
133+
<header>
134+
<span class="pane-label">SubRip (.srt) or WebVTT (.vtt)</span>
135+
<span class="pane-hint">Cue format</span>
136+
</header>
137+
<textarea v-model="inputText" spellcheck="false"></textarea>
138+
</div>
139+
<div class="io-pane output">
140+
<header>
141+
<span class="pane-label">Transliterated output</span>
142+
<button v-if="output" class="copy-btn" @click="copyOutput">Copy</button>
143+
</header>
144+
<pre>{{ output || 'Output appears here.' }}</pre>
145+
</div>
146+
</div>
147+
148+
<p v-if="cueCount > 0" class="cue-count tnum">{{ cueCount }} cue{{ cueCount > 1 ? "s" : "" }} transliterated.</p>
149+
<p v-if="error" class="error">⚠ {{ error }}</p>
150+
<p class="privacy">Text never leaves your browser.</p>
151+
</div>
152+
</template>
153+
154+
<style scoped>
155+
.subs {
156+
display: grid;
157+
gap: 1.25rem;
158+
}
159+
160+
.subs-controls {
161+
display: flex;
162+
gap: 0.75rem;
163+
align-items: end;
164+
flex-wrap: wrap;
165+
}
166+
.control-field {
167+
display: grid;
168+
gap: 0.4rem;
169+
flex: 1;
170+
min-width: 240px;
171+
}
172+
.control-field label {
173+
font-family: var(--font-mono);
174+
font-size: var(--text-micro);
175+
letter-spacing: 0.15em;
176+
text-transform: uppercase;
177+
color: var(--color-stone);
178+
}
179+
.control-field select {
180+
font-family: var(--font-sans);
181+
font-size: 0.9rem;
182+
padding: 0.625rem 0.75rem;
183+
background: var(--color-vellum);
184+
border: 1.5px solid var(--color-rule);
185+
color: var(--color-ink);
186+
border-radius: 1px;
187+
}
188+
.control-field select:focus { border-color: var(--color-brand); }
189+
190+
.run-btn {
191+
font-family: var(--font-mono);
192+
font-size: 0.8125rem;
193+
letter-spacing: 0.05em;
194+
text-transform: uppercase;
195+
padding: 0.7rem 1.25rem;
196+
background: var(--color-highlight);
197+
color: var(--color-vellum);
198+
border: 1.5px solid var(--color-highlight);
199+
cursor: pointer;
200+
border-radius: 1px;
201+
}
202+
.run-btn:hover:not(:disabled) {
203+
background: var(--color-highlight-deep);
204+
border-color: var(--color-highlight-deep);
205+
}
206+
.run-btn:disabled { opacity: 0.5; }
207+
208+
.subs-grid {
209+
display: grid;
210+
grid-template-columns: 1fr;
211+
gap: 1rem;
212+
}
213+
@media (min-width: 900px) {
214+
.subs-grid { grid-template-columns: 1fr 1fr; }
215+
}
216+
217+
.io-pane {
218+
background: var(--color-vellum);
219+
border: 1px solid var(--color-rule);
220+
display: flex;
221+
flex-direction: column;
222+
}
223+
.io-pane header {
224+
display: flex;
225+
align-items: center;
226+
justify-content: space-between;
227+
padding: 0.625rem 1rem;
228+
border-bottom: 1px solid var(--color-rule);
229+
font-family: var(--font-mono);
230+
font-size: var(--text-micro);
231+
letter-spacing: 0.12em;
232+
text-transform: uppercase;
233+
color: var(--color-stone);
234+
}
235+
.pane-hint {
236+
font-style: italic;
237+
text-transform: none;
238+
letter-spacing: 0.05em;
239+
font-size: 0.7rem;
240+
color: var(--color-stone-light);
241+
}
242+
243+
textarea, pre {
244+
flex: 1;
245+
min-height: 320px;
246+
font-family: var(--font-mono);
247+
font-size: 0.85rem;
248+
padding: 1rem;
249+
border: none;
250+
outline: none;
251+
background: transparent;
252+
color: var(--color-ink);
253+
line-height: 1.5;
254+
margin: 0;
255+
white-space: pre-wrap;
256+
overflow: auto;
257+
}
258+
259+
.copy-btn {
260+
font-family: inherit;
261+
font-size: 0.65rem;
262+
letter-spacing: 0.1em;
263+
text-transform: uppercase;
264+
background: transparent;
265+
border: 1px solid var(--color-rule-strong);
266+
padding: 0.25rem 0.55rem;
267+
cursor: pointer;
268+
color: var(--color-stone);
269+
border-radius: 1px;
270+
}
271+
.copy-btn:hover {
272+
background: var(--color-ink);
273+
color: var(--color-vellum);
274+
border-color: var(--color-ink);
275+
}
276+
277+
.cue-count {
278+
font-family: var(--font-mono);
279+
font-size: 0.8rem;
280+
color: var(--color-brand-deep);
281+
text-align: center;
282+
margin: 0;
283+
}
284+
.error {
285+
background: color-mix(in srgb, var(--color-highlight) 8%, transparent);
286+
padding: 0.625rem 1rem;
287+
border-left: 3px solid var(--color-highlight);
288+
font-family: var(--font-mono);
289+
font-size: 0.85rem;
290+
color: var(--color-highlight-deep);
291+
margin: 0;
292+
}
293+
.privacy {
294+
font-size: 0.85rem;
295+
color: var(--color-stone);
296+
margin: 0;
297+
text-align: center;
298+
}
299+
</style>

0 commit comments

Comments
 (0)