Showing posts with label Chinese. Show all posts
Showing posts with label Chinese. Show all posts

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

Monday, 27 April 2026

JavaScript's non-existent relationship to Java

Despite the fact that I can no longer, by any stretch of the imagination, be referred to as a "beginner" where my career is concerned, I sometimes do like to read beginner books on tech. It's oddly soothing. There's also plenty I can learn in terms of how to communicate tech concepts and ideas, from the authors of such books, if not the actual tech itself.

And this is one such book that I picked up from the neighborhood library recently - How to be a Web Developer, by Radu Nicoara.

This book.

Now, this isn't one of my Reference Reviews - I didn't finish reading the book and this would be dishonest. Radu Nicorara probably knows his way around the building blocks of the World Wide Web and has, again, probably done a whole lot more than I have. After all, he published a book. And I give him all the credit in the world for even attempting it.

However...

...once I got to Page 60 (or thereabouts), I encountered a statement that was so egregious that it took me out of reading the book entirely. Not that I ever found the book all that engaging in the first place.

This passage shocked me.



JavaScript is a scripting language (meaning the code is not precompiled) that's derived from the Java programming language, hence the name.
I was in complete disbelief when I saw this. Just to be sure, I sent a photograph of the page to Meta AI, and Meta AI being the nice little bot it was, it described the passage as "a bit misleading".

A bit misleading?! Try "completely false".

The error

For anyone who might be tempted to repeat this, JavaScript is not derived from Java. Aside from the fact that they may both be described as programming languages (coding pedants may insist that in JavaScript's case, it's a loose description) they don't actually have anything to do with each other.

JavaScript began life as a loosely-typed client-side language meant for browsers to interpret. Java was (and still is) a strongly-typed compiled programming language. The names are similar, and the syntaxes are similar. But that's where the similarities end, and even these similarities can't be used as evidence for JavaScript's relationship to Java.

In fact, JavaScript's original name was Mocha. Yes, as in the coffee. It was changed to "JavaScript" as some kind of marketing ploy to mislead people into thinking it was a child of Java. Well, guess it worked!

Fancy a cuppa Mocha, luv?

This reminds me of an encounter on the Clubhouse app where I heard some American woman make the ridiculous claim that in the Chinese language, "the words for danger and opportunity are the same".  John F Kennedy, the 35th President of the USA, was the first to say this back in 1960, and he was wrong. In Chinese, danger is "危机" and opportunity is "机会". Both words contain the word "机", but "机" is also a suffix commonly used to describe machines such as "飞机" (flying machine, a.k.a aeroplane), "手机" (hand machine, a.k.a mobile phone) and "耳机" (ear machine, a.k.a earphones). At the risk of stating the painfully obvious - aeroplanes, mobile phones and earphones have fuck-all to do with danger or opportunity, just as JavaScript has no relation to Java.

Which tells us a couple things - just because two words sound the same or contain subsets of each other, does not make the things they are describing, related. Thus, just because Java and JavaScript both contain the words "Java", it doesn't follow that they're related in any way. The second thing this tells us is, if you don't speak a language, maybe keep the witty quotes to a minimum unless looking stupid brings you deep emotional satisfaction.

As for syntax, both Java and JavaScript's syntax is based on C. Curly brackets, semi-colons, function declarations and so on. But this similarity is not confined to Java and JavaScript. Several other languages such as PHP and C# also have great syntax similarities with Java. In terms of similarity, C# is even closer to Java than JavaScript is.

All in all...

It's not my intention to jump on Nicoara for this error. Whomever his editor was, shares the blame for this. And this glaring factual error aside, what little I read of his book seemed sensible. Therefore, I didn't contact Nicoara and inform him of this boo-boo. What good would it do? It's not like one can un-publish this book.

Besides, I'm not exactly perfect. Early in my blogging days, I probably made my fair share of factual errors. Though, in all fairness, it's not like I'm profiting off my blog, or asking people to pay for it.

All that aside, I used this as a teachable moment. Even if that little piece of programming history I taught was dryer than a nun's coochie.

Java nice day!
T___T

Wednesday, 15 April 2026

Film Review: Black Mirror Series Seven (Part 2/3)

This next episode, the rather pretentiously titled Bête Noire, deals with gaslighting. Tech? Well, there's plenty. Maybe even too much.

The Premise

