<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Tsugite Labs]]></title><description><![CDATA[Tsugite Labs]]></description><link>https://tsugitelabs.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 29 Oct 2026 00:53:23 GMT</lastBuildDate><atom:link href="https://tsugitelabs.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Offline license keys for a browser plugin: a signature, WebCrypto, and a Worker that signs after Stripe pays]]></title><description><![CDATA[No license server at runtime and no database at issue time. What the key contains, how renewal and grace work, how a sandbox deployment almost handed out…]]></description><link>https://tsugitelabs.hashnode.dev/offline-license-keys-for-a-browser-plugin-a-signature-webcrypto-and-a-worker-that-signs-after-stripe-pays</link><guid isPermaLink="true">https://tsugitelabs.hashnode.dev/offline-license-keys-for-a-browser-plugin-a-signature-webcrypto-and-a-worker-that-signs-after-stripe-pays</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[stripe]]></category><category><![CDATA[Cloudflare Workers]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Tsugite Labs]]></dc:creator><pubDate>Mon, 26 Oct 2026 03:04:32 GMT</pubDate><content:encoded><![CDATA[<p>A plugin for a browser-side platform is a zip of JavaScript that runs on the customer's machine. If you sell one, the paid features ship in that zip, and so does whatever decides whether they should run. The usual answer is a license server: the plugin calls your API on every page load, the API looks the customer up, and you keep that service running for as long as anyone has paid. For a yearly license sold to a few dozen companies, that is a lot of uptime to promise for one yes-or-no answer.</p>
<p>I built a paid tier for a plugin for kintone, a Japanese no-code platform for business apps on which each customer company runs its own subdomain, and chose the other answer: an offline key. The key is a short signed statement, "this site may use this product until this date", and the sale runs in six steps:</p>
<ol>
<li>The customer clicks Buy in the plugin's settings page. The plugin already knows the site's subdomain.</li>
<li>A small Cloudflare Worker creates a Stripe Checkout Session carrying that subdomain and redirects the browser to Stripe's payment page.</li>
<li>The customer pays.</li>
<li>Stripe sends the browser back to the Worker's <code>/thanks</code> page.</li>
<li>The Worker reads the paid session back from Stripe, signs a key, and shows it.</li>
<li>The customer pastes the key into the plugin, which verifies the signature with the browser's WebCrypto API and never calls home.</li>
</ol>
<p>The Worker keeps no database, because everything the key needs is derived from the payment itself. The sections below follow the steps, then cover renewal, a mistake I made with Stripe's test mode, and what this design gives up against a license server. The system is built and tested end to end; it is not selling yet. Every runnable snippet was run on Node.js 24.</p>
<h2>What the key says, and why it is signed with ECDSA P-256</h2>
<p>The key is two base64url parts joined by a dot: a JSON payload and a signature over the payload's bytes.</p>
<pre><code class="language-js">// The key is payload.signature, both base64url without padding. The payload is this object as JSON:
const claims = { subdomain: 'example', product: 'my-plugin', expires: '2027-11-01T03:04:05.000Z' };
</code></pre>
<p>Three claims are enough. <code>subdomain</code> names the site the key is for, <code>product</code> names the plugin, and <code>expires</code> is a UTC instant in ISO 8601 form. There is no customer name, email or serial number, so nothing in the key needs a lookup in a database of your own. The subdomain is the one piece of customer data in it, and it also sits in Stripe's records of the payment.</p>
<p>The signature is ECDSA over the P-256 curve with SHA-256, for three reasons. WebCrypto supports it in every current browser, so the verifier needs no library. The public key is a JSON Web Key (JWK) with four fields, <code>kty</code>, <code>crv</code>, <code>x</code> and <code>y</code>, pinned inside the plugin at build time, while the private key never leaves the issuer. And the signature is 64 bytes, so the whole key is 197 characters for a seven-letter subdomain and 271 for the longest the issuer accepts, which fits a settings field or a URL. An HMAC would be shorter, but it needs a shared secret, and anything inside the plugin is readable by the customer.</p>
<h2>Verifying in the browser takes one WebCrypto call and a lot of refusing</h2>
<p>The verifier is the part that ships to customers, so it is deliberately small. It reads the key, compares every claim exactly, checks the signature, and then places the expiry on a timeline with four states: <code>valid</code>; <code>expiring</code>, the last 30 days before expiry, when the settings page starts asking for a renewal; <code>grace</code>, the 14 days after expiry, during which the paid features keep working so that a late renewal does not interrupt the customer's work; and <code>expired</code>.</p>
<pre><code class="language-js">// license.mjs: runs in the browser and in Node.js 20+. Expiry and grace end are instants (UTC).
export const DAY = 86400000;

