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.jsvar 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.cssbody
{
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.cssbody
{
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.cssbody
{
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.jsconst 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.jsconst 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.jsapp.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.jsapp.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.jsapp.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.handlebarstry {
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.handlebarslet 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