Maria starts to question her reality when ex-schoolmate, Verity, joins her company Ditta (which had a brief mention in the previous episode; fun fact!). She soon finds out that reality is being manipulated with the intention of driving her to suicide.

The Characters

Sienna Kelley is Maria Skinner. She delivers one hell of a portrayal of an argumentative control freak who always needs to be right, the not-so-reformed bully who instinctively reverts to form once the conditions present themselves. It's sometimes subtle, but Kelley pulls it off. It helps that the argumentative control freak personality is absolutely a thing, and I know more than a few people like that.

Rosy McEwen is Verity Greene. It's also a compelling performance from her, perhaps even more so considering the rather unbelievable premise. She's a victim of bullying who finds near limitless power but decides to spend it tormenting her former bullies. I find this thoroughly unrelatable because in her shoes, I'd be busy exploring so many possibilities and cementing my position. Despite this, McEwen manages to elicit sympathy.

Ben Baily Smith as Gabe, the boss. I found his portrayal as a laid-back hill boss, utterly watchable. Gabe bikes to work and comes off as chummy and sensitive, and tries to be reasonable and tolerant. Which can be tough if you have a pushy mofo like Maria as a subordinate. Just saying!

Michael Workéyè plays Kae, Maria's boyfriend. He's nice and goofy, and really quite the emotionally available guy. Another guy who tries to be reasonable with Maria and puts up with her bullshit. I'm starting to see a pattern!

Ben Ashenden as Nick, from the Graphics department. Comes across as enthusiastically friendly where Verity's concerned. Perhaps even over-friendly. Later on, he pushes Maria's buttons in very annoying ways. Overall, a rather immature character, but the actor looked like he had fun.

Elena Sanz as Camille, focus group tester. She reminded me a little of Gemma from M3GAN. Thought she'd have a bigger role here, but it was not to be.

Hannah Griffiths as Luisa. There's this running gag where people constantly steal her almond milk from the fridge. It druves her nuts, and ties into the story.

Amber Grappy as Yudy, the kitchen head. She always has this confused look. I don't know if that's by design.

Ravi Aujla has a brief appearance as Ditta. Good-looking distinguished silver fox guy. Not really interesting otherwise.

The Mood

The episode is bright and colorful, and the closeup visuals of chocolate and confections sure add to the artistry. Later on, this does not change, though the mood takes a turn for the sinister, which somehow gets worse considering everything is so visually... cheerful. In effect, the entire episode looks very polished visually.

What I liked

The storyline concept was pretty creative, even if it strained credulity at times. The themes of bullying, gaslighting and arrested development are pretty relevant and dare I say, timeless.

I like the visuals where they tell us what day of the week it is. It's just so artistic.


The actual gaslighting was pretty good! It started off subtle, with this foreshadowing shot that it was Barney's rather than Bernies... also, there are apparently two versions of this episode being aired, with this as a gag!


... to something like this, removing nut allergies from existence! And using Google to reinforce it, is just too precious!




What I didn't

Of all the titles they could've gone with that would have actually made sense, "Bête Noire" doesn't exactly stand out as a solid choice.

Unlikeable characters. Both the protagonist and the antagonist are anything but likeable, and that's even before the reveal at the end that Maria was Verity's bully. Maria is pushy and argumentative. Verity comes off as a tragic victim of bullying who's unable to move past the trauma and as such is in a state of arrested development. No main character comes even close to being sympathetic here.


Unbelievable tech. I mean... something as limitless as the tech that Verity is using, basically runs on what looks like a mini server farm? I suppose it's marginally more believable than that little "quamputer" we saw in Joan Is Awful.

The scene where Verity alters reality so that Maria has always spoken "Chinese". Honestly, if she's "always spoken Chinese", she should speak it a lot better than the garbage gibberish that came out of Maria's mouth. Badly-spoken Mandarin has always been a pet peeve of mine in Hollywood. Couldn't they have used Japanese, or Korean? Something arguably less easy to get wrong? Geez!

Conclusion

A mixed bag. It was a good gaslighting-style story with just enough corniness to make it enjoyable. And even with the rubbish they tried to pass off as Mandarin, the good outweighed the bad here. Solid episode.

My Rating

7.5 / 10

Next

Hotel Reverie

Friday, 13 February 2026

Web Tutorial: Year of the Horse SVG Animation (Part 1/3)

It's that time of the year again! Chinese New Year has arrived! 2026 is the Year of the Horse, and I want to work on a nice SVG animation. It's so nice that I'm going to have to break up this web tutorial into more manageable components.