// The site the plugin runs on: 'example' for example.cybozu.com; null anywhere else.
export function subdomainOf(hostname) {
  if (typeof hostname !== 'string') return null;
  return /^([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.(?:cybozu\.com|kintone\.com|s\.cybozu\.com)$/.exec(hostname)?.[1] ?? null;
}

// Strict base64url: a part that re-encodes differently (padding, '+', '/') is rejected rather than tolerated.
function decode(part) {
  if (!/^[A-Za-z0-9_-]+$/.test(part)) throw new Error('encoding');
  const bytes = Uint8Array.from(atob(part.replace(/-/g, '+').replace(/_/g, '/')), c =&gt; c.charCodeAt(0));
  const canonical = btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
  if (canonical !== part) throw new Error('encoding');
  return bytes;
}

// 30 days before expiry the UI starts asking for a renewal; 14 days after it, the paid features stop.
export function licensePeriod(expiresAt, now = Date.now()) {
  const left = expiresAt - now;
  if (left &gt; 30 * DAY) return { status: 'valid', paid: true, expiresAt };
  if (left &gt; 0) return { status: 'expiring', paid: true, expiresAt };
  if (left &gt; -14 * DAY) return { status: 'grace', paid: true, expiresAt };
  return { status: 'expired', paid: false, expiresAt };
}

export async function verifyLicense(key, { hostname, product, publicKey, now = Date.now(), subtle = globalThis.crypto.subtle }) {
  if (!key) return { status: 'missing', paid: false };
  try {
    if (typeof key !== 'string' || key.length &gt; 4096) throw new Error('key');
    const parts = key.trim().split('.');
    if (parts.length !== 2) throw new Error('parts');
    const payload = decode(parts[0]), signature = decode(parts[1]);
    const claims = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(payload));
    const subdomain = subdomainOf(hostname);
    // Every claim is compared exactly; a key for another site or product is as invalid as a forged one.
    if (!subdomain || claims.subdomain !== subdomain || !product || claims.product !== product
      || typeof claims.expires !== 'string' || new Date(claims.expires).toISOString() !== claims.expires
      || signature.length !== 64) throw new Error('claims');
    const imported = await subtle.importKey('jwk', publicKey, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']);
    if (!await subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, imported, signature, payload)) throw new Error('signature');
    return licensePeriod(Date.parse(claims.expires), now);
  } catch {
    // One answer for every failure: the UI never explains which check failed.
    return { status: 'invalid', paid: false };
  }
}
</code></pre>
<p>Two details matter more than they look. The payload is verified as the exact bytes that were signed, not as re-serialized JSON, so issuer and verifier never have to agree on key order or whitespace. And the <code>expires</code> check round-trips the string through <code>Date</code>, which rejects a bare <code>2027-11-01</code> and anything else <code>Date.parse</code> would accept loosely.</p>
<p><code>missing</code> and <code>invalid</code> are separate on purpose. An empty key field means the free tier or the 30-day trial; a key that is present but wrong means paid features off, and it never falls back to the trial, so a typo shows up as an error to fix.</p>
<h2>Binding the key to a subdomain, because the platform gives you one for free</h2>
<p>The plugin runs on a page whose <code>location.hostname</code> is <code>example.cybozu.com</code>, and that hostname is the identity in the key: the plugin passes it to <code>verifyLicense</code>, and a key issued for <code>example</code> is invalid on <code>other.cybozu.com</code>. That is the whole anti-sharing story: a leaked key is useless outside that subdomain. One key covers every app in that kintone; there is nothing per seat and nothing per install.</p>
<p>Use whatever stable identifier your platform hands the running code: a tenant subdomain, a workspace ID, a store URL. If your platform gives you nothing, as a general browser extension often does not, an offline key can still carry an expiry, but it cannot be bound to anyone, and you should expect it to be shared.</p>
<h2>Signing is the same call on the other side</h2>
<p>Step 5 runs in a Cloudflare Worker, which has the same WebCrypto API as the browser, so the signing side is as small as the verifying side:</p>
<pre><code class="language-js">// sign.mjs: the issuer's side. The same WebCrypto call runs in a Cloudflare Worker and in Node.js 20+.
import { DAY } from './license.mjs';

export const LICENSE_DAYS = 365;

function base64url(bytes) {
  let text = '';
  for (const byte of bytes) text += String.fromCharCode(byte);
  return btoa(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export async function signLicense(privateKey, { subdomain, product, issuedAt }, subtle = globalThis.crypto.subtle) {
  const expires = new Date(issuedAt + LICENSE_DAYS * DAY).toISOString();
  const payload = new TextEncoder().encode(JSON.stringify({ subdomain, product, expires }));
  // WebCrypto returns the signature as the two 32-byte numbers r and s side by side (the IEEE P1363 form),
  // which is the 64-byte layout the verifier checks for.
  const signature = new Uint8Array(await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, payload));
  return { license: `${base64url(payload)}.${base64url(signature)}`, expires };
}
</code></pre>
<p>Both modules run in Node.js as they are. The script below generates a key pair, signs a key for <code>example</code>, and checks it under the conditions the plugin meets:</p>
<pre><code class="language-js">import { DAY, verifyLicense } from './license.mjs';
import { signLicense } from './sign.mjs';

const { privateKey, publicKey } = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
const { kty, crv, x, y } = await crypto.subtle.exportKey('jwk', publicKey);
const pinned = { kty, crv, x, y };   // this object ships inside the plugin

const orderedAt = Date.parse('2026-11-01T03:04:05.000Z');
const { license, expires } = await signLicense(privateKey, { subdomain: 'example', product: 'my-plugin', issuedAt: orderedAt });
expires;         // '2027-11-01T03:04:05.000Z'
license.length;  // 197

const check = (key, changes = {}) =&gt;
  verifyLicense(key, { hostname: 'example.cybozu.com', product: 'my-plugin', publicKey: pinned, now: orderedAt, ...changes });

await check(license);                                   // { status: 'valid', paid: true, expiresAt: 1825038245000 }
await check(license, { hostname: 'other.cybozu.com' }); // { status: 'invalid', paid: false }
await check(license, { product: 'other-plugin' });      // { status: 'invalid', paid: false }
const [body, signature] = license.split('.');
const forged = Buffer.from(JSON.stringify({ ...JSON.parse(Buffer.from(body, 'base64url')), subdomain: 'other' }))
  .toString('base64url') + '.' + signature;           // claims edited, old signature kept
await check(forged, { hostname: 'other.cybozu.com' });  // { status: 'invalid', paid: false }
await check(license, { now: orderedAt + 340 * DAY });   // { status: 'expiring', paid: true, expiresAt: 1825038245000 }
await check(license, { now: orderedAt + 370 * DAY });   // { status: 'grace', paid: true, expiresAt: 1825038245000 }
await check(license, { now: orderedAt + 380 * DAY });   // { status: 'expired', paid: false, expiresAt: 1825038245000 }
</code></pre>
<h2>The Worker: two buttons, one session, no typing</h2>
<p>Steps 1 and 2. The plugin's settings page has a Buy button and a Renew button, and the customer never types a subdomain or a key on the payment page, because the plugin already knows both:</p>
<pre><code class="language-js">// In the plugin's settings page. The Worker's origin is a constant in the build.
export function checkoutLink({ key, status, hostname }) {
  if (key &amp;&amp; ['valid', 'expiring', 'grace', 'expired'].includes(status)) {
    return { kind: 'renew', url: `${ISSUER_URL}/renew?key=${encodeURIComponent(key)}` };
  }
  const subdomain = subdomainOf(hostname);
  return subdomain ? { kind: 'buy', url: `${ISSUER_URL}/buy?subdomain=${subdomain}` } : { kind: 'site', url: SITE_URL };
}
</code></pre>
<p><code>/buy</code> checks the subdomain against the signer's rule, creates a Checkout Session carrying <code>metadata.product</code>, <code>metadata.subdomain</code> and a <code>success_url</code> of <code>/thanks?session_id={CHECKOUT_SESSION_ID}</code> (Stripe fills in the ID), and answers with a 303 to Stripe's payment page. <code>/renew</code> first verifies the current key with the pinned public key, takes the subdomain from the key rather than from the page, and adds the key as <code>metadata.renewal_key</code>; Stripe allows <a href="https://docs.stripe.com/metadata">500 characters per metadata value</a>, and the longest key is 271.</p>
<p>Steps 4 and 5. <code>/thanks</code> is where the checks live. Everything is read back from Stripe; the Worker trusts nothing in the URL beyond the session's ID:</p>
<pre><code class="language-js">// /thanks?session_id=cs_...: adapted from the Worker. Refusal carries an HTTP status and a fixed reason code.
const session = await stripeGet(`/checkout/sessions/${sessionId}?expand[]=line_items`);
if (session.payment_status !== 'paid' || session.status !== 'complete') throw new Refusal(409, 'unpaid');
const items = session.line_items.data;
if (session.mode !== 'payment' || items.length !== 1 || items[0].price.id !== PRICE_ID || items[0].quantity !== 1) {
  throw new Refusal(422, 'product');
}
if (session.metadata.product !== PRODUCT || !SUBDOMAIN.test(session.metadata.subdomain)) throw new Refusal(422, 'subdomain');
// Signing starts from the session's creation time, not from now (see below).
const issuedAt = session.created * 1000;
</code></pre>
<p>The price and quantity checks are there because a paid session for a cheaper item in the same account must not become a key for this product. Anyone can open <code>/buy</code> and create an unpaid session; that costs nothing, it <a href="https://docs.stripe.com/api/checkout/sessions/create">expires after 24 hours by default</a>, and no key exists until Stripe says <code>paid</code>.</p>
<p>The last line is where the "no database" part comes from. The Worker does not sign with the current time; it uses the session's <code>created</code> timestamp, which never changes, so opening the key page once or fifty times gives the same expiry and there is nothing to record. The key string does change on each reload, because ECDSA signatures include a random component, but every one of them verifies. The page tells the customer to bookmark the URL: it is their receipt and their only way to see the key again.</p>
<h2>Renewal is one line of arithmetic, and it has a known blind spot</h2>
<p>A renewal must not punish renewing early or reward renewing late. With the current key's expiry read from the metadata:</p>
<pre><code class="language-js">import { DAY } from './license.mjs';

// New expiry = max(order time, current expiry) + 365 days.
export function renewalExpiry(orderedAt, previousExpiry) {
  const start = previousExpiry === null ? orderedAt : Math.max(orderedAt, previousExpiry);
  return new Date(start + 365 * DAY).toISOString();
}

const orderedAt = Date.parse('2026-11-01T03:04:05.000Z');
renewalExpiry(orderedAt, null);                  // '2027-11-01T03:04:05.000Z'  a new purchase
renewalExpiry(orderedAt, orderedAt + 40 * DAY);  // '2027-12-11T03:04:05.000Z'  renewed 40 days early: the days carry over
renewalExpiry(orderedAt, orderedAt - 7 * DAY);   // '2027-11-01T03:04:05.000Z'  renewed in the grace period: counts from the order
</code></pre>
<p>An expired current key, however old, still counts as genuine for renewing, so a lapsed customer renews from the same button. A key for a different product is refused by <code>/renew</code> before any session exists, with a page saying nothing was charged. <code>/thanks</code> can still refuse after payment, but only for sessions the buttons did not make, where the customer typed the key and the subdomain: a key for another subdomain, or text that is not a key. Those get a page that says the payment went through and how to reach me.</p>
<p>The blind spot: since nothing is stored, the Worker cannot see that a current key has already been used for a renewal. Renew twice with the same current key while it is still valid and both payments yield the same expiry, because <code>max</code> picks the key's expiry both times; after it has expired, each payment counts from its own order time and the two terms overlap by almost a year. The key page shows "expiry before this renewal" so the customer can notice, and I refund the second payment by hand rather than run a database to prevent it.</p>
<h2>A key cannot be revoked, so decide what a refund means before you sell</h2>
<p>The plugin never calls home, so there is no moment at which you can take a key back. A refunded customer keeps a working key until its expiry plus the 14-day grace period, and a customer whose subdomain changes needs a new key issued by hand. Rotating the public key would invalidate every key ever sold, so back up the private key and keep it for the life of the product.</p>
<p>So a refund is a goodwill gesture, not a switch, and the terms should say so. The trial has the same property: without a server, the client decides when it started, and I accepted that. If you need to cut off a specific customer, meter usage, or sell per seat, this design is the wrong one.</p>
<h2>Stripe's test mode almost handed out real keys</h2>
<p>To test the whole flow end to end, I deployed the Worker with a Stripe test-mode key, a test price, and the real signing key. The flow worked: Stripe's <a href="https://docs.stripe.com/testing">test card number</a> 4242 4242 4242 4242 paid, <code>/thanks</code> saw <code>payment_status: 'paid'</code>, and a key came out.</p>
<p>It was a real key, signed with the real private key and valid in the shipped plugin, and anyone who found the Worker's URL could have had one for any subdomain, for free. The Worker checked that the session was paid, for the right price, at quantity one, and not whether the money was real.</p>
<p>A <a href="https://docs.stripe.com/api/checkout/sessions/object">Checkout Session</a> carries a boolean <code>livemode</code>, <code>true</code> for real payments and <code>false</code> for test ones, and so does an Invoice. The fix is to treat it as a first-class input to the signing decision:</p>
<pre><code class="language-js">// Which key signs is decided by Stripe's livemode flag, never by configuration alone.
if (typeof session.livemode !== 'boolean') throw new Refusal(502, 'stripe');   // not a Stripe object we understand
const signing = session.livemode ? keys.live : keys.test;
if (!signing) throw session.livemode ? new Refusal(500, 'config') : new Refusal(403, 'test-payment');
</code></pre>
<p>Three rules sit behind those lines. A <code>livemode: true</code> payment is always signed with the real key, which must match the pinned public key or the Worker answers 500 before it touches Stripe. A <code>livemode: false</code> payment gets no key by default. Only when an explicit <code>ALLOW_TEST_PAYMENTS</code> variable is set does a test payment produce a key, signed with a separate test private key whose public half is not the one in the plugin, so the key exercises the page and is useless in the product; the tests assert that it fails against the pinned public key. And when the Stripe API key itself is a test key, the Worker never loads the real private key, so there is no code path from "test card" to "real signature".</p>
<p>The lesson is about key handling, not Stripe: an issuer deployed with a test Stripe key still holds the real private key, and mints real licenses for free as soon as one check is missing. Give test deployments their own signing key.</p>
<h2>Keep the key and the secrets out of every log</h2>
<p>Stripe's error messages can include your API key (a 401 reads <code>Invalid API Key provided: rk_live_...</code>), so passing them to a page or a log leaks it. The Worker maps every failure to a fixed reason code and logs only the code:</p>
<pre><code class="language-js">} catch (error) {
  // Only fixed reason codes reach the log and the page: no keys, IDs or Stripe messages.
  if (error instanceof Refusal) {
    if (error.status &gt;= 500) console.error(`issuer: ${error.reason}`);
    return errorPage(error.status, error.reason);
  }
  console.error('issuer: unexpected');
  return errorPage(500, 'config');
}
</code></pre>
<p>The key page's URL is a receipt, so it is served with <code>Cache-Control: no-store</code>, <code>Referrer-Policy: no-referrer</code> and <code>X-Robots-Tag: noindex</code>, and the <code>/renew</code> redirect, whose URL carries the current key, gets the same headers. The private key is a Worker secret in PKCS#8 PEM form, and the Worker imports it twice. One import is extractable and is used only to export the public point <code>x</code>, <code>y</code> and compare it with the pinned JWK, so a wrong secret gives a 500 instead of keys nobody can use. The other import is non-extractable and does the signing.</p>
<h2>What a license server would buy you, and what it would cost</h2>
<table>
<thead>
<tr>
<th>Need</th>
<th>Offline key</th>
<th>License server</th>
</tr>
</thead>
<tbody><tr>
<td>Works when your service is down</td>
<td>Yes, once issued</td>
<td>No</td>
</tr>
<tr>
<td>Revoke a key after a refund or abuse</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Count seats or installs</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Usage data</td>
<td>None</td>
<td>Whatever you log</td>
</tr>
<tr>
<td>Recover a lost key</td>
<td>Reopen the receipt URL</td>
<td>Look it up</td>
</tr>
<tr>
<td>Runtime cost</td>
<td>None</td>
<td>Hosting, uptime, a database</td>
</tr>
<tr>
<td>Customer database of your own</td>
<td>None (the tenant identifier lives in the key and in Stripe's records)</td>
<td>At least an identifier per customer</td>
</tr>
</tbody></table>
<p>For a yearly license bound to a tenant, with refunds rare and no per-seat pricing, I chose the left column: nothing has to be up for the plugin to keep working, and there is no customer database to protect. Stripe and the Worker do have to be up at the moment of purchase or renewal; after that, nothing does. If you sell per seat, need to cut off abusers, or want to know who is active this month, build the server, and keep the signed-key format so the plugin still works when the server is slow.</p>
<h2>Checklist</h2>
<ul>
<li>Put only what the verifier needs in the key: the tenant identifier, the product, and a UTC expiry. Pin the public key in the plugin and verify the signed bytes, not re-serialized JSON.</li>
<li>Compare the key's identifier with what the platform gives the running code, such as <code>location.hostname</code>.</li>
<li>Return <code>missing</code> and <code>invalid</code> separately, and never let an invalid key fall back to a trial.</li>
<li>Give a renewal window before expiry and a grace period after it, and show the expiry with its time zone.</li>
<li>Derive the expiry from the payment's own timestamp, so the receipt URL reproduces a key with the same subdomain and expiry, and compute renewals as <code>max(order time, current expiry) + term</code>.</li>
<li>Check <code>livemode</code> on every Checkout Session and Invoice you read, and keep a separate signing key for test payments.</li>
<li>Log fixed reason codes only, and mark the key page <code>no-store</code>, <code>no-referrer</code>, <code>noindex</code>.</li>
<li>Write the refund terms knowing that a key cannot be revoked and works through the grace period, and back up the private key.</li>
</ul>
<h2>Tools</h2>
<p>The plugin this licensing was built for has <a href="https://tsugite-kintone-due-date.pages.dev/">a distribution page, in Japanese only</a>. The snippets were run on Node.js 24.19 and use only WebCrypto APIs available in Node.js 20 and current browsers.</p>
]]></content:encoded></item><item><title><![CDATA[Japanese holidays and business days in JavaScript, no holiday API]]></title><description><![CDATA[The National Holidays Act is a small algorithm. Here is what it gives you, where it stops, and how to check your code against the official CSV.]]></description><link>https://tsugitelabs.hashnode.dev/japanese-holidays-and-business-days-in-javascript-no-holiday-api</link><guid isPermaLink="true">https://tsugitelabs.hashnode.dev/japanese-holidays-and-business-days-in-javascript-no-holiday-api</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Node.js]]></category><category><![CDATA[i18n]]></category><category><![CDATA[Web Development]]></category><dc:creator><![CDATA[Tsugite Labs]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:48:33 GMT</pubDate><content:encoded><![CDATA[<p>Add one business day to Friday, 18 September 2026 for a customer in Japan and the answer is Thursday, 24 September. Monday the 21st is Respect for the Aged Day, Wednesday the 23rd is Autumnal Equinox Day, and Tuesday the 22nd is a day off because it sits between two holidays (a "citizen's holiday", one of the two rules explained below). A date library that only skips weekends gives you Monday, a due date on a day the customer's office is closed.</p>
<p>If you build scheduling, billing or SLA features for Japanese customers, you need these dates. <code>Intl</code> has no holiday data, and you do not need a holiday API either: the Japanese government publishes the list as a CSV, and the law that defines the holidays is specific enough to implement in about 50 lines. This post covers those rules, the cases where the rules are not enough, the edge cases that catch date code, how to test against the official CSV, and how to add business days with a company calendar on top.</p>
<p>I ran into all of this while building a due-date plugin for kintone, a Japanese business app platform. The code below is the same logic, with the platform parts removed.</p>
<h2>The Act is a list of sixteen holidays and two rules</h2>
<p>The Act on National Holidays (国民の祝日に関する法律, 1948, amended many times since) names the holidays and says how they are observed. As in force since 2020, it defines sixteen holidays:</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Japanese name</th>
<th>English name</th>
</tr>
</thead>
<tbody><tr>
<td>1 January</td>
<td>元日</td>
<td>New Year's Day</td>
</tr>
<tr>
<td>2nd Monday of January</td>
<td>成人の日</td>
<td>Coming of Age Day</td>
</tr>
<tr>
<td>11 February</td>
<td>建国記念の日</td>
<td>National Foundation Day</td>
</tr>
<tr>
<td>23 February</td>
<td>天皇誕生日</td>
<td>Emperor's Birthday</td>
</tr>
<tr>
<td>around 20 March</td>
<td>春分の日</td>
<td>Vernal Equinox Day</td>
</tr>
<tr>
<td>29 April</td>
<td>昭和の日</td>
<td>Showa Day</td>
</tr>
<tr>
<td>3 May</td>
<td>憲法記念日</td>
<td>Constitution Memorial Day</td>
</tr>
<tr>
<td>4 May</td>
<td>みどりの日</td>
<td>Greenery Day</td>
</tr>
<tr>
<td>5 May</td>
<td>こどもの日</td>
<td>Children's Day</td>
</tr>
<tr>
<td>3rd Monday of July</td>
<td>海の日</td>
<td>Marine Day</td>
</tr>
<tr>
<td>11 August</td>
<td>山の日</td>
<td>Mountain Day</td>
</tr>
<tr>
<td>3rd Monday of September</td>
<td>敬老の日</td>
<td>Respect for the Aged Day</td>
</tr>
<tr>
<td>around 23 September</td>
<td>秋分の日</td>
<td>Autumnal Equinox Day</td>
</tr>
<tr>
<td>2nd Monday of October</td>
<td>スポーツの日</td>
<td>Sports Day</td>
</tr>
<tr>
<td>3 November</td>
<td>文化の日</td>
<td>Culture Day</td>
</tr>
<tr>
<td>23 November</td>
<td>勤労感謝の日</td>
<td>Labor Thanksgiving Day</td>
</tr>
</tbody></table>
<p>Ten holidays have fixed dates. Four fall on a given Monday of the month: the "Happy Monday" holidays, the informal name for the amendments that moved them to Mondays in 2000 and 2003 to make three-day weekends. Two are the equinoxes, which the Act does not pin to a date.</p>
<p>Article 3 then adds two rules that create extra days off:</p>
<ul>
<li><strong>Substitute holiday</strong> (振替休日, <em>furikae kyūjitsu</em>): when a holiday falls on a Sunday, the nearest following day that is not a holiday becomes a day off.</li>
<li><strong>Citizen's holiday</strong> (国民の休日, <em>kokumin no kyūjitsu</em>): a day with a national holiday on both sides, and which is not a national holiday itself, becomes a day off.</li>
</ul>
<p>The official data labels both kinds of extra day 休日, "day off", without saying which rule produced it. The code below uses the same label so that it can be compared with that data later. First the helpers and the tables:</p>
<pre><code class="language-js">// jp-holidays.mjs
const DAY = 86400000;
const ISO = /^(\d{4})-(\d{2})-(\d{2})$/;
const pad = n =&gt; String(n).padStart(2, '0');

