<?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[Cultural Web Engineering]]></title><description><![CDATA[Technical articles about JavaScript, TypeScript, multilingual interfaces, accessibility, privacy-first architecture, cultural products, and responsible web design.
]]></description><link>https://johson.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a9916761a983257505dca6b/1ca808c1-0efd-42b2-892e-64e6b74fadd1.jpg</url><title>Cultural Web Engineering</title><link>https://johson.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 10 Sep 2026 18:30:04 GMT</lastBuildDate><atom:link href="https://johson.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Progressive Enhancement for Interactive Web Experiences: Start with a Working Form]]></title><description><![CDATA[I work on the cultural web project mentioned later in this article. This post focuses on a general engineering approach rather than presenting an independent product review.

Interactive web experienc]]></description><link>https://johson.hashnode.dev/progressive-enhancement-for-interactive-web-experiences-start-with-a-working-form</link><guid isPermaLink="true">https://johson.hashnode.dev/progressive-enhancement-for-interactive-web-experiences-start-with-a-working-form</guid><dc:creator><![CDATA[johson]]></dc:creator><pubDate>Thu, 03 Sep 2026 07:48:49 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<p>I work on the cultural web project mentioned later in this article. This post focuses on a general engineering approach rather than presenting an independent product review.</p>
</blockquote>
<p>Interactive web experiences are often designed from the JavaScript component inward.</p>
<p>The first implementation might begin with state:</p>
<pre><code class="language-typescript">type Stage =
  | "idle"
  | "loading"
  | "revealed"
  | "error";
</code></pre>
<p>Then come event handlers, animations, API calls, loading indicators, and transitions.</p>
<p>Only later does someone ask:</p>
<blockquote>
<p>What happens if the JavaScript bundle does not load?</p>
</blockquote>
<p>For many applications, the answer is that nothing happens. The visitor sees a button that looks interactive but cannot perform its primary action.</p>
<p>JavaScript can fail for many ordinary reasons:</p>
<ul>
<li><p>A slow or unstable mobile connection</p>
</li>
<li><p>A cached HTML file referencing an outdated bundle</p>
</li>
<li><p>A browser extension blocking a script</p>
</li>
<li><p>A content-security-policy error</p>
</li>
<li><p>A deployment containing mismatched assets</p>
</li>
<li><p>A runtime exception in unrelated code</p>
</li>
<li><p>A low-powered device taking too long to execute the bundle</p>
</li>
</ul>
<p>Progressive enhancement begins with a different question:</p>
<blockquote>
<p>What is the smallest version of this experience that can work with HTML and an HTTP request?</p>
</blockquote>
<p>Once that version works, JavaScript can improve its speed and presentation without becoming its only path to completion.</p>
<h2>Begin with a normal HTML form</h2>
<p>Consider a small web experience that lets a visitor choose a situation and draw a result.</p>
<p>The baseline interface can be a form:</p>
<pre><code class="language-html">&lt;form method="post" action="/draw"&gt;
  &lt;fieldset&gt;
    &lt;legend&gt;Choose a situation&lt;/legend&gt;

    &lt;label&gt;
      &lt;input
        type="radio"
        name="situation"
        value="general"
        checked
      /&gt;
      General
    &lt;/label&gt;

    &lt;label&gt;
      &lt;input
        type="radio"
        name="situation"
        value="new-encounter"
      /&gt;
      A new encounter
    &lt;/label&gt;

    &lt;label&gt;
      &lt;input
        type="radio"
        name="situation"
        value="waiting"
      /&gt;
      Waiting for a reply
    &lt;/label&gt;
  &lt;/fieldset&gt;

  &lt;button type="submit"&gt;
    Draw a result
  &lt;/button&gt;