First, we want a galloping horse. No two ways about it. It's the Year of the Horse, after all. From the internet, I obtained this file and scaled it down, then cut out the individual horses to use as frames for the animation. No, I didn't pay for it. I'm also not profiting from this web tutorial, so...

The original image.



horse00.png



horse01.png



horse02.png



horse03.png


This part of the web tutorial shows you how to import external images into an SVG. Let's get the HTML in there. It's a 600 by 400 pixel animation. I set the background to green for visibility. Note that I've included a meta tag for UTF-8. There will be Chinese characters in here.
<!DOCTYPE html>
<html>
  <head>
    <title>Year of the Horse</title>
     <meta charset="utf-8">
  </head>

  <body>
    <svg width="600" height="400" style="background-color:rgb(100,200,0)" viewBox="0 0 600 400" xmlns="www.w3.org">

    </svg>
  </body>
</html>


We want to place horse00.png right here, around the bottom middle.
<!DOCTYPE html>
<html>
  <head>
    <title>Year of the Horse</title>
    <meta charset="utf-8">
  </head>

  <body>
    <svg width="600" height="400" style="background-color:rgb(100,200,0)" viewBox="0 0 600 400" xmlns="www.w3.org">
     <image href="horse00.png" x="250" y="300" width="100" height="55">

     </image>

    </svg>
  </body>
</html>


See this?


Now place the rest of the PNG files, all overlapping one another.
<!DOCTYPE html>
<html>
  <head>
    <title>Year of the Horse</title>
    <meta charset="utf-8">
  </head>

  <body>
    <svg width="600" height="400" style="background-color:rgb(100,200,0)" viewBox="0 0 600 400" xmlns="www.w3.org">
     <image href="horse00.png" x="250" y="300" width="100" height="55">

     </image>

     <image href="horse01.png" x="250" y="300" width="100" height="55">

     </image>

     <image href="horse02.png" x="250" y="300" width="100" height="55">

     </image>

     <image href="horse03.png" x="250" y="300" width="100" height="55">

     </image>

    </svg>
  </body>
</html>


It's going to look a right mess, until...


...you set opacity to 0! At that point, the horses will all disappear.
<!DOCTYPE html>
<html>
  <head>
    <title>Year of the Horse</title>
    <meta charset="utf-8">
  </head>

  <body>
    <svg width="600" height="400" style="background-color:rgb(100,200,0)" viewBox="0 0 600 400" xmlns="www.w3.org">
     <image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">

     </image>

     <image href="horse01.png" x="250" y="300" width="100" height="55" opacity="0">
  
     </image>

     <image href="horse02.png" x="250" y="300" width="100" height="55" opacity="0">
  
     </image>

     <image href="horse03.png" x="250" y="300" width="100" height="55" opacity="0">

     </image>
    </svg>
  </body>
</html>


We'll then animate the first image with this. This animation has the id horse0, which we'll need for referencing later. We set attributeName to opacity because that's what we'll be animating. It goes from 0.8 to 1, as you can see from the from and to attributes, with a very short duration of 0.1 seconds. It begins as soon as the SVG loads, so begin is set to 0 seconds. And lastly, we set repeatCount to 1. In theory, it should only execute once.
<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse0"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="0s"
   repeatCount="1"
  />

</image>


For the next one, we have horse1. It is identical to horse0, except that it begins only when horse0 ends.
<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse0"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="0s"
   repeatCount="1"
  />
</image>

<image href="horse01.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse1"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse0.end"
   repeatCount="1"
  />

</image>


And so on, and so for.
<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse0"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="0s"
   repeatCount="1"
  />
</image>

<image href="horse01.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse1"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse0.end"
   repeatCount="1"
  />
</image>

<image href="horse02.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse2"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse1.end"
   repeatCount="1"
  />

</image>

<image href="horse03.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse3"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse2.end"
   repeatCount="1"
  />

</image>


Of course, you'll want the reverse animation to make the previous frame disappear. These ones won't require an id. Now when you refresh your page, you'll see the horse animate... but only once.
<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse0"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="0s"
   repeatCount="1"
  />

  <animate
   attributeName="opacity"
   from="0.5" to="0"
   dur="0.1s"
   begin="horse0.end"
   repeatCount="1"
  />

</image>

<image href="horse01.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse1"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse0.end"
   repeatCount="1"
  />

  <animate
   attributeName="opacity"
   from="0.5" to="0"
   dur="0.1s"
   begin="horse1.end"
   repeatCount="1"
  />