// Accept only a real calendar date written as YYYY-MM-DD; Date.UTC alone would turn 2026-02-30 into 2 March.
export function toTime(iso) {
  const match = ISO.exec(String(iso));
  if (!match) throw new RangeError('Expected YYYY-MM-DD');
  const time = Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
  if (new Date(time).toISOString().slice(0, 10) !== iso) throw new RangeError('Invalid calendar date');
  return time;
}

export function addDays(iso, days) {
  return new Date(toTime(iso) + days * DAY).toISOString().slice(0, 10);
}

export function weekday(iso) {
  return new Date(toTime(iso)).getUTCDay(); // 0 = Sunday ... 6 = Saturday
}

const FIXED = [
  ['01-01', '元日'], ['02-11', '建国記念の日'], ['02-23', '天皇誕生日'], ['04-29', '昭和の日'],
  ['05-03', '憲法記念日'], ['05-04', 'みどりの日'], ['05-05', 'こどもの日'], ['08-11', '山の日'],
  ['11-03', '文化の日'], ['11-23', '勤労感謝の日'],
];
// [month, nth Monday, name]
const HAPPY_MONDAY = [[1, 2, '成人の日'], [7, 3, '海の日'], [9, 3, '敬老の日'], [10, 2, 'スポーツの日']];
// The official CSV labels both substitute holidays and sandwiched days 休日 ("day off").
const DAY_OFF = '休日';