&lt;/form&gt;
</code></pre>
<p>This form already provides several important behaviors:</p>
<ul>
<li><p>It is keyboard accessible.</p>
</li>
<li><p>It has a clear submission action.</p>
</li>
<li><p>It works without a pointer device.</p>
</li>
<li><p>The selected value is represented by a native control.</p>
</li>
<li><p>The browser can submit it without JavaScript.</p>
</li>
<li><p>Assistive technologies understand its structure.</p>
</li>
</ul>
<p>There is no need to simulate radio buttons with clickable <code>&lt;div&gt;</code> elements.</p>
<p>Native HTML gives us a strong starting point.</p>
<h2>Validate the request on the server</h2>
<p>Client-side validation can improve feedback, but the server remains responsible for accepting only supported values.</p>
<pre><code class="language-typescript">const allowedSituations = new Set([
  "general",
  "new-encounter",
  "waiting"
]);

function parseSituation(
  value: unknown
): string {
  if (
    typeof value !== "string" ||
    !allowedSituations.has(value)
  ) {
    return "general";
  }

  return value;
}
</code></pre>
<p>The server can select an eligible result:</p>
<pre><code class="language-typescript">function drawResult(
  situation: string
): FortuneResult {
  const eligible = results.filter(
    (result) =&gt;
      result.situations.includes(situation) ||
      result.situations.includes("general")
  );

  if (eligible.length === 0) {
    throw new Error(
      "No eligible results are available"
    );
  }

  const index = crypto.randomInt(
    eligible.length
  );

  return eligible[index];
}
</code></pre>
<p>A simplified route might look like this:</p>
<pre><code class="language-typescript">app.post("/draw", async (request, response) =&gt; {
  const situation = parseSituation(
    request.body.situation
  );

  const result = drawResult(situation);

  response
    .setHeader("Cache-Control", "no-store")
    .render("result-page", {
      situation,
      result
    });
});
</code></pre>
<p>The response is a complete HTML page containing the selected result.</p>
<p>No JavaScript is required to reach it.</p>
<h2>Render a complete result page</h2>
<p>The result should remain meaningful without animation or client-side state.</p>
<pre><code class="language-html">&lt;main id="main-content"&gt;
  &lt;article aria-labelledby="result-title"&gt;
    &lt;p&gt;Your result&lt;/p&gt;

    &lt;h1 id="result-title"&gt;
      A Gentle Turning Point
    &lt;/h1&gt;

    &lt;p&gt;
      A small change in perspective may
      reveal a path that was difficult to see.
    &lt;/p&gt;

    &lt;section aria-labelledby="next-step-title"&gt;
      &lt;h2 id="next-step-title"&gt;
        A small next step
      &lt;/h2&gt;

      &lt;p&gt;
        Give the situation space before
        deciding what it means.
      &lt;/p&gt;
    &lt;/section&gt;
  &lt;/article&gt;

  &lt;a href="/"&gt;
    Return to the beginning
  &lt;/a&gt;
&lt;/main&gt;
</code></pre>
<p>This is the core experience.</p>
<p>JavaScript should not be responsible for reconstructing essential content that the server can provide directly.</p>
<h2>Enhancement should preserve the form</h2>
<p>Once the baseline works, JavaScript can intercept the submission and request only a result fragment.</p>
<p>The form remains in the HTML:</p>
<pre><code class="language-html">&lt;form
  method="post"
  action="/draw"
  data-enhance="draw"
&gt;
  &lt;!-- Native form controls --&gt;

  &lt;button type="submit"&gt;
    Draw a result
  &lt;/button&gt;
&lt;/form&gt;

&lt;section
  id="result-region"
  aria-live="polite"
  aria-busy="false"
&gt;&lt;/section&gt;
</code></pre>
<p>If JavaScript loads successfully, it can attach an event listener.</p>
<pre><code class="language-typescript">const form =
  document.querySelector&lt;HTMLFormElement&gt;(
    '[data-enhance="draw"]'
  );

const resultRegion =
  document.querySelector&lt;HTMLElement&gt;(
    "#result-region"
  );
