express-static-l10n
v0.2.0
Published
Localize existing static HTML at request time with Express.
Maintainers
Readme
express-static-l10n
Localize existing static HTML at request time with Express—without moving the site into a template engine or shipping a browser translation runtime.
<h1 data-i18n="home.title">Welcome</h1>The middleware negotiates a locale, loads JSON catalogs, safely replaces text
and explicitly selected attributes, sets the HTTP language metadata, and then
sends the HTML. Other files continue to be served by express.static().
Install
npm install express-static-l10n expressNode.js 22 or newer, Express 4.22.2+, and Express 5.2.1+ are supported. The package is ESM-only.
Quick start
import { fileURLToPath } from "node:url";
import express from "express";
import {
FileSizeLimitError,
jsonDirectory,
localizedStatic
} from "express-static-l10n";
const app = express();
const publicRoot = fileURLToPath(new URL("./public", import.meta.url));
const localeRoot = fileURLToPath(new URL("./locales", import.meta.url));
app.use(localizedStatic({
root: publicRoot,
locales: ["en", "ko"],
defaultLocale: "en",
catalog: jsonDirectory({
root: localeRoot
})
}));
// The localization middleware deliberately ignores non-HTML assets.
app.use(express.static(publicRoot));
app.use((error, _request, response, next) => {
if (error instanceof FileSizeLimitError) {
response.status(413).json({ code: error.code });
return;
}
next(error);
});
app.listen(3000);With no bundles, catalogs are stored as one file per locale:
locales/
en.json
ko.json{
"home": {
"title": "Welcome"
}
}Locale detection is ?lang= / ?locale=, then the locale cookie, then
Accept-Language, then defaultLocale. The order and names are configurable.
A runnable version of this setup, with English and Korean catalogs, lives in
examples/basic-site.
HTML contract
Text is replaced by data-i18n:
<h1 data-i18n="home.title">Source-language fallback</h1>Attributes are opt-in. A bare attribute name uses the element's data-i18n
key; attribute:key selects a different key.
<input
data-i18n="search.placeholder"
data-i18n-attr="placeholder aria-label"
placeholder="Search"
aria-label="Search"
>
<img
data-i18n-attr="alt:hero.description title:hero.tooltip"
alt="Source description"
>Only alt, aria-label, content, placeholder, title, and value are
translatable by default. Translation values are always serialized as text or
attribute values; HTML injection is not supported. Text bindings are rejected
on raw-text elements such as script, style, and iframe, and content
cannot be translated on a meta element with http-equiv.
Small, explicit interpolation uses JSON parameters:
<p
data-i18n="hello"
data-i18n-params='{"name":"Kim"}'
>Hello</p>{ "hello": "Hello, {{name}}!" }Malformed parameter JSON is an error. ICU messages, plural rules, and HTML translations are intentionally outside v0.1.
Bundles
A page can declare ordered bundles:
<meta name="i18n-bundles" content="common,home">The default directory layout becomes:
locales/
en/
common.json
home.json
ko/
common.json
home.jsonObjects are merged deeply in declaration order; a later bundle replaces an
earlier string or subtree. Programmatic bundles are loaded before page
bundles. Missing locale bundles fall back per key to fallbackLocale (or
defaultLocale).
Options
localizedStatic({
root: "./public",
locales: ["en", "ko", "fr"],
defaultLocale: "en",
fallbackLocale: "en",
catalog: jsonDirectory({
root: "./locales",
onError: "throw",
maxBytes: 1_048_576,
maxBundles: 32
}),
bundles: ["common"],
detect: {
order: ["query", "cookie", "header"],
query: ["lang"],
cookie: ["locale"]
},
persistCookie: { name: "locale", maxAgeSeconds: 2_592_000 },
missingKey: "source",
cache: { maxEntries: 100, ttlMs: 60_000 },
maxHtmlBytes: 2_097_152,
maxBundles: 32,
fallthrough: true
});missingKey: "source"keeps the original HTML;"key"displays the key;"error"fails the request.localesmust contain unique, valid BCP 47 language tags.jsonDirectory({ onError: "throw" })surfaces invalid JSON and permission errors."stale"keeps the last successfully parsed snapshot after a later failure. A first failure still throws.cache: falsedisables HTML/response caching. Otherwise both caches are bounded LRUs. Catalog versions and HTML mtimes invalidate transformed output.- HTML input defaults to a 2 MiB per-file limit and
jsonDirectorycatalogs to 1 MiB per file.maxHtmlBytesandjsonDirectory({ maxBytes })must be positive safe integers. Oversized input throwsFileSizeLimitErrorwith stable codeERR_FILE_SIZE_LIMIT; map it to HTTP 413 in your Express error handler if that is the desired application policy. CatalogonError: "stale"may serve the last good snapshot after a later file exceeds its limit. - At most 32 unique configured and page-declared bundles are loaded for one
response by default. Set
maxBundleson both the middleware andjsonDirectorywhen choosing another bounded fan-out. - The returned middleware has
clearCache(), which also clears a catalog provider that exposes the same method. persistCookieappends aPath=/; SameSite=Laxcookie. ExistingSet-CookieandVaryheaders are preserved.maxAgeSecondsmust be a non-negative safe integer.fallthrough: falsereturns a neutral 404 for missing HTML. The default calls the next middleware.translatableAttributesreplaces the default attribute allowlist; unsafe and URL-bearing attributes are still rejected.indexsets the directory index file name (defaultindex.html) orfalseto disable index resolution.dotfiles: trueserves paths whose segments begin with a dot, which are hidden by default.
HTTP and filesystem behavior
Only GET and HEAD requests resolving to .html files are handled.
Directory requests may resolve to index.html. Dotfiles are hidden by default,
decoded traversal is rejected, and HTML or catalog symlinks cannot escape their
configured roots.
Responses include Content-Language. Vary: Cookie, Accept-Language is
appended when those detectors are enabled.
File-size checks use metadata from the same open file used for reading. Reads stop at the configured ceiling if a file grows, and concurrent file changes are rejected rather than cached. The limits bound individual inputs; total request cost still depends on bundle count, document structure, translation count, and the custom catalog provider, if any.
Non-goals
v0.1 does not provide a client runtime, template engine, remote translation
API, build-time generator, authentication integration, automatic translation,
SEO URL generation, canonical/hreflang rewriting, ICU/plural rules, or
data-i18n-html.
Development
npm install
npm run verify
npm run benchmark:quickThe integration suite runs the same middleware against Express 4 and 5.
npm run benchmark measures transform cost at 100, 1,000, and 10,000 bound
elements using fixed synthetic input. Compare runs on the same machine and
Node.js version; it excludes filesystem and catalog-provider I/O.
Implementation rationale and the full behavior matrix are recorded in the design notes.
See CONTRIBUTING.md before changing the HTML or middleware contract. Report vulnerabilities privately as described in SECURITY.md.
Related projects
- vite-deploy-continuity — serving built assets coherently across deployments of the same static site.
- playwright-render-contract — verifying the localized HTML renders and behaves as intended.