function nthMonday(year, month, nth) {
  const first = `${year}-${pad(month)}-01`;
  // (8 - weekday) % 7 is the number of days from the 1st to the first Monday: 0 when the 1st is a Monday.
  return addDays(first, (8 - weekday(first)) % 7 + (nth - 1) * 7);
}

// The standard approximation of the equinox dates, written for 1980-2099.
function equinoxes(year) {
  const drift = 0.242194 * (year - 1980) - Math.floor((year - 1980) / 4);
  return [`${year}-03-${pad(Math.floor(20.8431 + drift))}`, `${year}-09-${pad(Math.floor(23.2488 + drift))}`];
}
</code></pre>
<p>Dates are <code>YYYY-MM-DD</code> strings and all arithmetic runs in UTC through <code>Date.UTC</code>, so the server's time zone never shifts a date. <code>toTime</code> rejects anything that is not a real date in that format, so <code>'2026-02-30'</code> and <code>'2026/01/01'</code> throw a <code>RangeError</code> instead of being silently reinterpreted. Then the function that applies Article 3:</p>
<pre><code class="language-js">export function computeHolidays(year) {
  if (!Number.isInteger(year) || year &lt; 2020 || year &gt; 2099) throw new RangeError('Year out of range');
  const [spring, autumn] = equinoxes(year);
  const holidays = Object.fromEntries([
    ...FIXED.map(([day, name]) =&gt; [`${year}-${day}`, name]),
    ...HAPPY_MONDAY.map(([month, nth, name]) =&gt; [nthMonday(year, month, nth), name]),
    [spring, '春分の日'], [autumn, '秋分の日'],
  ]);
  const national = Object.keys(holidays);
  // Article 3(3): a day between two national holidays that is not itself a national holiday is a day off.
  for (const iso of national) {
    const next = addDays(iso, 1);
    if (!(next in holidays) &amp;&amp; national.includes(addDays(iso, 2))) holidays[next] = DAY_OFF;
  }
  // Article 3(2): a national holiday on a Sunday moves to the nearest following day that is not a holiday.
  for (const iso of national) {
    if (weekday(iso) !== 0) continue;
    let day = addDays(iso, 1);
    while (day in holidays) day = addDays(day, 1);
    holidays[day] = DAY_OFF;
  }
  return Object.fromEntries(Object.entries(holidays).sort(([a], [b]) =&gt; (a &lt; b ? -1 : 1)));
}
</code></pre>
<p>Running it for 2026 reproduces the September cluster from the opening:</p>
<pre><code class="language-js">import { computeHolidays } from './jp-holidays.mjs';

Object.entries(computeHolidays(2026)).filter(([iso]) =&gt; iso.startsWith('2026-09'));
// [
//   [ '2026-09-21', '敬老の日' ],
//   [ '2026-09-22', '休日' ],
//   [ '2026-09-23', '秋分の日' ]
// ]