</code></pre>
<p>The script should first confirm that the required elements and APIs exist.</p>
<pre><code class="language-typescript">if (
  form &amp;&amp;
  resultRegion &amp;&amp;
  "fetch" in window &amp;&amp;
  "FormData" in window
) {
  enhanceDrawForm(form, resultRegion);
}
</code></pre>
<p>If any requirement is missing, the script does nothing. The browser continues using the normal form submission.</p>
<p>That is a graceful failure.</p>
<h2>Request a server-rendered fragment</h2>
<p>The enhanced flow can send the same form data using <code>fetch</code>.</p>
<pre><code class="language-typescript">async function requestResult(
  form: HTMLFormElement,
  signal: AbortSignal
): Promise&lt;string&gt; {
  const response = await fetch(form.action, {
    method: form.method,
    body: new FormData(form),
    headers: {
      "X-Requested-Fragment": "fortune-result"
    },
    signal
  });

  if (!response.ok) {
    throw new Error(
      `Request failed with ${response.status}`
    );
  }

  return response.text();
}
</code></pre>
<p>The server can detect the fragment request:</p>
<pre><code class="language-typescript">const wantsFragment =
  request.get("X-Requested-Fragment") ===
  "fortune-result";
</code></pre>
<p>Then render either the result component or the complete page:</p>
<pre><code class="language-typescript">if (wantsFragment) {
  return response.render(
    "partials/fortune-result",
    { result }
  );
}

return response.render(
  "result-page",
  { result }
);
</code></pre>
<p>Both responses use the same result data and the same server-side template.</p>
<p>This prevents the normal and enhanced experiences from drifting into two separate implementations.</p>
<h2>Manage the loading state without hiding the form</h2>
<p>The enhanced handler can temporarily disable the submit button:</p>
<pre><code class="language-typescript">function enhanceDrawForm(
  form: HTMLFormElement,
  resultRegion: HTMLElement
): void {
  const submitButton =
    form.querySelector&lt;HTMLButtonElement&gt;(
      'button[type="submit"]'
    );

  if (!submitButton) {
    return;
  }

  form.addEventListener(
    "submit",
    async (event) =&gt; {
      event.preventDefault();

      submitButton.disabled = true;
      resultRegion.setAttribute(
        "aria-busy",
        "true"
      );

      try {
        const html = await requestResult(
          form,
          new AbortController().signal
        );

        resultRegion.innerHTML = html;
        focusResult(resultRegion);
      } catch {
        renderError(resultRegion);
      } finally {
        submitButton.disabled = false;
        resultRegion.setAttribute(
          "aria-busy",
          "false"
        );
      }
    }
  );
}
</code></pre>
<p>Disabling the button prevents accidental double submissions while the request is active.</p>
<p>It must be re-enabled in <code>finally</code>, including when the request fails.</p>
<p>A permanently disabled primary action is a common failure mode in interactive interfaces.</p>
<h2>Move focus deliberately</h2>
<p>When a full page loads, the browser and assistive technologies receive a new document.</p>
<p>When only part of a page changes, keyboard and screen-reader users may not know where the result appeared.</p>
<p>The result heading can be focusable:</p>
<pre><code class="language-html">&lt;h2
  data-result-heading
  tabindex="-1"
&gt;
  A Gentle Turning Point
&lt;/h2&gt;
</code></pre>
<p>After inserting the fragment:</p>
<pre><code class="language-typescript">function focusResult(
  region: HTMLElement
): void {
  const heading =
    region.querySelector&lt;HTMLElement&gt;(
      "[data-result-heading]"
    );

  heading?.focus();
}
</code></pre>
<p><code>tabindex="-1"</code> allows programmatic focus without adding the heading to the normal tab sequence.</p>
<p>The <code>aria-live="polite"</code> result region provides an additional announcement mechanism, but it should not replace clear focus management.</p>
<h2>Treat animation as an enhancement</h2>
<p>A short reveal animation can create anticipation. It should not become a timer that prevents access to the result.</p>
<pre><code class="language-css">[data-result-card] {
  animation:
    reveal-result 500ms ease-out both;
}

