Tuesday, 28 July 2026

A Software Developer's Vacation In Singapore: Coast-to-Coast Trail Edition (Part 3/4)

The next time I tried this stretch again, it was with an eye to getting all the way to Rower's Bay Park, which was Checkpoint Ten.

Because I enjoyed the ride from Checkpoint Six to Seven so much, I decided to do it all over again, taking a bus to And Mo Kio Linear Park and renting the Anywheel bike from there. Once I hit Sengkang Swimming Complex, the exploration started in earnest.

What followed was something that felt like a masterclass in urban planning to a layperson like me. The paths were beautifully flat. This was made for cyclists, and it showed. I cycled right through Sengkang Riverside Park, absorbing plenty of sights on the way. Honestly, I never thought any place outside of the usual tourist attractions could be so damn pretty.

This part was actually the
least extravagantly pretty.

I crossed this charming little bridge over Punggol Waterway, and the view was... let's just say my photos don't do it justice. They call it Jewel Bridge.

View from the bridge.

This route took me right by Waterway Point and Punggol Sports Center. At this point, I think my gushing over how picturesque everything is, is just getting repetitive. But at this point, I encountered Checkpoint Nine.

So pretty.

Even prettier.

Checkpoint Eight.

This route eventually led to this aquaduct called the Waterworks, by Waterway Ridges Rain Garden. This was quite a pretty neighborhood. Looked expensive, too.

Fancy-looking canal.

Turning in, I could see Coney Island as I cycled along Punggol Promenade Nature Walk.

Coney Island across the water.

And this was the entry to Coney Island, Checkpoint Nine. I actually went in to take a little tour, but didn't go that far.

Checkpoint Nine.

Now, at this point I had to make a decision. The actual path to Checkpoint Ten was from Checkpoint Seven. Yes, Checkpoint Seven was a fork in the Trail where the branch I took led to Checkpoints Eight and Nine, while the other branch led to Checkpoint Ten. Which meant that if I wanted to do things the "official" way, I needed to ride back to Checkpoint Seven and then proceed to Checkpoint Ten.

Nah. Fork that noise.

I pushed on to Punggol Beach and continued riding west along Northshore. Eventually, I emerged into Seletar Link North, onto the manmade island known as Punggol Timor. This was a markedly different vibe - mostly industrial. Much of the traffic was heavy trucks and pickups.

A lot of sand.

The first landmark I passed was this entire section of sand mountains. If I had to hazard a guess, this would be where they keep the sand they used to reclaim this island.

This was... nice!

Much of the scenery ahead was green wilderness. But I did come across this little channel, and simply had to stop to take a picture. With the wind and all, it was pretty surreal. This was the channel that separated Punggol Timor, which I was currently on, and Punggol Barat, which I was riding through next.

The rest of the ride from that point on, was a slog. The path was narrow and I was going past some extensive construction work. On the other hand, the sheer simplicity of the path ahead, held its own appeal. It was as ghetto as the crap I had to slog through in Ang Mo Kio and Bishan, but at least it was a largely uninterrupted path.

Soon, I crossed another bridge and found myself back on the mainland. Across the road was the verdant entrance of Rower's Bay Park, Checkpoint Ten and the final stop on the Coast-to-Coast Trail.

Checkpoint Ten

I had tracked down all ten checkpoints; now all I had to do was familiarize myself with the route so I could do it in one attempt.

Thoughts on the last part

This was without a doubt the best part of the trail. It was like entering a code base where everything was current tech, pristine and not tacked on with legacy shit. All of the exciting new features, none of the scars. As a software developer and thus a bit of a builder myself (even if in a non-physical sense), I could not help but really appreciate the planning that went into this.


The route itself did not cover a wide area this time; this was largely a circular path I took. A very picturesque circular path, to be sure.

Next

Conquering the entire trail at one go.

Saturday, 25 July 2026

A Software Developer's Vacation In Singapore: Coast-to-Coast Trail Edition (Part 2/4)