Object.keys(computeHolidays(2026)).length;  // 18
</code></pre>
<p>Sixteen holidays plus two days off: the 22nd above, and 6 May, which the edge-case section below explains.</p>
<h2>Where the rules stop</h2>
<h3>Equinox days are announced, not defined</h3>
<p>The Act says Vernal Equinox Day is "the day of the vernal equinox" and leaves the date to astronomy. The National Astronomical Observatory of Japan, the country's national observatory, calculates it and publishes the following year's dates each February in the Official Gazette (官報, the Japanese government's official journal). Until then, the official date for a future year does not exist.</p>
<p>The <code>equinoxes</code> function is an approximation. Its constants are the coefficients of the standard formula: a base date for 1980 (20.8431 days into March, 23.2488 days into September), a drift of 0.242194 days per year (the fractional part of the tropical year), and one day back for every leap year since 1980. In my check it matched the 1980 to 2027 dates in the official CSV. Its results for future years are provisional until the Observatory publishes them, and this implementation limits them to 2099, the end of the range the formula is written for, rather than extrapolating further.</p>
<h3>One-off holidays and amendments are not in the formula</h3>
<p>Some days off come from amendments to the Act and some from separate one-off laws. All of them are in the official data, and none can be derived from the rules above:</p>
<ul>
<li><strong>Imperial events.</strong> In 2019 a special law created two one-off holidays: 1 May (the enthronement of the current emperor) and 22 October (the enthronement ceremony). With 1 May a holiday, 30 April sat between Showa Day on the 29th and 1 May, and 2 May sat between 1 May and Constitution Memorial Day on the 3rd, so both became citizen's holidays under Article 3. Together with the weekend and the substitute day on 6 May, Japan had ten consecutive days off from 27 April to 6 May. The CSV carries all of these dates; the rules alone produce none of them. Earlier one-off entries in the data, for imperial weddings, a funeral and the 1990 enthronement ceremony, exist for the same reason.</li>
<li><strong>The Tokyo Olympics.</strong> For 2020 and 2021 only, Marine Day, Sports Day and Mountain Day were moved next to the opening and closing ceremonies. When the Games were postponed from 2020 to 2021, the special holiday dates were revised again. In 2021, Mountain Day landed on Sunday 8 August, which produced a substitute day on the 9th.</li>
<li><strong>Changes to the list.</strong> Mountain Day first appears in 2016. The Emperor's Birthday was 23 December through 2018 and 23 February from 2020; 2019 had none, because the new emperor's birthday had already passed when he took the throne. Sports Day was called 体育の日 (Health and Sports Day) until 2019. Substitute holidays themselves only start in 1973 in the data.</li>
</ul>
<p>So the rules are a fallback for years the government has not published yet, not a replacement for the published list. <code>computeHolidays</code> starts at 2020 because earlier years used a different holiday list. It still accepts 2020 and 2021, where its output is wrong because of the Olympic changes; the test section below relies on that to show the difference, and for those two years you must use the official data. The plugin I mentioned bundles the official data for 1955 through 2027 and uses the rule-based fallback only from 2028 on.</p>
<h2>Three edge cases that catch hand-written code</h2>
<p><strong>A substitute holiday can skip several days.</strong> In 2026, Constitution Memorial Day falls on Sunday 3 May. Monday the 4th and Tuesday the 5th are already holidays, so the substitute is Wednesday the 6th. The common shortcut "if Sunday, take Monday" gives the wrong day in 2020, 2026 and 2037.</p>
<pre><code class="language-js">computeHolidays(2026)['2026-05-06'];  // '休日'
</code></pre>
<p><strong>The citizen's holiday is rarer than it looks.</strong> With the current list, it can only happen in September, when Respect for the Aged Day (the third Monday) and Autumnal Equinox Day are two days apart. In 2026, 22 September falls between the two. In 2032, the predicted equinox is 22 September, so the sandwiched day would be Tuesday the 21st. Both the rule and the code use the same condition: the day between two national holidays is not itself a national holiday.</p>
<pre><code class="language-js">Object.entries(computeHolidays(2032)).filter(([iso]) =&gt; iso.startsWith('2032-09'));
// [
//   [ '2032-09-20', '敬老の日' ],
//   [ '2032-09-21', '休日' ],
//   [ '2032-09-22', '秋分の日' ]
// ]
</code></pre>
<p><strong>Saturdays get nothing.</strong> The substitute rule applies to Sundays only. New Year's Day 2033 is a Saturday, and the following Monday is a normal working day:</p>
<pre><code class="language-js">computeHolidays(2033)['2033-01-03'];  // undefined
computeHolidays(2020)['2020-02-24'];  // '休日' (the Emperor's Birthday was a Sunday)
</code></pre>
<h2>Test the rules against the official CSV</h2>
<p>The Cabinet Office, the Japanese government ministry that administers the holiday law, publishes the list as a CSV at <code>https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv</code>, linked from <a href="https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html">its holiday page</a>. The file is encoded in Shift_JIS, has one header line, and each row is a date in <code>YYYY/M/D</code> form followed by the name, for example <code>2026/9/22,休日</code>. It covers 1955 through the next year; in October 2026 it ends at 2027.</p>
<p>The following script parses the file and compares each year with the rules. <code>TextDecoder</code> handles Shift_JIS in Node.js builds that ship full ICU (International Components for Unicode, the library behind <code>Intl</code> and the text decoders) data, which is the default build:</p>
<pre><code class="language-js">// compare-csv.mjs, run as: node compare-csv.mjs syukujitsu.csv
import fs from 'node:fs';
import { computeHolidays } from './jp-holidays.mjs';

// The Cabinet Office CSV is Shift_JIS with one header line and rows like 2026/9/22,休日.
export function parseHolidayCsv(bytes) {
  const text = new TextDecoder('shift_jis', { fatal: true }).decode(bytes);
  const holidays = {};
  for (const row of text.trim().split(/\r?\n/).slice(1)) {
    const match = /^(\d{4})\/(\d{1,2})\/(\d{1,2}),(.+)$/.exec(row.trim());
    if (!match) throw new Error(`Unexpected row: ${row}`);
    const [, y, m, d, name] = match;
    holidays[`${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`] = name.trim();
  }
  return holidays;
}

const official = parseHolidayCsv(fs.readFileSync(process.argv[2]));

for (let year = 2020; year &lt;= 2027; year++) {
  const computed = computeHolidays(year);
  const dates = new Set([...Object.keys(computed), ...Object.keys(official)].filter(iso =&gt; iso.startsWith(`${year}-`)));
  const diffs = [...dates].sort().filter(iso =&gt; computed[iso] !== official[iso])
    .map(iso =&gt; `${iso} rules: ${computed[iso] ?? '-'} official: ${official[iso] ?? '-'}`);
  console.log(year, diffs.length ? diffs : 'match');
}
</code></pre>
<p>The output shows the Olympic years and nothing else:</p>
<pre><code class="language-text">2020 [
  '2020-07-20 rules: 海の日 official: -',
  '2020-07-23 rules: - official: 海の日',
  '2020-07-24 rules: - official: スポーツの日',
  '2020-08-10 rules: - official: 山の日',
  '2020-08-11 rules: 山の日 official: -',
  '2020-10-12 rules: スポーツの日 official: -'
]
2021 [
  '2021-07-19 rules: 海の日 official: -',
  '2021-07-22 rules: - official: 海の日',
  '2021-07-23 rules: - official: スポーツの日',
  '2021-08-08 rules: - official: 山の日',
  '2021-08-09 rules: - official: 休日',
  '2021-08-11 rules: 山の日 official: -',
  '2021-10-11 rules: スポーツの日 official: -'
]
2022 match
2023 match
2024 match
2025 match
2026 match
2027 match
</code></pre>
<p>For 2022 through 2027 the rules reproduce the CSV exactly, names and 休日 entries included. That is the regression test. Keep it, and re-run it when the CSV gains a new year: if the new year disagrees with the rules, something was announced that the formula cannot know about.</p>
<p>In production, convert the CSV to JSON once (strip a byte order mark if your copy has one) and bundle the JSON with the app. The parse function above is all you need for that conversion.</p>
<h2>Business days need a company calendar on top</h2>
<p>Public holidays are only part of a Japanese business calendar. Most offices close from 29 December to 3 January, and many close for several days around Obon, a family holiday in mid-August that is not a public holiday. Some businesses work Saturdays. The calendar therefore has three layers: closed weekdays, national holidays, and company-specific dates.</p>
<pre><code class="language-js">// business-days.mjs
import { addDays, toTime, weekday } from './jp-holidays.mjs';

export function createCalendar({ holidays = {}, companyHolidays = [], closedWeekdays = [0, 6] } = {}) {
  const closed = new Set([...Object.keys(holidays), ...companyHolidays]);
  const weekdays = new Set(closedWeekdays);
  return { isBusinessDay: iso =&gt; !weekdays.has(weekday(iso)) &amp;&amp; !closed.has(iso) };
}

// Step one day at a time until a business day, not counting the start day.
function nextBusinessDay(calendar, iso, direction) {
  for (let i = 0; i &lt; 366; i++) {
    iso = addDays(iso, direction);
    if (calendar.isBusinessDay(iso)) return iso;
  }
  throw new RangeError('No business day within a year');
}

// `days` business days after `iso` (before it when negative). 0 returns `iso` even on a holiday.
export function addBusinessDays(calendar, iso, days) {
  if (!Number.isSafeInteger(days)) throw new RangeError('Days must be an integer');
  toTime(iso); // validate the date even when days is 0
  const direction = days &lt; 0 ? -1 : 1;
  for (let left = Math.abs(days); left &gt; 0; left--) iso = nextBusinessDay(calendar, iso, direction);
  return iso;
}

// Move a date that lands on a closed day to the previous ('before') or next ('after') business day.
export function shiftToBusinessDay(calendar, iso, adjust) {
  if (!['none', 'before', 'after'].includes(adjust)) throw new RangeError('Unknown adjust');
  toTime(iso);
  if (adjust === 'none' || calendar.isBusinessDay(iso)) return iso;
  return nextBusinessDay(calendar, iso, adjust === 'before' ? -1 : 1);
}

// Every date from `from` to `to` inclusive, for company closures written as ranges.
export function dateRange(from, to) {
  const dates = [];
  for (let iso = from; iso &lt;= to; iso = addDays(iso, 1)) dates.push(iso);
  return dates;
}
</code></pre>
<p>The loop in <code>nextBusinessDay</code> is bounded at 366 steps so that a misconfigured calendar with every weekday closed throws instead of hanging. The checks at the top of <code>addBusinessDays</code> and <code>shiftToBusinessDay</code> reject a fractional day count such as <code>1.5</code> and an unknown <code>adjust</code> value, which matters when those values come from user settings. Two calendars, one with the usual year-end closure, show how much the company layer matters:</p>
<pre><code class="language-js">import { computeHolidays } from './jp-holidays.mjs';
import { createCalendar, addBusinessDays, shiftToBusinessDay, dateRange } from './business-days.mjs';

const holidays = { ...computeHolidays(2026), ...computeHolidays(2027) };
const national = createCalendar({ holidays });
const yearEnd = createCalendar({ holidays, companyHolidays: dateRange('2026-12-29', '2027-01-03') });

addBusinessDays(national, '2026-09-18', 1);   // '2026-09-24'
addBusinessDays(national, '2026-12-28', 5);   // '2027-01-05'
addBusinessDays(yearEnd, '2026-12-28', 5);    // '2027-01-08'
addBusinessDays(yearEnd, '2027-01-08', -5);   // '2026-12-28'
addBusinessDays(national, '2026-11-03', 0);   // '2026-11-03' (a holiday, returned unchanged)
</code></pre>
<p>Monday 28 December plus five business days is 5 January with public holidays alone, and 8 January once the office closure is in. This function excludes the start date, so Friday plus one business day is Monday. Confirm that convention against the contract you are implementing. Zero days returns the input even when it is a holiday, because "the same day" and "move to a business day" are separate decisions.</p>
<p>That second decision is <code>shiftToBusinessDay</code>. A contract may require a due date that lands on a closed day to move to the previous or the next business day:</p>
<pre><code class="language-js">addDays('2026-11-01', 2);                            // '2026-11-03' (Culture Day)
shiftToBusinessDay(national, '2026-11-03', 'before');  // '2026-11-02'
shiftToBusinessDay(national, '2026-11-03', 'after');   // '2026-11-04'
</code></pre>
<p>Expose the direction as a setting rather than choosing for the user.</p>
<p>Two more points for production use. The calendar works in calendar dates in Japan. If your timestamps are instants (a <code>Date</code> or an ISO string with a zone), convert them to a <code>YYYY-MM-DD</code> date in <code>Asia/Tokyo</code> first. <code>new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Tokyo' }).format(date)</code> returns <code>2026-10-23</code> for 23:00 UTC on Thursday 22 October 2026, because it is already Friday morning in Japan; count from the UTC date and the result is off by one. And store company closures as date ranges in configuration that users can edit, because they change every year and differ between customers.</p>
<h2>Checklist</h2>
<ul>
<li>Use the Cabinet Office CSV for every year it covers (1955 through next year) and the rules only for later years.</li>
<li>Implement Article 3 as written: substitute days skip over other holidays, and the sandwiched day between two holidays is a day off.</li>
<li>Treat the equinox formula as an approximation: its future dates are provisional, and stop at 2099, where the formula's range ends.</li>
<li>Compare the rules with the CSV in a test, and expect 2020 and 2021 to differ.</li>
<li>Keep three layers in the calendar: closed weekdays, national holidays, company closures as editable date ranges.</li>
<li>Make the direction of the holiday shift (earlier or later) a setting, and confirm the start-date convention against the contract.</li>
<li>Convert instants to an <code>Asia/Tokyo</code> date before counting business days.</li>
</ul>
<h2>Tools</h2>
<p>The same logic runs in <a href="https://tsugite-kintone-due-date.pages.dev/">a kintone plugin that uses this code</a> for business-day due dates; the page is in Japanese. The snippets were run on Node.js 24.19 and use no API newer than Node.js 20.</p>
]]></content:encoded></item><item><title><![CDATA[Japanese text in JavaScript: what normalize('NFKC') fixes, what it breaks, and how to format era dates]]></title><description><![CDATA[A Japanese user types ＡＢＣ１２３ into your search box. Your database has a record called ABC123, but the search finds nothing. Their bug report says "ABC123…]]></description><link>https://tsugitelabs.hashnode.dev/japanese-text-in-javascript-what-normalize-nfkc-fixes-what-it-breaks-and-how-to-format-era-dates</link><guid isPermaLink="true">https://tsugitelabs.hashnode.dev/japanese-text-in-javascript-what-normalize-nfkc-fixes-what-it-breaks-and-how-to-format-era-dates</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[unicode]]></category><category><![CDATA[i18n]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[Tsugite Labs]]></dc:creator><pubDate>Sat, 24 Oct 2026 19:19:34 GMT</pubDate><content:encoded><![CDATA[<p>A Japanese user types <code>ＡＢＣ１２３</code> into your search box. Your database has a record called <code>ABC123</code>, but the search finds nothing. Their bug report says "ABC123 finds nothing", because to them the two strings look the same. You try it with <code>ABC123</code>, and it works.</p>
<pre><code class="language-js">const records = ['ABC123', 'abc123', 'ＡＢＣ１２３'];

records.filter(r =&gt; r.toLowerCase() === 'abc123');
// [ 'ABC123', 'abc123' ]
</code></pre>
<p>The third record uses fullwidth characters, Unicode code points U+FF01 to U+FF5E that mirror printable ASCII at double width. <code>Ａ</code> is U+FF21 and <code>A</code> is U+0041, so <code>===</code> treats them as different characters.</p>
<p>Forms, search, duplicate detection and CSV imports can all run into this mismatch. Calling <code>normalize('NFKC')</code> handles the width differences, but it also changes characters you may need to preserve. The examples below cover selective width conversion, formatting dates in the Japanese era calendar, and holiday data.</p>
<p>I ran into these issues while building plugins for kintone, a Japanese business app platform. Customer names, phone numbers and dates arrive in a mix of widths and formats. The examples work independently of that platform.</p>
<h2>Why Japanese users type fullwidth letters</h2>
<p>Japanese is written with three scripts: kanji, hiragana and katakana. Kanji are characters shared with Chinese and used for most words and names. Hiragana and katakana are phonetic alphabets; katakana is used for loanwords and for the phonetic reading of a person's name, which many Japanese forms ask for in a separate field.</p>
<p>Users type Japanese through an input method editor (IME), software that turns keystrokes into Japanese text. While the IME is in Japanese mode, digits typically come out fullwidth: a user who types <code>123</code> without switching modes gets <code>１２３</code>. The IME's fullwidth alphanumeric mode does the same for letters. Users may leave the IME in that mode for an address line or an order number rather than switch modes for one field.</p>
<p>Halfwidth katakana (<code>ｶﾞｯﾂﾎﾟｰｽﾞ</code> instead of <code>ガッツポーズ</code>) comes from older systems and bank transfer formats that only had room for one byte per character. The example spells <em>gattsu pōzu</em>, a loanword from "guts pose" meaning a fist-pumping gesture. Data exported from those systems carries halfwidth katakana into yours.</p>
<p>Katakana also has a voicing mark, two short strokes at the top right that turn <em>ka</em> (カ) into <em>ga</em> (ガ). The fullwidth <code>ガ</code> takes one UTF-16 code unit, so its <code>.length</code> is 1. In halfwidth text the mark is a separate character after the base, so <code>ｶﾞ</code> is two code units: <code>ｶ</code> (U+FF76) followed by <code>ﾞ</code> (U+FF9E).</p>
<h2>What NFKC fixes</h2>
<p>Unicode defines four normalization forms. NFC and NFD handle canonical equivalence, where the same character can be written as one code point or as a base plus a combining mark. NFKC and NFKD go further and also fold compatibility characters: characters that represent the same abstract character as another one but were kept separate for formatting or legacy reasons. Fullwidth ASCII and halfwidth katakana are compatibility characters, which is why NFKC is the form that helps here.</p>
<pre><code class="language-js">'ＡＢＣ１２３'.normalize('NFKC');   // 'ABC123'
'ｶﾞｯﾂﾎﾟｰｽﾞ'.normalize('NFKC');     // 'ガッツポーズ'
'東京　都'.normalize('NFKC');       // '東京 都' (ideographic space U+3000 becomes U+0020)
</code></pre>
<p>NFKC makes three conversions that help with search and duplicate detection:</p>
<ul>
<li>Fullwidth letters, digits and ASCII punctuation become their ASCII equivalents.</li>
<li>Halfwidth katakana becomes fullwidth katakana, and detached voiced marks merge into their base character (<code>ｶ</code> followed by <code>ﾞ</code> becomes <code>ガ</code>).</li>
<li>The ideographic space becomes an ordinary space.</li>
</ul>
<p>NFC alone does none of this. <code>'ｶﾞ'.normalize('NFC') === 'ガ'</code> is <code>false</code>, and <code>'ｶﾞ'.normalize('NFKC') === 'ガ'</code> is <code>true</code>.</p>
<p>Normalization also changes string length. <code>'ｶﾞｯﾂﾎﾟｰｽﾞ'.length</code> is 9 before NFKC and 6 after because each voicing mark merges into the character before it. Other symbols expand, as the next section shows. Apply length limits to the value you keep, whether that is the original or a normalized copy. All the code points in these examples are in the Basic Multilingual Plane and take one UTF-16 code unit each.</p>
<p>Applying NFKC before the comparison makes the search match all three records:</p>
<pre><code class="language-js">records.filter(r =&gt; r.normalize('NFKC').toLowerCase() === 'abc123');
// [ 'ABC123', 'abc123', 'ＡＢＣ１２３' ]
</code></pre>
<h2>What NFKC breaks</h2>
<p>NFKC applies the same compatibility folding to the whole string, including symbols used in Japanese text:</p>
<pre><code class="language-js">'①②③'.normalize('NFKC');       // '123'
'㈱'.normalize('NFKC');         // '(株)'
'㍻31.4.30'.normalize('NFKC');  // '平成31.4.30'
'㍿'.normalize('NFKC');         // '株式会社'
'㎏'.normalize('NFKC');         // 'kg'
'½'.normalize('NFKC');          // '1⁄2'
</code></pre>
<p>Whether those conversions help depends on how you use the result:</p>
<ul>
<li><code>①②③</code> are circled numbers, used as list markers and as labels that mean "item 1, item 2, item 3". After NFKC the three markers are the single number <code>123</code>. A product code like <code>A①</code> becomes <code>A1</code> and collides with a real <code>A1</code>.</li>
<li><code>㈱</code> is a single symbol that abbreviates 株式会社 ("Co., Ltd."). NFKC expands it to three characters with ASCII parentheses. You can no longer tell whether the user typed that symbol or the three characters <code>(株)</code>. An exact-name lookup against a company register or a customer database needs to distinguish them.</li>
<li><code>㍻</code> is a one-character mark for Heisei, the Japanese era that ended in 2019. It is common in dates typed on older systems. The expansion to <code>平成</code> is correct, but if you normalize the stored value and leave the query unchanged, a search for <code>㍻31</code> no longer matches it.</li>
<li><code>㍿</code> goes from 1 character to 4. <code>½</code> becomes three characters including a fraction slash (U+2044), so the result <code>1⁄2</code> does not equal the ASCII <code>1/2</code>.</li>
</ul>
<p>NFKC folds every compatibility character it encounters. You need to decide which conversions belong in each field.</p>
<h3>Two lookalike pairs NFKC does not unify</h3>
<p>Some characters that look alike remain different after NFKC:</p>
<pre><code class="language-js">'０９０ー１２３４'.normalize('NFKC');  // '090ー1234'
'10～20'.normalize('NFKC');           // '10~20'
'10〜20'.normalize('NFKC');           // '10〜20'
</code></pre>
<p>The first line is a phone number where the user typed <code>ー</code> (U+30FC, the katakana prolonged sound mark, the long dash inside <code>ガッツポーズ</code>) in place of a hyphen. This is what the hyphen key produces while the IME is in Japanese mode. It is an ordinary katakana letter, with no compatibility mapping, so NFKC converts the digits and leaves <code>ー</code> alone. The result looks like a valid phone number, but a phone-number regex still rejects it.</p>
<p>The fullwidth hyphen <code>－</code> (U+FF0D) belongs to the fullwidth ASCII block, so NFKC and the function below both turn it into <code>-</code>. For phone numbers or postal codes, map <code>ー</code> to <code>-</code> explicitly. If you only need digits, strip the other characters after converting.</p>
<p>The second and third lines use <code>～</code> (U+FF5E, fullwidth tilde) and <code>〜</code> (U+301C, wave dash). Both represent ranges like "10 to 20", and Windows and macOS input methods have historically produced different ones. NFKC folds the fullwidth tilde to ASCII <code>~</code> and leaves the wave dash untouched, so <code>'10～20'</code> and <code>'10〜20'</code> still differ after normalization. If you need ranges to match, map both characters to the same one.</p>
<h3>Names: even NFC changes characters</h3>
<p>Some kanji exist twice in Unicode: once as the ordinary character and once as a "compatibility ideograph" that preserves a different glyph shape from an older encoding. These variants appear in family names. <code>'\uFA19'</code> (U+FA19) and <code>'\u795E'</code> (U+795E) are meant to print with different shapes, but may look identical on your screen. Some people's names are registered with the first variant.</p>
<pre><code class="language-js">'\uFA19'.normalize('NFC') === '\u795E';   // true
'\uFA19'.normalize('NFKC') === '\u795E';  // true
</code></pre>
<p>Both normalization forms replace the variant with the ordinary character. Despite their name, these compatibility ideographs are canonically equivalent to the ordinary character in Unicode, so NFC folds them too. Calling <code>normalize()</code> before storing the name changes how it is written without telling the user. This applies to the kanji variants above; names typed in katakana or Latin letters have no such variants.</p>
<p>Preserve the original name and normalize a copy for matching. For name fields, apply <code>.normalize('NFC')</code> to that copy after the width conversion in the next section. A search using the ordinary character can then find a record stored with the variant.</p>
<h2>Normalize only the ranges you mean</h2>
<p>I use this function to convert fullwidth ASCII, ideographic spaces, and halfwidth katakana and punctuation. The Unicode escapes make the ranges explicit during code review.</p>
<pre><code class="language-js">function tidyInput(text) {
  return text
    // Fullwidth ASCII (U+FF01 to U+FF5E) sits exactly 0xFEE0 above ASCII.
    .replace(/[\uFF01-\uFF5E]/g, c =&gt; String.fromCharCode(c.charCodeAt(0) - 0xFEE0))
    // Ideographic space.
    .replace(/\u3000/g, ' ')
    // Halfwidth katakana and punctuation, one run at a time, so NFKC
    // never sees characters outside this range.
    .replace(/[\uFF61-\uFF9F]+/g, run =&gt; run.normalize('NFKC'));
}

tidyInput('ＡＢＣ１２３　ｶﾞｯﾂﾎﾟｰｽﾞ');   // 'ABC123 ガッツポーズ'
tidyInput('①㈱㍻ ０９０ー１２３４');      // '①㈱㍻ 090ー1234'
tidyInput('（株）ＡＢＣ');              // '(株)ABC'
</code></pre>
<p>The conversion rules have a few consequences:</p>
<ul>
<li>The first <code>replace</code> relies on the fullwidth block being laid out in the same order as ASCII, offset by <code>0xFEE0</code>. That holds for all of U+FF01 to U+FF5E, so a subtraction is enough and there is no table to maintain. Fullwidth quotes and backslashes become their ASCII forms too, which is harmless as long as your queries are parameterized.</li>
<li>The fullwidth tilde <code>～</code> is inside that range, so it becomes <code>~</code>. If you want ranges unified instead, add a line that maps both <code>～</code> and <code>〜</code> to whichever one you store.</li>
<li>NFKC processes each consecutive sequence of halfwidth katakana and punctuation (U+FF61 to U+FF9F). The punctuation <code>｡｢｣､･</code> becomes <code>。「」、・</code>. Circled numbers, <code>㈱</code>, <code>㍻</code> and compatibility ideographs never reach the NFKC call.</li>
<li>A voicing mark with no halfwidth base in front of it, as in <code>カﾞ</code> (fullwidth <code>カ</code> followed by halfwidth <code>ﾞ</code>), comes out as <code>カ</code> plus a combining mark (U+3099) rather than <code>ガ</code>. Appending <code>.normalize('NFC')</code> for the matching copy composes it, and also folds the compatibility ideographs from the names section. Don't append it for a value you are going to store.</li>
<li>It doesn't trim or collapse spaces. That is a separate decision, and it depends on the field as well.</li>
<li>In <code>（株）ＡＢＣ</code>, the parentheses and Latin letters become ASCII, while <code>株</code> stays as it is. The separate symbol <code>㈱</code> stays unchanged. This lets you convert character widths without expanding the abbreviation.</li>
<li>The function leaves <code>ー</code> unchanged because it is valid inside a katakana word. Handle its use as a hyphen in phone numbers when you process that field.</li>
</ul>
<p>For search, apply the function to the query and to a copy of each record you index. Keep the stored original as the user typed it. If your database performs the search, build an indexed column with the same conversion. You can backfill that column for existing records without changing the originals. The code above covers the JavaScript conversion; the database indexing depends on your database.</p>
<p>For exports that must satisfy a prescribed format, such as bank transfer files or government filings, convert the text during export and tell the user what changed. Recording each replacement lets you show a message such as "we converted halfwidth ｶﾞ to fullwidth ガ in the account name", so the user can check the result.</p>
<p>For imports into your own database, follow the search approach: store the original and normalize a copy. Before processing a CSV, check its encoding. Japanese CSV exports often use Shift_JIS, a legacy encoding, rather than UTF-8. If the encoding is unknown, try <code>new TextDecoder('utf-8', { fatal: true }).decode(bytes)</code> with the file's bytes; if it throws, try <code>new TextDecoder('shift_jis', { fatal: true }).decode(bytes)</code>. Check a known value to confirm the choice; <a href="https://nodejs.org/api/util.html#new-textdecoderencoding-options">TextDecoder's fatal option</a> rejects invalid byte sequences but does not identify the encoding.</p>
<h2>Era dates: Intl already knows Reiwa</h2>
<p>Japanese paperwork also uses the era calendar (和暦, <em>wareki</em>), which counts years from the start of each emperor's reign. Official documents, invoices, contracts and HR records use an era name followed by the year within that era. For example, 2026 is 令和8年, "Reiwa 8". If your app prints documents for Japanese customers or reads dates from Japanese paperwork, you need to handle this format.</p>
<p>The eras you will encounter are Meiji (明治, from 1868), Taisho (大正, from 1912), Showa (昭和, from 1926), Heisei (平成, from 1989) and Reiwa (令和, from 1 May 2019).</p>
<p><code>Intl.DateTimeFormat</code> with the <code>ja-JP-u-ca-japanese</code> locale formats era dates, including the transition days:</p>
<pre><code class="language-js">const wareki = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
  era: 'long', year: 'numeric', month: 'long', day: 'numeric',
  timeZone: 'Asia/Tokyo',
});