@keyframes reveal-result {
  from {
    opacity: 0;
    transform: translateY(0.5rem);
  }

  to {
    opacity: 1;
    transform: translateY(0);
  }
}
</code></pre>
<p>Visitors who prefer reduced motion should receive the result without movement:</p>
<pre><code class="language-css">@media (prefers-reduced-motion: reduce) {
  [data-result-card] {
    animation: none;
  }
}
</code></pre>
<p>Do not delay the HTML insertion merely to ensure that every visitor watches the complete animation.</p>
<p>The result is the content. The animation is presentation.</p>
<h2>Provide a useful failure state</h2>
<p>An enhanced request can fail even though the standard page would work.</p>
<p>The error message should provide a recovery path:</p>
<pre><code class="language-typescript">function renderError(
  region: HTMLElement
): void {
  region.innerHTML = `
    &lt;div role="alert"&gt;
      &lt;p&gt;
        The result could not be loaded.
      &lt;/p&gt;

      &lt;p&gt;
        Please try again, or continue using
        the standard page.
      &lt;/p&gt;
    &lt;/div&gt;
  `;
}
</code></pre>
<p>A more complete implementation can include a button that disables enhancement for the next submission:</p>
<pre><code class="language-typescript">let enhancementEnabled = true;
</code></pre>
<pre><code class="language-typescript">form.addEventListener("submit", (event) =&gt; {
  if (!enhancementEnabled) {
    return;
  }

  event.preventDefault();
  // Enhanced request
});
</code></pre>
<p>After an enhanced failure, a recovery control can set:</p>
<pre><code class="language-typescript">enhancementEnabled = false;
</code></pre>
<p>The visitor’s next submission then follows the form’s original browser behavior.</p>
<p>This is safer than repeatedly retrying a broken JavaScript request.</p>
<h2>Avoid collecting the visitor’s private question</h2>
<p>Progressive enhancement also helps clarify the data boundary.</p>
<p>The form may need a broad situation category:</p>
<pre><code class="language-text">waiting
</code></pre>
<p>It does not need:</p>
<pre><code class="language-text">I sent a private message to a specific person
at 11:42 PM and they have not replied.
</code></pre>
<p>The visitor can keep the detailed question in their own mind.</p>
<p>The server receives only the minimum category required to choose eligible content.</p>
<pre><code class="language-typescript">type DrawRequest = {
  situation: "general" |
    "new-encounter" |
    "waiting";
};
</code></pre>
<p>A technically enhanced experience should not automatically become a more invasive one.</p>
<h2>Do not make analytics part of completion</h2>
<p>If analytics fails, the draw should still complete.</p>
<p>Avoid this:</p>
<pre><code class="language-typescript">await analytics.track(
  "draw_started"
);

const html = await requestResult(
  form,
  signal
);
</code></pre>
<p>If the analytics request stalls or throws an exception, the primary interaction may never run.</p>
<p>Analytics should be isolated:</p>
<pre><code class="language-typescript">void trackSafely("draw_started");

const html = await requestResult(
  form,
  signal
);
</code></pre>
<pre><code class="language-typescript">async function trackSafely(
  eventName: string
): Promise&lt;void&gt; {
  try {
    await analytics.track(eventName);
  } catch {
    // Analytics must not block the product.
  }
}
</code></pre>
<p>Measurement is secondary to the visitor’s task.</p>
<h2>Test both paths</h2>
<p>A progressively enhanced application has at least two valid modes.</p>
<h3>JavaScript disabled</h3>
<p>Test that:</p>
<ul>
<li><p>The form can be submitted.</p>
</li>
<li><p>The selected value reaches the server.</p>
</li>
<li><p>A complete result page is returned.</p>
</li>
<li><p>Validation errors remain understandable.</p>
</li>
<li><p>The visitor can return to the beginning.</p>
</li>
</ul>
<h3>JavaScript enabled</h3>
<p>Test that:</p>
<ul>
<li><p>The form is intercepted only when supported.</p>
</li>
<li><p>The submit button is disabled during the request.</p>
</li>
<li><p>Only one result is inserted.</p>
</li>
<li><p>Focus moves to the result heading.</p>
</li>
<li><p>The button is re-enabled after failure.</p>
</li>
<li><p>Reduced-motion preferences are respected.</p>
</li>
<li><p>A failed enhanced request has a standard fallback.</p>
</li>
</ul>
<p>The JavaScript-disabled test should be part of the normal test suite, not a manual check performed once during launch.</p>
<p>With Playwright, a browser context can be created with JavaScript disabled:</p>
<pre><code class="language-typescript">const context =
  await browser.newContext({
    javaScriptEnabled: false
  });