I would continue the next part of this exploration on my own, on another day. The plan was go to from Checkpoint Four to Seven. I had no idea what to expect, thus I kept the scope of the task small.

On that weekend afternoon, I headed for Adam Road Food Center and began the journey with another Anywheel rental. I cycled on my merry way until, to my surprise, I came across the Japanese Association (from which I had recently obtained my JLPT) and turned into a little path that seemed to be made of wooden planks and dead leaves, part of the Adam Park Connector.

The wooden boardwalk
Park Connector.

That was actually a fun little ride, at the end of which I ended up at a charming little spot of green called Kheam Hock Park.

The underpass
at the foot of
Kheam Hock Park.

Next to the park was a tunnel that led under the Pan Island Expressway. From there, I passed a few ancient-looking gravestones and carried on my way up along Kheam Hock Road till I reached this humongous overhead bridge that spanned Lornie Highway.

This one was a pain in the ass.

It had a bike ramp which was absolutely torturous going up and worse going down... but the view of the highway from the bridge itself, overcast sky and all, was something else.

And then it was a long smooth ride along Lornie Road, alongside MacRitchie Reservoir. Things were nice and quiet, with occasional downward slopes which were an absolute joy. Eventually, things petered out at MacRitchie Reservoir Park.

A nice ride along Lornie Road.

After that, the most frustrating, tooth-grinding part of the journey began. I was supposed to travel along Bishan and Ang Mo Kio, passing the Ang Mo Kio-Bishan Park. However, the construction works around the area made wide detours necessary, and the entire experience thoroughly unpleasant.

Construction works.


Arrived here after
many detours.


Checkpoint Five.


It was a while before I got to Checkpoint Five due to all the detours I had to take, and all the obstacles I had to navigate. I wasn't riding the bike; the bike was riding me.

The track along Ang Mo Kio
Linear Park and
Luxus Hills Park.


Once I reached Nanyang Polytechnic at Ang Mo Kio Avenue 5, that part was thankfully over. The architecture along this stretch was interesting, and so was the greenery. This was Ang Mo Kio Linear Park, and immediately after that, Luxus Hills Park.

Checkpoint Six.

Also, Checkpoint Six at the end of Luxus Hills Park. This signboard was located oh, so innocuously. After Luxus Hills was this rickety bridge made of concrete blocks, spanning a nice view of the canal.

This lovely bike ramp.

After turning in northwards into Yio Chu Kang Road, I was confronted by a nice bicycle ramp on some steel steps. This one was far more pleasant to use than the last one, the incline being a lot less steep.

Houses opposite the river.


The Singa at a bike stand.

From there, I turned into Punggol Park Connector right by Sungei Tongkang, where I had a nice view of the houses opposite. There were also quite a few people fishing even though I'm pretty sure it's illegal. Still, it was a smooth ride from here. Also, check out this Singa on a bench!

Nicely cultured green.

I don't know why, but I was rather taken by this cultured patch of greenery right here. It looked particularly fetching amid the wild shrubbery. Around the area was also a nice view of the widening river.

Breathtaking river view.

As I went on, a particularly low underpass made me glad I wasn't a basketball player.

The underpass.


Checkpoint Seven.

Checkpoint Seven was right where Sengkang Swimming Complex was, and I ended my exploration of the Coast-to-Coast trial for the day.

Thoughts so far

Honestly, this was a mixed bag. The construction works at Bishan made this super annoying. But if I had to evaluate the trail excluding this, it would actually be a lot more pleasant than the one I endured the week before - because the trail here was at least meant to be bike-friendly. Let's face it; at some point the work on Bishan will be done, but Bukit Timah Road will remain a pain in the ass.



The fact is that this part of the Trail had plenty of interesting features. Whereas the previous part of the Trail felt like a legacy codebase with a new feature (bike paths) shoehorned in, this part at least felt like a codebase that was meant for this particular function.

Next

Checkpoints Seven to Ten

Thursday, 23 July 2026