for (const d of ['1989-01-07', '1989-01-08', '2019-04-30', '2019-05-01', '2026-10-25']) {
  console.log(d, wareki.format(new Date(`${d}T00:00:00+09:00`)));
}
// 1989-01-07 昭和64年1月7日
// 1989-01-08 平成元年1月8日
// 2019-04-30 平成31年4月30日
// 2019-05-01 令和元年5月1日
// 2026-10-25 令和8年10月25日
</code></pre>
<p>Showa ended on 7 January 1989 and Heisei started the next day. Heisei ended on 30 April 2019 and Reiwa started on 1 May. The formatter knows both boundaries. The <code>u-ca-japanese</code> part is a Unicode locale extension that selects the calendar, and the language in front of it decides the words:</p>
<pre><code class="language-js">new Intl.DateTimeFormat('ja-JP-u-ca-japanese', { dateStyle: 'short', timeZone: 'Asia/Tokyo' })
  .format(new Date('2019-05-01T00:00:00+09:00'));
// 'R1/5/1'

new Intl.DateTimeFormat('en-US-u-ca-japanese', {
  era: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Asia/Tokyo',
}).format(new Date('2019-05-01T00:00:00+09:00'));
// 'May 1, 1 Reiwa'
</code></pre>
<p>The short style uses the one-letter era codes (M, T, S, H, R) that Japanese paper forms often ask for. It also writes the first year as <code>1</code>; longer formats write it as <code>元</code> instead (see below).</p>
<h3>Handle 元 in the first year of an era</h3>
<p>The transition-day examples contain <code>平成元年</code> and <code>令和元年</code>. The first year of an era is conventionally written 元年 (<em>gannen</em>, "origin year") in Japanese. The longer Japanese formats follow that convention, while the short style above uses <code>R1</code>.</p>
<p>This matters if you need a numeric year. <code>formatToParts</code> returns the formatted pieces as an array, but its year value is still the string <code>元</code>:</p>
<pre><code class="language-js">const parts = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
  era: 'long', year: 'numeric', timeZone: 'Asia/Tokyo',
}).formatToParts(new Date('2019-05-01T00:00:00+09:00'));
// [
//   { type: 'era', value: '令和' },
//   { type: 'year', value: '元' },
//   { type: 'literal', value: '年' }
// ]