const page = await context.newPage();

await page.goto("/");

await page.getByRole(
  "button",
  { name: "Draw a result" }
).click();

await expect(
  page.getByRole("heading", {
    name: "Your result"
  })
).toBeVisible();
</code></pre>
<p>This protects the baseline experience from future regressions.</p>
<h2>Applying the pattern to a cultural web product</h2>
<p>I have been considering these principles while working on Ichizenn’s <a href="https://www.ichizenn.com/koi-mikuji/">恋みくじ</a>, a Japanese love-fortune web experience.</p>
<p>A product like 恋みくじ benefits from animation and anticipation, but its meaning should not depend entirely on those effects.</p>
<p>The essential journey remains simple:</p>
<ol>
<li><p>Consider a question privately.</p>
</li>
<li><p>Choose an appropriate situation.</p>
</li>
<li><p>Draw a result.</p>
</li>
<li><p>Read and reflect on it.</p>
</li>
</ol>
<p>That journey can exist in ordinary HTML. JavaScript can make the transition smoother without owning the entire experience.</p>
<h2>A practical implementation order</h2>
<p>For a new interactive feature, I would build in this order:</p>
<ol>
<li><p>Write the semantic HTML form.</p>
</li>
<li><p>Implement server-side validation.</p>
</li>
<li><p>Return a complete HTML result page.</p>
</li>
<li><p>Test the experience without JavaScript.</p>
</li>
<li><p>Extract a reusable server-rendered result fragment.</p>
</li>
<li><p>Add the enhanced fetch request.</p>
</li>
<li><p>Add loading and error states.</p>
</li>
<li><p>Add focus management.</p>
</li>
<li><p>Add optional animation.</p>
</li>
<li><p>Test both modes in continuous integration.</p>
</li>
</ol>
<p>This order keeps the essential interaction available throughout development.</p>
<h2>Final thoughts</h2>
<p>Progressive enhancement is not a rejection of JavaScript.</p>
<p>It is a way of assigning JavaScript the right responsibility.</p>
<p>HTML describes the task. The server provides the result. JavaScript improves the transition. CSS improves the presentation.</p>
<p>When each layer has a clear role, the experience becomes more resilient.</p>
<p>Visitors receive a working product when everything loads—and a working product when one layer does not.</p>
<p>What interactive feature in your current project could begin as a normal HTML form before becoming a JavaScript component?</p>
]]></content:encoded></item><item><title><![CDATA[Building a Translation QA Pipeline for Multilingual Web Apps]]></title><description><![CDATA[I work on the cultural web project mentioned later in this article. This post focuses on the engineering process behind multilingual content.
Adding another language to a web application often begins ]]></description><link>https://johson.hashnode.dev/building-a-translation-qa-pipeline-for-multilingual-web-apps</link><guid isPermaLink="true">https://johson.hashnode.dev/building-a-translation-qa-pipeline-for-multilingual-web-apps</guid><dc:creator><![CDATA[johson]]></dc:creator><pubDate>Thu, 03 Sep 2026 07:42:22 GMT</pubDate><content:encoded><![CDATA[<p>I work on the cultural web project mentioned later in this article. This post focuses on the engineering process behind multilingual content.</p>
<p>Adding another language to a web application often begins with two JSON files:</p>
<p>text locales/ ├── en.json └── ja.json</p>
<p>The English file contains the original interface:</p>
<p>json { "drawButton": "Draw your fortune", "resultTitle": "Your result", "shareButton": "Share" }</p>
<p>The Japanese file contains the translation:</p>
<p>json { "drawButton": "恋みくじを引く", "resultTitle": "結果", "shareButton": "シェアする" }</p>
<p>This looks manageable until the application grows.</p>
<p>New keys are added to one locale but not another. A translator removes a placeholder accidentally. An English sentence is copied into the Japanese file. A button translation becomes too long for its component. A phrase that sounds harmless in one language becomes an inappropriate promise in another.</p>
<p>A translation can be valid JSON and still be invalid product content.</p>
<p>The solution is to treat localization files as testable application data.</p>
<p>Define one source locale</p>
<p>Choose one locale as the structural reference.</p>
<pre><code class="language-typescript">const sourceLocale = "en";
const supportedLocales = ["en", "ja"] as const;
</code></pre>
<p>This does not mean that English is culturally more important. It simply gives the build process a stable reference for determining which keys should exist.</p>
<pre><code class="language-typescript">type Locale = typeof supportedLocales[number];
</code></pre>
<p>Every production locale should contain the keys required by the source locale.</p>
<h2>Flatten nested translation objects</h2>
<p>Localization files are often nested:</p>
<pre><code class="language-json">{
  "fortune": {
    "draw": "Draw",
    "result": {
      "title": "Your result",
      "description": "A small step may reveal a new path."
    }
  }
}
</code></pre>
<p>Flattening the object makes comparison easier:</p>
<pre><code class="language-typescript">type TranslationObject = {
  [key: string]: string | TranslationObject;
};