A Software Developer's Vacation In Singapore: Coast-to-Coast Trail Edition (Part 1/4)

It all began when I started wandering around Singapore and exploring the places I used to hang out in, places I used to work at. I started noticing the green spaces that Singapore is famous for. Sure, logically I know this, but all along, I had always dismissed it as tourist propaganda. Until I took a good look around and saw what tourists see - without needing to go to the likes of Gardens By The Bay, or Singapore Botanical Gardens.

Singapore does have some pretty nice green spaces. And from the looks of it, more foreigners seemed to be enjoying those spaces than locals. Not that I begrudge said foreigners their leisure... but it felt weird to me that as a citizen and taxpayer, I wasn't getting nearly as much out of all this as they were.

Time to rectify that!

Stop going out of the country on break. There's plenty to discover on this island. It was high time I coded less on weekends and explored more.

During the tail end of 2025, I began my exploration. In particular, a colleague of mine pointed me to the Coast-to-Coast Trail, a cycling/hiking route that starts from Jurong Lake Gardens and ends at Rower's Bay Park.

From what I managed to Google of the task, it seemed to be too big an undertaking to take on without seeking some guidance. To that end, I sought out the help of an ex-Navy buddy, who also happened to be an avid cyclist. He was familiar with the route and even knew what signs to look out for, such as the one shown below. Honestly, I'd never have figured that one out by myself. All I actually had was this map.

I met him at Lakeside MRT Station at 7AM on a Saturday. He had his own bike while I used an Anywheel rental. It had just stopped raining and the ground was wet. He had a helmet, padded tights and gloves. I had... slippers and pluck. And we set off, going through Jurong Lake Gardens and ended up at the signpost for Checkpoint One.

Checkpoint One.

The surroundings were pretty lovely. I was going to have to explore this another time, but for now, I was squarely focused on the Coast-to-Coast Trail.

My buddy took me on what felt like a bicycle trek through an urban landscape. This felt like a bit of a mood whiplash after the lush idyllic environment of Jurong Lake Gardens.

The bicycle paths were built pretty close to the road on this portion of the trail. Basically this was a common feature of bicycle paths that had been retrofitted into urban infrastructure, which struck me as an uncomfortable but necessary compromise. At one point, the trail led right under an MRT track!

Right under an MRT track.

Up a zigzag ramp bridge...

...and over the Pan Island
Expressway

This led straight to a ramp bridge which took us over the Pan Island Expressway eventually led us to a canal behind Bukit Batok Industrial Estate.

Behind an industrial estate.

This bike ramp.

At the end of it, we crossed a traffic junction and rolled the bike down a solid, if not totally friendly, bike ramp.

Zodiac statues.

There were some pretty amazing visuals on the way, such as this Animal Zodiac display.

Checkpoint Two.

From there, we arrived at Bukit Batok Nature Park. And just like that, we were at Checkpoint Two. Now, that was a fun, if short, ride away from the bustle of the main road.

Riding through Bukit Batok
Nature Park.

It was here my buddy observed that I seemed almost oblivious to the uphill inclines he was pointing out even though my Anywheel bike had no gears. My habit of swimming almost daily certainly contributed to my cardiovascular conditioning. My bike technique, however, was woeful. I was wobbling way too much, even with the wet road as an excuse.

Then we turned in to Bukit Timah Nature Reserve. It was another fun ride along Upper Bukit Timah Road, and soon we arrived at Checkpoint Three.

Checkpoint Three.

Downhill from there, we passed the Rifle Range Park to Bukit Timah Road. The ride downhill was exhilirating.... and then the fun abruptly stopped because we were staring at a long stretch of road ahead. Not a proper bicycle path, but a narrow one right by the bustling road.

A long unpleasant ride ahead.

It would have been a long an uneventful ride, but for one thing - I took a spill over the slick path and skinned both knees. My buddy was right - my technique was woeful. And my eventual fall along Bukit Timah Road felt inevitable. Honestly, I was lucky. A meter to the left and I might have been squashed by oncoming traffic. Still, I got back up, back on the bike, and we continued all the way to Adam Road Food Center where the next Checkpoint was.