</image>

<image href="horse02.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse2"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse1.end"
   repeatCount="1"
  />

  <animate
   attributeName="opacity"
   from="0.5" to="0"
   dur="0.1s"
   begin="horse2.end"
   repeatCount="1"
  />

</image>

<image href="horse03.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse3"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="horse2.end"
   repeatCount="1"
  />

  <animate
   attributeName="opacity"
   from="0.5" to="0"
   dur="0.1s"
   begin="horse3.end"
   repeatCount="1"
  />

</image>


In here, you'll need an additional trigger. horse0 should run once when the SVG loads... and also when horse3 ends. This will in turn trigger the rest of the animations in an infinite loop!
<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">
  <animate
   id="horse0"
   attributeName="opacity"
   from="0.8" to="1"
   dur="0.1s"
   begin="0s;horse3.end"
   repeatCount="1"
  />

  <animate
   attributeName="opacity"
   from="0.5" to="0"
   dur="0.1s"
   begin="horse0.end"
   repeatCount="1"
  />

</image>


Because of the reverse animations we added, you can see a shadowy effect of the horse in the animation! Without it, the animation would look a lot jerkier.



Excellent! We have a galloping horse. What next?

We'll do some easy parts first. Let's have some text. It's a simple text tag where I have some Chinese New Year greeting in Chinese text, in orange fill and red outline. In English, it means "Teochew Thunder wishes all the spirit of dragons and horses!"
<svg width="600" height="400" style="background-color:rgb(100,200,0)" viewBox="0 0 600 400" xmlns="www.w3.org">
  <text x="300" y="30" text-anchor="middle" font-size="30px" fill="rgb(250, 100, 0)" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2">潮州雷祝大家龙马精神!</text>

  <image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">
    <animate
      id="horse0"
      attributeName="opacity"
      from="0.8" to="1"
      dur="0.1s"
      begin="0s;horse3.end"
      repeatCount="1"
    />

    <animate
      attributeName="opacity"
      from="0.5" to="0"
      dur="0.1s"
      begin="horse0.end"
      repeatCount="1"
    />
  </image>


Cool, right?


Then four red rectangles on each of the corners of the SVGs, leaving a 10 pixel border of space.
<text x="300" y="30" text-anchor="middle" font-size="30px" fill="rgb(250, 100, 0)" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2">潮州雷祝大家龙马精神!</text>

<rect fill="none" x="10" y="10" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<rect fill="none" x="570" y="10" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<rect fill="none" x="10" y="370" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<rect fill="none" x="570" y="370" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />


<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">


See this?


And then lines to connect the rectangles.
<text x="300" y="30" text-anchor="middle" font-size="30px" fill="rgb(250, 100, 0)" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2">潮州雷祝大家龙马精神!</text>

<line x1="20" y1="20" x2="100" y2="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<line x1="500" y1="20" x2="580" y2="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<line x1="20" y1="20" x2="20" y2="380" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<line x1="580" y1="20" x2="580" y2="380" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<line x1="20" y1="380" x2="580" y2="380" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />

<rect fill="none" x="10" y="10" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<rect fill="none" x="570" y="10" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<rect fill="none" x="10" y="370" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />
<rect fill="none" x="570" y="370" width="20" height="20" stroke="rgba(250, 0, 0, 0.8)" stroke-width="2" />

<image href="horse00.png" x="250" y="300" width="100" height="55" opacity="0">


It's actually pretty simple, but the effect is that of a horse galloping in a frame.


That's it for now!

This is actually a good stopping point if it's enough for you. You just need to change the ugly green background to something else. But we're going to seriously improve on this...

Next

Adding a changing sky background.

Wednesday, 4 February 2026

No Blanket Solutions

There exists in the spoken language, a supreme contradiction. A sentence that manages to be simultaneously profound in its humility and stunning in its arrogance.

"If I can do it, anyone should be able to do it." - every douchebag ever.


Why is this humble? Well, on the surface, it seems like people who say this are modestly describing their achievements as mediocre. They feel that they're not special, so if someone as unexceptional as them can do something, there's no reason why most other people wouldn't be able to do it. For example, I quit smoking almost two years ago. I did it far easier than many people did, with no lingering physical or psychological side-effects despite having been a pack-a-day guy for the better part of twenty years. No discipline was required; the struggle was non-existent for me. However, I'm also reasonably certain that this isn't that uncommon. There are plenty of people, wired the same way as me, who could accomplish that. Acknowledging that you're not special, is humility.