function flattenTranslations(
  input: TranslationObject,
  prefix = ""
): Record&lt;string, string&gt; {
  const output: Record&lt;string, string&gt; = {};

  for (const [key, value] of Object.entries(input)) {
    const fullKey = prefix ? `${prefix}.${key}` : key;

    if (typeof value === "string") {
      output[fullKey] = value;
    } else {
      Object.assign(
        output,
        flattenTranslations(value, fullKey)
      );
    }
  }

  return output;
}
</code></pre>
<p>The previous object becomes:</p>
<pre><code class="language-typescript">{
  "fortune.draw": "Draw",
  "fortune.result.title": "Your result",
  "fortune.result.description":
    "A small step may reveal a new path."
}
</code></pre>
<h2>Detect missing keys</h2>
<p>The first validation compares the available keys.</p>
<pre><code class="language-typescript">function findMissingKeys(
  source: Record&lt;string, string&gt;,
  target: Record&lt;string, string&gt;
): string[] {
  return Object.keys(source).filter(
    (key) =&gt; !(key in target)
  );
}
</code></pre>
<p>It can be used during the build:</p>
<pre><code class="language-typescript">const missingJapaneseKeys = findMissingKeys(
  flattenTranslations(en),
  flattenTranslations(ja)
);

if (missingJapaneseKeys.length &gt; 0) {
  throw new Error(
    `Missing Japanese translations:\n` +
    missingJapaneseKeys.join("\n")
  );
}
</code></pre>
<p>This prevents an incomplete locale from silently falling back to English in production.</p>
<p>The reverse comparison can detect obsolete keys:</p>
<pre><code class="language-typescript">function findUnexpectedKeys(
  source: Record&lt;string, string&gt;,
  target: Record&lt;string, string&gt;
): string[] {
  return Object.keys(target).filter(
    (key) =&gt; !(key in source)
  );
}
</code></pre>
<p>Unexpected keys are not always errors, but they may indicate that an old translation was left behind after a feature changed.</p>
<h2>Validate placeholders</h2>
<p>Translations frequently contain dynamic values:</p>
<pre><code class="language-json">{
  "welcome": "Welcome, {name}",
  "resultCount": "You have {count} saved results"
}
</code></pre>
<p>A translator may accidentally remove or rename a placeholder:</p>
<pre><code class="language-json">{
  "welcome": "ようこそ",
  "resultCount": "{total}件の結果があります"
}
</code></pre>
<p>The application expects <code>{name}</code> and <code>{count}</code>, but the translated content provides neither.</p>
<p>We can extract placeholders:</p>
<pre><code class="language-typescript">function extractPlaceholders(
  value: string
): string[] {
  return [
    ...value.matchAll(/\{([a-zA-Z0-9_]+)\}/g)
  ]
    .map((match) =&gt; match[1])
    .sort();
}
</code></pre>
<p>Then compare them:</p>
<pre><code class="language-typescript">function placeholdersMatch(
  source: string,
  target: string
): boolean {
  return JSON.stringify(
    extractPlaceholders(source)
  ) === JSON.stringify(
    extractPlaceholders(target)
  );
}
</code></pre>
<p>The build should report the exact key:</p>
<pre><code class="language-typescript">if (!placeholdersMatch(sourceValue, targetValue)) {
  errors.push(
    `${locale}:${key} has different placeholders`
  );
}
</code></pre>
<p>This catches a common class of runtime errors before the interface is opened.</p>
<h2>Detect untranslated content carefully</h2>
<p>An identical source and target value can indicate missing translation:</p>
<pre><code class="language-typescript">if (
  locale !== sourceLocale &amp;&amp;
  sourceValue === targetValue
) {
  warnings.push(
    `${locale}:${key} may be untranslated`
  );
}
</code></pre>
<p>This should usually be a warning, not a build failure.</p>
<p>Some terms are intentionally identical:</p>
<ul>
<li><p>JavaScript</p>
</li>
<li><p>TypeScript</p>
</li>
<li><p>API</p>
</li>
<li><p>URL</p>
</li>
<li><p>Product names</p>
</li>
<li><p>Version numbers</p>
</li>
</ul>
<p>An allowlist prevents unnecessary warnings:</p>
<pre><code class="language-typescript">const identicalValuesAllowed = new Set([
  "JavaScript",
  "TypeScript",
  "API",
  "URL"
]);
</code></pre>
<p>Automated checks should support editorial judgment, not replace it.</p>
<h2>Add content safety rules</h2>
<p>A culturally sensitive application may need rules beyond ordinary translation completeness.</p>
<p>For example, a reflective product should avoid presenting entertainment content as certainty.</p>
<pre><code class="language-typescript">const restrictedClaims = [
  "guaranteed",
  "will definitely",
  "100% accurate",
  "cannot fail"
];
</code></pre>
<pre><code class="language-typescript">function findRestrictedClaims(
  value: string
): string[] {
  const normalized = value.toLowerCase();

  return restrictedClaims.filter(
    (claim) =&gt; normalized.includes(claim)
  );
}
</code></pre>
<p>Japanese content needs its own reviewed list rather than a literal translation of the English restrictions.</p>
<p>The goal is not to block every emotionally expressive sentence. It is to flag wording that deserves human review.</p>
<p>Warnings might include:</p>
<pre><code class="language-text">en:result.promise contains “guaranteed”
ja:result.promise contains a restricted certainty claim
</code></pre>
<p>A content editor can then decide whether the sentence is appropriate.</p>
<h2>Do not use character length as a quality score</h2>
<p>It is tempting to require every translation to stay within the same character count as the source.</p>
<p>That does not work across languages.</p>
<pre><code class="language-text">Share
</code></pre>
<p>and:</p>
<pre><code class="language-text">シェアする
</code></pre>
<p>have different character counts but similar interface purposes.</p>
<p>Length checks are still useful as warnings for constrained components:</p>
<pre><code class="language-typescript">type LengthRule = {
  key: string;
  maximum: number;
};