Checkpoint Four.

And that was it for this episode. We got this far in a couple hours, not that we were speeding or anything.

Thoughts so far

Singapore's generally neat and well-organized. A lot of thought has been given to the issue to safety for pedestrians, cyclists and motorists co-existing on Singapore's paths. The result is a largely safe, if restrictive experience.


Going through the first few checkpoints of the Coast-to-Coast Trail felt like traversing through a legacy codebase that has seen several updates from several different development teams with very different approaches... which somehow still works as intended. Probably because users use it as intended.

Next

Checkpoints Four to Seven.

Friday, 17 July 2026

Web Tutorial: NodeJS Chinese Name Transliterator

The Romanization of Chinese names have always been a subject of fascination for me. I've had Vietnamese colleagues and Chinese colleagues from Hong Kong and Taiwan. I've watched Korean dramas that were dubbed in Mandarin, and wondered what the original Korean names were.

Today, I want to present this tool I wrote in NodeJS - it accepts a single Chinese name, in Chinese characters, and provides transliterations of the name in various languages.

Setup

After installing my project using the node command, I installed Express and Handlebars.
npm install --save express
npm install --save express-handlebars


This is what exists in app.js, as a baseline. We load Express and Handlebars, and ensure that the view engine is Handlebars. We also ensure that main is the default layout, and we'll create that soon. We also declare assets as the folder where static content is served, and set the default page to serve the view for home. And then set the 404 and 500 pages. Lastly, we start the app up using the listen() method.

app.js
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home");
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


This is the main layout file. Note the links to CSS and JavaScript. In the body, we have a div with id pnlContainer, which will contain the content of whatever page the app serves.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Chinese Name Transliterator</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
    <script type="text/javascript" src="js/functions.js"></script>
  </head>
  <body>
    <div id="pnlContainer">
      {{{ body }}}    
    </div>
  </body>
</html>


Let's just create a very basic CSS file that only specifies font. We can add more later.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}


In the JavaScript, we'll just have one single function, to check if a string is made up of only Chinese characters. To do that, we use a Regular Expression of a range of Chinese characters, then use the test() method with name as the argument, returning the result. That's also incidentally the only JavaScript validation we'll need in here.

asset/js/functions.js
function isValidChineseName(name) {
    const re = /^[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]+$/;
    return re.test(name);
}


Here's the view for 404s.

views/404.handlebars
<h1>404</h1>

<p>Not found!</p>


And for error 500s.

views/500.handlebars
<h1>500</h1>

<p>There was an error.</p>
<p><b>{{ errorMessage }}</b></p>


And here's the default page. You'll notice there is no form tag; we won't need one because I don't actually intend to do any form submission here. What we do have is a nice header, a div (id pnlSelection), a button (id btnTransliterate) and a span tag (id pnlError) for showing errors. And a script tag for more shenanigans later.

views/home.handlebars
<h1>Chinese Name Transliterator</h1>

<div id="pnlSelection">

</div>

<button id="btnTransliterate">TRANSLITERATE</button>
<span id="pnlError"></span>

<script>

</script>


In pnlSelection, we have two more divs. One contains a label, and the other contains a textbox. The id of the textbox is txtFullChineseName and it will have at most 4 characters, unless you feel like transliterating full Manchurian names or something.

views/home.handlebars
<h1>Chinese Name Transliterator</h1>

<div id="pnlSelection">
  <div><label for="txtFullChineseName">Full Chinese Name</label></div>
  <div><input id="txtFullChineseName" maxlength="4" /></div>

</div>

<button id="btnTransliterate">TRANSLITERATE</button>
<span id="pnlError"></span>

<script>

</script>


This probably looks a mess. Time for more CSS.


pnlContainer fits the full height of the window (thus 100vh), has a maximum width of 400 pixels, which will fit nicely on mobile, and is centered in the middle of the window via the margin property. Text is centered by default.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}