Number(parts.find(p =&gt; p.type === 'year').value);  // NaN
</code></pre>
<p>If you need the era and year as data, handle <code>元</code> yourself:</p>
<pre><code class="language-js">function eraYear(date) {
  const parts = new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
    era: 'long', year: 'numeric', timeZone: 'Asia/Tokyo',
  }).formatToParts(date);
  const value = type =&gt; parts.find(p =&gt; p.type === type).value;
  const year = value('year');
  return { era: value('era'), year: year === '元' ? 1 : Number(year) };
}

eraYear(new Date('2019-05-01T00:00:00+09:00'));  // { era: '令和', year: 1 }
eraYear(new Date('2026-10-25T00:00:00+09:00'));  // { era: '令和', year: 8 }
</code></pre>
<h3>Always pass a time zone</h3>
<p>A JavaScript <code>Date</code> represents an instant, while era boundaries follow calendar days in Japan. The same instant can fall in Heisei in one time zone and Reiwa in another:</p>
<pre><code class="language-js">const moment = new Date('2019-04-30T20:00:00Z');  // 05:00 on 1 May in Tokyo

new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
  era: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'Asia/Tokyo',
}).format(moment);
// '令和元年5月1日'

new Intl.DateTimeFormat('ja-JP-u-ca-japanese', {
  era: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC',
}).format(moment);
// '平成31年4月30日'
</code></pre>
<p>Without <code>timeZone</code>, the formatter uses the server's or browser's time zone. In the example above, a UTC server would format the date as 30 April and use Heisei, even though it is already 1 May in Japan. Use <code>Asia/Tokyo</code> for dates in Japan's civil calendar, unless the user explicitly requests a date in another time zone.</p>
<h3>Three things Intl will not do</h3>
<p><strong>Parse era input.</strong> <code>Intl</code> provides formatting, with no corresponding parser. <code>Date.parse('令和7年1月1日')</code> returns <code>NaN</code>. Japanese forms commonly accept dates such as <code>R7.1.1</code> or <code>令和7年1月1日</code>. To support them, write a parser that matches the era name or letter, the year (with <code>元</code> meaning 1), the month and the day. Add the era's first Gregorian year minus one to the era year: Reiwa 7 is 2018 + 7 = 2025. Validate that the resulting date falls within the era. Users sometimes write <code>平成32年</code> out of habit, but Heisei ended in year 31.</p>
<p><strong>Convert dates before 1873.</strong> Japan switched to the Gregorian calendar on 1 January 1873. Before that, months and days followed a lunar-based calendar. Converting a date such as "Meiji 1, 3rd month, 5th day" to a Gregorian month and day requires a conversion table.</p>
<p><code>Intl</code> gets its era data from ICU (International Components for Unicode, the library behind <code>Intl</code> in Node.js and browsers). ICU starts Meiji on 23 October 1868, the day the era was proclaimed. The proclamation also backdated the era to the first day of that year in the old calendar, which is 25 January 1868 in the Gregorian calendar. Another source may therefore give that date instead. For historical records before 1873, check which convention your source uses before relying on a formatter. This is unlikely to matter for a birthday field.</p>
<p><strong>Know about the next era in advance.</strong> Era tables live in the ICU data shipped with your runtime. When Reiwa started in 2019, runtimes needed an ICU update to format it; older versions printed 平成31年5月1日. Check <code>process.versions.icu</code> to see which ICU version a Node.js process uses. There is no announced date for the next transition. When it comes, you will need to update Node.js and browsers to get the new era data.</p>
<h2>Holidays are data, not a formula</h2>
<p>If your app calculates business days, you also need holiday data. <code>Intl</code> does not provide it. Japanese public holidays are set by law. You could implement rules for holidays falling on a set Monday, such as the second Monday of January, or for observing a Sunday holiday on the next weekday. The list itself can also change by legislation: in 2020 and 2021, three holidays moved to different dates for the Tokyo Olympics. A formula that was right last year can be wrong this year.</p>
<p>Japan's Cabinet Office publishes <a href="https://www8.cao.go.jp/chosei/shukujitsu/gaiyou.html">the official list</a> as a CSV covering 1955 through 2027, with a date column and a name column. The file is in Shift_JIS, the same encoding as many customer CSV exports. On npm, <a href="https://www.npmjs.com/package/@holiday-jp/holiday_jp"><code>@holiday-jp/holiday_jp</code></a> (2.5.2 at the time of writing) provides a maintained copy of the same data.</p>
<p>Keep holiday data in a file you can update without deploying the app, and let users enter company-specific closures. Most Japanese businesses also close for the New Year period (typically 29 December to 3 January) and often for Obon, a family holiday period in mid-August. These closure periods need their own entries alongside the public holiday calendar.</p>
<h2>Checklist</h2>
<ul>
<li>Don't call <code>normalize('NFKC')</code> on a whole string that came from a user. Convert the ranges you mean: fullwidth ASCII, the ideographic space, and halfwidth katakana.</li>
<li>Normalize a copy for search and matching. Store names and free text as typed. Even NFC rewrites the compatibility ideographs that appear in family names.</li>
<li>Check length limits against the value you keep, original or normalized copy. Halfwidth kana shrinks when normalized, and whole-string NFKC also expands some symbols.</li>
<li>Check the encoding of incoming CSV files before anything else. Shift_JIS is common.</li>
<li>Decide per field what to do with <code>ー</code> in numbers and with <code>〜</code> versus <code>～</code> in ranges. NFKC unifies neither.</li>
<li>Use <code>Intl.DateTimeFormat('ja-JP-u-ca-japanese', { timeZone: 'Asia/Tokyo', ... })</code> for era output. Map <code>元</code> to 1 when you read the parts back.</li>
<li>Write your own parser for era input and reject years past the end of an era.</li>
<li>Ship holidays as data, with a company-closure list on top.</li>
</ul>
<h2>Tools</h2>
<p>For kintone, Tsugite Labs offers free plugins for input cleanup, era dates and business-day calculations:</p>
<ul>
<li><a href="https://tsugite-kintone-jp-tidy.pages.dev/">Text Tidy on Save</a> converts fullwidth characters and halfwidth kana and trims stray spaces in text fields when a kintone record is saved. Each rule can be enabled or disabled per field. Enable conversion on save only for fields that require a standardized format and don't need the original spelling; leave names and free text unconfigured.</li>
<li><a href="https://tsugite-kintone-due-date.pages.dev/">Business-Day Due Date and Japanese Era</a> computes business-day deadlines from national and company holidays and shows dates in era format.</li>
</ul>
<p>Both pages are in Japanese. The code in this post was run on Node.js 24.19 with ICU 78.3. The era output needs a runtime with full ICU data, which has been the default Node.js build since version 13.</p>
]]></content:encoded></item></channel></rss>