Building the Input/Output UI in Astro

Romaji to Kana Converter: Astro

Chapter 4 · Building the Input/Output UI in Astro

Chapters 2 and 3 built a complete, correctly-tested conversion engine with zero Astro dependency anywhere in it. This chapter gives src/lib/convert.ts its first real caller — replacing Chapter 1's own placeholder page with an actual input box and live kana output, built entirely with a plain client-side script and no framework, no island, and no extra dependency installed.

A Plain <script> Tag Is All This Needs

Astro processes an ordinary <script> tag — one with no attributes beyond an optional src — through a real, specific pipeline, verified directly against Astro's own current documentation rather than assumed:

  • It's bundled by Vite. The script becomes a real ES module, deduplicated automatically if the same component ever renders more than once on a page, and small scripts get inlined into the HTML automatically.
  • It supports real ES module imports. A default script can import both local files from src/ and npm packages — which is exactly what lets this page pull in convert from src/lib/convert.ts with an ordinary relative import.
  • It's TypeScript by default. Astro's own documentation states this plainly — no extra config, no lang="ts" attribute, nothing to opt into.

The one thing that turns all of this off is the is:inline directive — Astro's own documentation is direct about what it does: a script marked is:inline is rendered into the HTML exactly as written, with no TypeScript support and no import resolution at all. That's the right choice for an external CDN script or something already sitting in public/; it's the wrong choice here, since this page's whole script depends on importing a real local TypeScript module.

The Page Markup

A text input, and two separate output areas — one per script, matching how convert() has returned both since Chapter 2:

<!-- src/pages/index.astro --> <html lang="en"> <head> <meta charset="utf-8" /> <title>Romaji to Kana Converter</title> </head> <body> <main> <h1>Romaji to Kana Converter</h1> <label for="romaji-input">Type romaji:</label> <input id="romaji-input" type="text" placeholder="e.g. konnichiwa" autocomplete="off" /> <section> <h2>Hiragana</h2> <p id="hiragana-output"></p> </section> <section> <h2>Katakana</h2> <p id="katakana-output"></p> </section> </main> <!-- script goes here — see the next section --> </body> </html>

No Astro frontmatter fence is needed anywhere in this file — nothing about this page runs at build time or request time on the server at all. Every part of what makes this page actually work happens in the browser, after the static HTML above has already loaded.

Wiring the Script

Imported directly, called on every keystroke, with the two output elements updated from the exact same { hiragana, katakana } shape convert() has returned since Chapter 2:

<script> import { convert } from '../lib/convert'; const input = document.getElementById('romaji-input') as HTMLInputElement; const hiraganaOutput = document.getElementById('hiragana-output')!; const katakanaOutput = document.getElementById('katakana-output')!; input.addEventListener('input', () => { const { hiragana, katakana } = convert(input.value); hiraganaOutput.textContent = hiragana; katakanaOutput.textContent = katakana; }); </script>

Running npm run dev and typing into the input box updates both output areas on every single keystroke — no submit button, no page reload, and (per the section above) no bundler configuration of any kind beyond what a default Astro scaffold already provides.

A Real, Honest Finding: Converting Live, Mid-Token

Converting on every keystroke means the engine is regularly asked to convert a string that isn't a complete word yet — and Chapter 2's own longest-to-shortest tokenizer has a real, predictable answer for that: whatever hasn't matched a real token yet passes through untouched. Typing the word kya (客-adjacent syllable きゃ) one letter at a time, and calling convert() after each one, produces exactly this, verified directly against real output:

after typing "k": "k" after typing "ky": "ky" after typing "kya": "きゃ"
Expected Behavior, Not a Bug — But Worth Knowing About
While a three-character token like kya is still incomplete, the tokenizer correctly has no valid match for "k" or "ky" on their own, so both letters pass straight through as raw Latin text — exactly the same "no match, emit the raw character" fallback Chapter 2 built for genuinely unrecognized input. The instant the third letter completes a real token, the output snaps directly from raw Latin to the correct kana in one keystroke. This is the algorithm working exactly as specified, not a glitch — but a first-time user watching text flicker between "unconverted" and "converted" while typing a yōon syllable could easily read it as broken if this behavior isn't expected going in.

こんにちは shows the same effect on a larger scale: since convert() checks the lexicalized-exception dictionary against the whole word, typing "konnichiw" — one letter short of the full greeting — produces the ordinary tokenized-but-uncorrected output, and only the final "a" completes the exact string the dictionary is keyed on, snapping the whole output to こんにちは in a single keystroke.

No Debouncing Needed — And That's Worth Noticing
Converting on every keystroke, with no delay, works fine here specifically because convert() is a plain, synchronous, entirely local function call — there's no network request to rate-limit, and no latency for a fast typist to outrun. A server-backed version of this exact same feature would have a real reason to debounce every keystroke into a single delayed request instead of firing one per letter — that's precisely the kind of tradeoff Chapter 5's own client-vs-server comparison exists to measure directly, rather than this chapter simply declaring one approach better.

Trying It End to End

With the script wired in, typing each of the words already verified across Chapters 2 and 3 reproduces the exact same output live in the browser that node already confirmed on the command line:

TypedHiragana outputKatakana output
sushiすしスシ
gakuseiがくせいガクセイ
konnichiwaこんにちはコンニチハ

Nothing about this UI depends on where the conversion logic actually runs — convert() is called the same way whether it's the local function this chapter imports directly, or (per Chapter 5) a fetch call to a real endpoint returning the identical shape. That's deliberate: this chapter builds the UI around a stable interface, so the comparison in Chapter 5 can swap out what's behind it without touching the markup or the event listener at all.

Hands-On Exercises

Exercise 1

Build index.astro exactly as this chapter describes, run npm run dev, and type "kya" into the input box one letter at a time. Confirm you see the same live intermediate output this chapter verified (k, then ky, then きゃ) before writing down what you observed.

📄 View solution
Exercise 2

Add a temporary console.log inside the input event handler, logging the raw input value and the converted hiragana/katakana on every keystroke. Open the browser console and confirm it fires exactly once per keystroke — not more, not less — while typing a full word.

📄 View solution
Exercise 3

In your own words, explain why this page's on-input conversion needs no debouncing at all, and name specifically what would have to change about where the conversion logic runs for debouncing to genuinely become necessary.

📄 View solution

Chapter 4 Quick Reference

  • index.astro — a plain input box plus two output areas, no frontmatter needed since nothing runs server-side here
  • A default <script> tag — bundled by Vite, TypeScript by default, real ES module imports supported, verified against Astro's own current documentation
  • is:inline — the one thing that turns all of that off; the wrong choice here, since this page depends on importing a real local module
  • Live conversion, verified — an incomplete multi-character token (k, ky) passes through as raw Latin text until the full token completes, then snaps to the correct kana in one keystroke — expected, not a bug
  • No debouncing needed — convert() is synchronous and entirely local, with no network request to rate-limit
  • Stable interface — the UI calls convert() the same way regardless of where the logic behind it actually runs, setting up Chapter 5's own comparison cleanly
  • Next chapter: Client-Side vs. Server-Side Conversion — building a real server-rendered alternative and measuring it directly against this one