pnlSelection is slightly more interesting. The display property is grid, which instantly turns it into a grid container. grid-template-columns has a value of "1fr 1fr" which basically means two by two, and I've specified a 10-pixel gap. The text-align property has been set to justify to offset the text centering of its parent.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}


Now for pnlError and the button. pnlError has red text. The button is pill-shaped and has large text. Nothing to see here, move along.

asset/css/styles.css
body
{
    font-size: 16px;
    font-family: georgia;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


Time for a preview! So far so good.


We're not just going to have a Chinese name transliterated - we're also going to select the languages it is transliterated in. For that, let's created options.js in the data folder of the assets folder. We basically export an array of objects. Each object has two properties - name and label. These are the five romanization systems to be transliterated in - Wade-Giles, Hokkien, Jyutping, Hangul and Vietnamese.

assets/data/options.js
const options = [
{
    value: "wade-giles",
    label: "Wade-Giles",
},
{
    value: "hokkien",
    label: "Hokkien",
},
{
    value: "jyutping",
    label: "Cantonese",
},
{
    value: "hangul",
    label: "Korean",
},
{
    value: "vietnamese",
    label: "Vietnamese",
}
];

module.exports = options;


We also have to make sure it's available in the back-end. Declare options as what options.js exports, then pass it into home as options.

app.js
const options = require("./assets/data/options.js");
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home", { options: options });
});

app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


Now in the view, we want to iterate through options, which has been passed down to this view from app.js. For each option, we'll have a div and a span. The first div will contain a checkbox and label, with values reflecting those of the option. The span tag will have an id that is "result_" concatenated with the name property of the current element of options.

views/home.handlebars
<div id="pnlSelection">
  <div><label for="txtFullChineseName">Full Chinese Name</label></div>
  <div><input id="txtFullChineseName" maxlength="4" /></div>

  {{#each options}}
  <div>
     <label>
       <input type="checkbox" name="languages" value="{{value}}">
       {{label}}
     </label>
  </div>
  <div>
     <span class="txtResult" id="result_{{value}}"></span>
  </div>
  {{/each}}

</div>


That's how it looks like! We haven't styled the span tags yet, and they have no content at the moment, so they're invisible.


Now, we want to handle clicking! Here's some beginning code...

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {

});
</script>


And then we want to start with validation. First, we clear the contents of pnlError. If the string given is not a valid Chinese name (remember the function we created earlier?), we set the contents of pnlError to a message, then exit early.
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

});
</script>


If no options are selected, we also set the error message and exit early. selectedOptions can be reused later.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
    return;
  }

});
</script>


See what happens when the name contains anything that isn't Chinese.


Or when no option are checked. I'll be using this name as an example for the rest of this tutorial.


Then we declare properties, which really is a comma-separated string of all values selected. For this, we iterate through selectedOptions using the map() method, grabbing the value property values and appending them using the join() method.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
    return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

});
</script>


We'll create a prompt, prompt. For this, we want a valid JSON object that will contain named properties that the user has selected. So if the user checked on the checkboxes labelled "Cantonese" and "Korean", the resultant value inserted into prompt would be "jyutping,hangul".

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

  let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;
});
</script>


Then we use a Try-catch block to send the prompt to the transliterate route using the asynchronous fetch() function. We'll display an error if there's one.

views/home.handlebars
<script>
document.getElementById("btnTransliterate").addEventListener("click", async () => {
  document.getElementById("pnlError").textContent = "";

  const name = document.getElementById("txtFullChineseName").value.trim();

  if (!isValidChineseName(name)) {
    document.getElementById("pnlError").textContent = "Please enter a Chinese name using Chinese characters only.";
    return;
  }

  const selectedOptions = document.querySelectorAll("input[name='languages']:checked");

  if (selectedOptions.length === 0) {
    document.getElementById("pnlError").textContent = "Please select at least one transliteration option.";
return;
  }

  let properties = [...selectedOptions]
  .map(x => `"${x.value}"`)
  .join(", ");

  let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent English romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;

  try {
    const response = await fetch("/transliterate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        prompt: prompt
      })
    });
  } catch (err) {
    document.getElementById("pnlError").textContent = "An unexpected error occurred.";
    console.error(err);
  }

});
</script>