Why is this arrogant? At the same time,  if I were to claim that I quit smoking without much fuss, and there is something wrong with people who can't simply follow my example; that's a problematic stance to take. Assuming that oneself is the standard on which everyone else should be based around, is both immature and pretty egoistical. It lacks self-awareness.

And both positions are wrong.

We're not all the same.

They're wrong for the very uncomplicated reason that we're all built different. We don't all come off some assembly line with the exact same characteristics. There are significant variances across data points like culture, upbringing, physical and environmental.

For example, people who spent their formative years in a Southeast Asian multicultural country like Singapore or Malaysia would have significantly less trouble picking up a third or fourth language as opposed to, say, an Englishman born and bred in the UK. Incidentally, that's why people coo and act impressed when a white guy speaks Mandarin (even if his pronounciation is dogshit). Whereas nobody bats an eyelid when an Asian guy speaks English well. Why? Because a white dude even attempting to speak anything other than dodgy English is impressive, whereas for an Asian guy it's just another Tuesday.

Is it fair? Well, of course not; but if we're going to acknowledge that everyone's built different, then nothing is fair.

The tech space

"There are no blanket solutions" is a favorite refrain of mine, because it's true especially in the tech space. You can have all the best practices in the world, but they have to be evaluated against the exact context in which you're applying them. The concept of best practices is a good thing, don't get me wrong, but only if not applied blindly.

Frameworks aren't always the way to go; I've said this before over and over. They're often the way to go in software development, but not in every situation.

Not every data storage solution has to include a database.

Not every 2FA solution
looks the same.

Not every 2FA solution looks the same - some involve texts to mobile phone and some involve a third-party authenticator app.

You wouldn't use Python to code every damn thing, just the same way you wouldn't use Java to do it. At least, I hope not.

While we're at it, almost every organization's implementation of Agile Methodology looks different from the next.

But while all I've said so far is uncontroversial, that's for the tech space. The software development context.

In a personal context

Someone once told me he wanted to be like me. I had a place of my own. I was doing well financially with no debt. While I wasn't filthy rich, I spent money without needing to think too hard about it. And above all, I was chill. I didn't let what I didn't have, bother me too much.

And all that was before I got blissfully married and really started stepping up my game at life.

Possibly, to an outsider who was just watching me live my life, it looked like I had everything without needing to turn to drugs or alcohol in order to cope. Furthermore, it looked like all that was needed to achieve what I achieved, was to live my life the way I did. Sadly, I had to disabuse this person of that fantasy. He was not going to achieve the same things I did by living like me, simply because he wasn't me.

There's more than
one path to the
rainbow.

My achievements aren't spectacular by any means. An apartment, a job and a spouse. Spare cash in the bank. The means to take care of my immediate family. These things are achievable by the vast majority of people. All I've really done is earn as much money as I can while living a very modest lifestyle. And that's the hard part.

You see, not everyone can, or wants to, live life the way I do, as if I were still drawing an income of just under SGD 3,000 a month. It takes a specific kind of person to happily sacrifice pleasure for stability. Not everyone has the same experience and needs. Not everyone has the same personality. I know plenty of people who would struggle mightily with a simple existence.

Overseas vacations, wine and cheese, cab rides, expensive pets, gym and club memberships - I won't miss any of those things because they're not the kind of things a guy like me cares about. But again, not everyone is like me in this regard. And honestly, for the sake of Singapore's economy, I really hope not. (Part of the reason why I can live the way I do, is because other people have this habit of buying pricey shit they don't need. So keep on doing what you do, guys; you're awesome.)

Therefore, it's not a simple matter of glibly saying "live life this way". Not everyone will take to my chosen lifestyle the way I have, or reap the exact same rewards from doing so. Why should anyone travel the same path and have the same outcomes as me when they're fundamentally different from me? That makes no sense, does it?

In a nutshell...

The mistake most people make is to assume that everyone wants the same things that they do, or at everyone is built the same way. That's why you have well-intentioned but severely misguided people going around advising others to have kids or go to Church or become a vegetarian because those things have benefitted them... and they assume that these things will benefit other people in exactly the same way.

People can certainly achieve the same things I have, or more... but they're going to have to find their own pathway to it.

Stay unique,
T___T