const lengthRules: LengthRule[] = [
  {
    key: "fortune.drawButton",
    maximum: 24
  },
  {
    key: "navigation.home",
    maximum: 16
  }
];
</code></pre>
<p>Do not fail an entire translation only because it exceeds an arbitrary English-based limit. Instead, send the key to visual review.</p>
<h2>Validate HTML and Markdown boundaries</h2>
<p>If translations are expected to contain plain text, reject embedded HTML:</p>
<pre><code class="language-typescript">function containsHtml(value: string): boolean {
  return /&lt;\/?[a-z][\s\S]*&gt;/i.test(value);
}
</code></pre>
<pre><code class="language-typescript">if (containsHtml(targetValue)) {
  errors.push(
    `${locale}:${key} contains unexpected HTML`
  );
}
</code></pre>
<p>This reduces inconsistent presentation and lowers the risk of unsafe content entering rendering paths.</p>
<p>If rich text is required, define exactly which format and elements are allowed. Do not let individual translation strings invent their own markup conventions.</p>
<h2>Produce one readable QA report</h2>
<p>A useful validation script should separate errors from warnings.</p>
<pre><code class="language-typescript">type ValidationReport = {
  errors: string[];
  warnings: string[];
};
</code></pre>
<p>Errors can block deployment:</p>
<ul>
<li><p>Missing required keys</p>
</li>
<li><p>Invalid placeholder names</p>
</li>
<li><p>Unsupported value types</p>
</li>
<li><p>Unexpected HTML</p>
</li>
<li><p>Invalid locale files</p>
</li>
</ul>
<p>Warnings can request human review:</p>
<ul>
<li><p>Identical source and target values</p>
</li>
<li><p>Unusually long button labels</p>
</li>
<li><p>Restricted emotional claims</p>
</li>
<li><p>Obsolete translation keys</p>
</li>
<li><p>Punctuation inconsistencies</p>
</li>
</ul>
<pre><code class="language-typescript">function printReport(
  report: ValidationReport
): void {
  for (const warning of report.warnings) {
    console.warn(`WARNING: ${warning}`);
  }

  for (const error of report.errors) {
    console.error(`ERROR: ${error}`);
  }

  if (report.errors.length &gt; 0) {
    process.exitCode = 1;
  }
}
</code></pre>
<p>The report should tell developers what to fix, not merely say that localization failed.</p>
<h2>Run validation in continuous integration</h2>
<p>Add the validator to the project scripts:</p>
<pre><code class="language-json">{
  "scripts": {
    "validate:i18n": "tsx scripts/validate-i18n.ts",
    "test": "npm run validate:i18n &amp;&amp; vitest"
  }
}
</code></pre>
<p>Then execute it in continuous integration:</p>
<pre><code class="language-yaml">- name: Validate translations
  run: npm run validate:i18n