Now let's handle the transliterate route. First, we set up the api.js file. org and key are derived from the OpenAI API project you should have set up for this project. Obviously my org and key values aren't "xx", but you'll have to get your own.

api.js
module.exports = {
  org: "xx",
  key: "xx"
}


Now, in app.js, define api as the exported value from api.js. Ensure that app uses the json() method from express. That's what we'll use to parse. And then define the transliterate route. It's POST, and async.

app.js
const api = require("./api.js");
const options = require("./assets/data/options.js");
var express = require("express");

var app = express();

var handlebars = require("express-handlebars").create({defaultLayout:"main"});
app.engine("handlebars", handlebars.engine);

app.set("view engine", "handlebars");
app.set("port", process.env.PORT || 3000);

app.use(express.static("assets"));

app.get("/", (req, res)=> {
  res.render("home", { options: options });
});

app.use(express.json());

app.post("/transliterate", async (req, res) => {

});


app.use((req, res, next)=> {
  res.status(404);
  res.render("404");
});

app.use((err, req, res, next)=> {
  res.status(500);
  res.render("500", { errorMessage: err.code });
});

app.listen(app.get("port"), ()=> {

});


We have a Try-catch block here.
app.post("/transliterate", async (req, res) => {
  try {

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }

});


We obtain prompt by getting it from the body object of the request, req. If prompt does not exist, we exit early with an error.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


So here, we create response. It is what's returned from running fetch() with OpenAI's API responses endpoint, The operation is a POST and the headers passed in are Authorization, which uses the key value from api; and Content-Type which is set to accept JSON.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        }
      }
    );

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Then in the body, we send a JSON-encoded string of an object containing the model and input. The model is "gpt-5" and the value for input is set to the string known as prompt. In effect, we're asking the API endpoint to provided romanizations for the provided Chinese name, in the various selected languages.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })

      }
    );
  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Since we used await for the previous line, the next part will have response ready. If the ok property of response is false or does not exist, exit with an error.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })
      }
    );
  
    if (!response.ok) {
      const errorText = await response.text();
  
      return res.status(response.status).json({
        success: false,
        error: errorText
      });  
    }

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


At this point, if there has been no early exit, that means the call was successful. We define data by converting it to JSON using the async method json(). Then we return the data. It looks convoluted right now because that's the way OpenAI wraps it - the text property of the first object of the content array, which in turn is the second element of the output array of data.

app.js
app.post("/transliterate", async (req, res) => {
  try {
    const prompt = req.body.prompt;
  
    if (!prompt) {
      return res.status(400).json({
        success: false,
        error: "Prompt is required."
      });
    }
  
    const response = await fetch(
      "https://api.openai.com/v1/responses",
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${api.key}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          model: "gpt-5",
          input: prompt
        })
      }
    );
  
    if (!response.ok) {
      const errorText = await response.text();
  
      return res.status(response.status).json({
        success: false,
        error: errorText
      });
    }
  
    const data = await response.json();

    res.json({
      success: true,
      result: data.output[1].content[0].text
    });

  } catch (err) {
    console.error(err);
  
    res.status(500).json({
      success: false,
      error: err.message
    });
  }
});


Back to the view! We define data as the JSON shape of response. If success was not defined, then we set the error message in pnlError and exit early.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Define transliterations. It'll be the object data's result object. Then we have txtResult, declared as a collection of all elements in the DOM using the CSS class txtResult.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


We convert it to an array, then run a forEach() to iterate through it. x is the current element. We get the id and strip "result_" from it to get the language that it's supposed to be in. Then from there, we populate x with its relevant transliteration, if it exists.

home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

  Array.from(txtResult).forEach((x) => {
    let propName = x.id.replace("result_", "");
    x.textContent = (transliterations[propName] ? transliterations[propName] : "");
  });

} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Try this! Enter a name. Then select a couple of languages. Let's try Wade-Giles, Cantonese and Korean.


It works! It takes a bit of time, but it works. In Wade-Giles, the name is "Tu Yu-lei", and so on.



Here's an improvement...

Add this to the layout file. It's another div, id pnlOverlay. In it, you have a message and an hourglass icon.

views/layouts/main.handlebars
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Chinese Name Transliterator</title>

    <link rel="stylesheet" type="text/css" href="css/styles.css">
    <script type="text/javascript" src="js/functions.js"></script>
  </head>
  <body>
    <div id="pnlOverlay">
      <p><h1>Please wait.</h1>This may take<br />a minute...</p>
      <p class="hourglass">⧗</p>
    </div>

      
    <div id="pnlContainer">
      {{{ body }}}    
    </div>
  </body>
</html>


Style it this way. The position property is fixed, and we anchor it to the top left corner of the screen, with full height and width, a translucent black background and white text. Classic overlay. Of course, we have to set display to none, to hide it. The hourglass CSS class just makes sure the font for this is huge.

assets/css/styles.css
#pnlOverlay
{
    position: fixed;
    display: none;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    color: rgb(255, 255, 255);
    text-align: center;
}


#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

.txtResult
{
    font-weight: bold;
    color: rgb(100, 100,100);
}

.hourglass
{
    font-size: 5em;
}


button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


For the heck of it, throw in an animation!

assets/css/styles.css
#pnlOverlay
{
    position: fixed;
    display: none;
    left: 0;
    top: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.9);
    color: rgb(255, 255, 255);
    text-align: center;
    animation: pulse 1s infinite;
}

#pnlContainer
{
    min-height: 100vh;
    max-width: 400px;
    margin: 0 auto 0 auto;
    text-align: center;
}

#pnlSelection
{
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
    text-align: justify;
}

#pnlError
{
    font-size: 0.8em;
    font-weight: bold;
    color: #FF0000;
}

.txtResult
{
    font-weight: bold;
    color: rgb(100, 100,100);
}

.hourglass
{
    font-size: 5em;
}

@keyframes pulse
{
    0%
    {
        color: rgb(255, 255, 255);
    }
    50%
    {
        color: rgb(100, 100, 100);
    }
}


button
{
    width: 100%;
    height: 2em;
    margin-top: 2em;
    border-radius: 20px;
}


Now, add this in the view. When the button is clicked, pnlOverlay should pop up just before the Try-catch block.

views/home.handlebars
let properties = [...selectedOptions]
.map(x => `"${x.value}"`)
.join(", ");

let prompt = `Return only a valid JSON object. The object should contain the following properties only: ${properties}. The value of each property should be the equivalent romanized version of the Chinese name "${name}" (without intonations) only. In the event of ambiguity, pick the first reasonable result.`;

document.getElementById("pnlOverlay").style.display = "block";
  
try {
  const response = await fetch("/transliterate", {


And once there's a resolution, it should disappear!

views/home.handlebars
try {
  const response = await fetch("/transliterate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      prompt: prompt
    })
  });

  document.getElementById("pnlOverlay").style.display = "none";

  const data = await response.json();

  if (!data.success) {
    document.getElementById("pnlError").textContent = data.error;
    return;
  }

  const transliterations = JSON.parse(data.result);

  const txtResult = document.getElementsByClassName("txtResult");

  Array.from(txtResult).forEach((x) => {
    let propName = x.id.replace("result_", "");
    x.innerHTML = (transliterations[propName] ? transliterations[propName] : "");
  });
} catch (err) {
  document.getElementById("pnlError").textContent = "An unexpected error occurred.";
  console.error(err);
}


Here, let's try this.


There, it politely tells you to wait!


And then it shows you the results.


We're done here!

Just another fun NodeJS project. Combining my love for writing code, with exploring new human languages.

Talk Too U-lei-ter!
T___T