</code></pre>
<p>A missing translation is much cheaper to fix before deployment than after a visitor encounters a broken result.</p>
<h2>Automated tests are only the first layer</h2>
<p>A complete localization QA process also needs visual and human review.</p>
<p>Test each supported language with:</p>
<ul>
<li><p>Narrow mobile screens</p>
</li>
<li><p>Enlarged text</p>
</li>
<li><p>Keyboard navigation</p>
</li>
<li><p>Screen readers where possible</p>
</li>
<li><p>Slow font loading</p>
</li>
<li><p>Long dynamic values</p>
</li>
<li><p>Empty and error states</p>
</li>
<li><p>Share cards and social previews</p>
</li>
</ul>
<p>A JSON validator cannot determine whether Japanese text feels natural, whether a metaphor survives translation, or whether an interface remains culturally respectful.</p>
<p>Those decisions still require people.</p>
<h2>Applying the process to a cultural product</h2>
<p>I have been exploring this workflow while working on Ichizenn’s <a href="https://www.ichizenn.com/koi-mikuji/">恋みくじ</a>, a Japanese love-fortune web experience.</p>
<p>The product contains short emotional messages, interface instructions, result categories, and culturally specific terminology. A translation error in this context is not only a cosmetic problem. It can change the emotional meaning of a result.</p>
<p>That makes localization part of the product architecture rather than a final writing task.</p>
<h2>Final checklist</h2>
<p>Before shipping a new locale, verify that:</p>
<ul>
<li><p>Every required key exists.</p>
</li>
<li><p>Obsolete keys are reported.</p>
</li>
<li><p>Placeholders match the source.</p>
</li>
<li><p>Plain-text fields contain no HTML.</p>
</li>
<li><p>Restricted claims receive human review.</p>
</li>
<li><p>Length warnings are visually tested.</p>
</li>
<li><p>Language attributes are correct.</p>
</li>
<li><p>Mobile layouts are checked.</p>
</li>
<li><p>Accessibility behavior remains intact.</p>
</li>
<li><p>A native speaker reviews the final experience.</p>
</li>
</ul>
<h2>Final thoughts</h2>
<p>Localization files are executable product content.</p>
<p>They control what visitors read, which actions they understand, and how the application communicates uncertainty, errors, privacy, and trust.</p>
<p>Treating translations as testable data does not remove the need for translators and editors. It gives them a safer system in which to work.</p>
<p>Which localization problems have escaped into production in your projects: missing keys, broken placeholders, layout overflow, or something harder to automate?</p>
]]></content:encoded></item></channel></rss>