A build plugin for structural string literal minification and obfuscation.
unplugin-keywords addresses a fundamental limitation in JavaScript minification: the inability to safely mangle string literals used as object keys, custom event types, or structural constants. By explicitly importing these identifiers from a virtual module, the plugin extracts them at the AST level and maps them to deterministic, short hashes during the build process. This explicit opt-in mechanism empowers bundlers to inline and obfuscate application internals without breaking semantic contracts.
Traditional JavaScript minifiers rely on property mangling (e.g., Terser's mangle.properties) to reduce structural identifiers. unplugin-keywords provides a module-based alternative that addresses the structural limitations of global mangling.
- Explicit Opt-In:
Traditional property mangling requires maintaining complex, global exclusion rules (e.g.,
mangle.json), which are fragile and hard to scale.unplugin-keywordsutilizes explicit imports (import * as K from 'virtual:keywords'). Developers unambiguously declare which identifiers are safe to obfuscate directly in the source code. - Gradual Adoption: Unlike global mangling flags that affect the entire codebase simultaneously, installing this plugin alters nothing by default. It allows incremental adoption on a per-file or per-module basis.
- Cross-Boundary Consistency:
Standard mangled properties cannot safely cross package boundaries; a property mangled to
ain Package A will not map toain Package B. Becausevirtual:keywordsrelies on deterministic hashing, identical keys inherently produce identical hashes across independent builds, preserving structural contracts. - Universal Application:
Standard minifiers only mangle object keys, leaving string literal values intact. This plugin processes both keys and values uniformly (e.g.,
[K.type]: K.SET_USER). It extends obfuscation to literal types (const mode: typeof K.extract | typeof K.transform = K.extract) and even arbitrary static strings (throw new Error(K['Invalid State'])). - Trade-offs: This explicit approach sacrifices some source code readability. Furthermore, as demonstrated in the benchmarks below, standard gzip compression handles unmodified semantic strings highly effectively. If reducing the gzipped network payload is the sole objective, the effort of adopting this plugin may not justify the minimal payload reduction.
A side-by-side comparison of minified bundles:
| Unmodified (Standard Minification) | Keywordified (Literal Obfuscation) |
|---|---|
![]() |
![]() |
| 6.86 kB │ gzip: 2.09 kB | 5.40 kB │ gzip: 2.05 kB |
Note
Baseline Metrics: Both the "Unmodified" and "Keywordified" metrics represent standard tsdown minification. The official @preact/signals-core@1.14.1 release achieves a smaller footprint (5.4 kB Minified / 1.9 kB Gzipped) by employing a hand-crafted mangle.json for manual property obfuscation.
Compression Efficiency: While the uncompressed bundle size is reduced by 21.3%, the gzipped size is only 1.9% smaller. This demonstrates the effectiveness of standard gzip compression on unmodified code: if minimizing the gzipped network payload is the sole objective, adopting this plugin is unnecessary.
For more information, see the demo documentation.
Standard minifiers operate exclusively on variable bindings and function names, leaving structural strings intact. While this preserves the semantic contract, it inflates bundle size and exposes internal state architecture (e.g., Redux action types, state machine nodes).
unplugin-keywords solves this by treating structural strings as imported module bindings.
1. Source Code (Development):
Developers reference strings via a virtual module. The strongly recommended pattern is to use a namespace import (import * as K), which clearly demarcates keyword usage throughout the file.
import * as K from 'virtual:keywords';
const action = {
[K.type]: K.SET_USER,
[K.payload]: data,
};2. AST Transformation: During the build phase, the plugin traverses the AST, resolving bindings and statically resolving member expressions. It replaces valid identifier access with a generated AST node pointing to a deterministic base62 hash or a minimal lexical sequence.
3. Minified Output (Production): The bundler receives the transformed code and processes the hashed literals. Depending on the frequency of usage, the minifier will either inline the strings directly or extract them into single-character variables to save bytes.
// Example of minifier output: strings may be inlined or assigned to variables if used multiple times
const _="z2pL21k";const a={a3fB9zX:_,k1Mw8pA:data};unplugin-keywords provides two distinct virtual modules. While exclusively using K.* is a perfectly valid and robust approach, the dual-module system allows further bundle size reduction.
-
virtual:keywords(Stable Hash): Generates deterministic, key-derived hashes (e.g.,"z2pL21k"). Designed for public-facing APIs and structural contracts that must remain consistent across package boundaries (e.g.,package.jsonexports). Convention:import * as K from 'virtual:keywords'; -
virtual:keywords/local(Lexical Counter): Generates the shortest possible sequential identifiers (min length: 2, e.g.,"a0","b0"). Intended for internal and local implementations where cross-boundary stability is irrelevant. Convention:import * as L from 'virtual:keywords/local';
Module Separation:
To minimize bundle size, identifiers can be partitioned: bind public interfaces to K.*, and obscure all internal state and private members behind L.*.
Install the package:
npm install -D unplugin-keywordsConfigure your bundler. Example for Vite:
import { defineConfig } from 'vite';
import keywords from 'unplugin-keywords/vite';
export default defineConfig(({ mode }) => ({
plugins: [
keywords({
// Preserves keyword suffix in development for debugging (e.g., "zXpL21k.SET_USER")
isDev: mode === 'development',
// Initializes the hashing algorithm. Modify to rotate hashes globally.
secret: 'my-secret-key',
}),
],
}));To enable type checking and IDE auto-completion, execute the CLI and register the output in tsconfig.json:
npx keywordsTip
During development, the plugin automatically runs a background type generation process while the bundler is running. Manual CLI execution is only necessary for pre-flight type checking (e.g., in CI) before the bundler runs.
The namespace import pattern is applicable in class-based architectures where structural symbols are heavily used for internal state and lifecycle methods.
Important
Overriding lifecycle methods (e.g., [K.render]) requires a modified base class—such as a custom build of Lit—compiled with unplugin-keywords to dispatch the hashed keys. Sharing this dictionary across the ecosystem enables consistent obfuscation.
// Source: https://github.com/lit/lit/blob/main/packages/lit-html/src/directives/async-replace.ts
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import * as K from 'virtual:keywords';
import * as L from 'virtual:keywords/local';
import {
AsyncDirective,
type DirectiveParameters,
} from '../async-directive.js';
import { type ChildPart, noChange } from '../lit-html.js';
import { forAwaitOf, Pauser, PseudoWeakRef } from './private-async-helpers.js';
type Mapper<T> = (v: T, index?: number) => unknown;
export class AsyncReplaceDirective extends AsyncDirective {
private [L.__value]?: AsyncIterable<unknown>;
private [L.__weakThis] = new PseudoWeakRef(this);
private [L.__pauser] = new Pauser();
[K.render]<T>(_value: AsyncIterable<T>, _mapper?: Mapper<T>) {
return noChange;
}
override [K.update](_part: ChildPart, [value, mapper]: DirectiveParameters<this>) {
if (!this[K.isConnected]) {
this[K.disconnected]();
}
if (value === this[L.__value]) {
return noChange;
}
this[L.__value] = value;
let i = 0;
const { [L.__weakThis]: weakThis, [L.__pauser]: pauser } = this;
forAwaitOf(value, async (v: unknown) => {
while (pauser[L.get]()) {
await pauser[L.get]();
}
const _this = weakThis[L.deref]();
if (_this !== undefined) {
if (_this[L.__value] !== value) {
return false;
}
if (mapper !== undefined) {
v = mapper(v, i);
}
_this[K.commitValue](v, i);
i++;
}
return true;
});
return noChange;
}
protected [K.commitValue](value: unknown, _index: number) {
this[K.setValue](value);
}
override [K.disconnected]() {
this[L.__weakThis][L.disconnect]();
this[L.__pauser][L.pause]();
}
override [K.reconnected]() {
this[L.__weakThis][L.reconnect](this);
this[L.__pauser][L.resume]();
}
}In production, all internal properties (e.g., __value, __pauser) will be completely minified to short sequence identifiers (via virtual:keywords/local), obfuscating internal property names from the bundled Lit component.
Tip
Native ECMAScript private fields (#prop) are safely mangled by standard minifiers, eliminating the need for plugin obfuscation for internal class state.
// Modular Imports
import { type, 'kebab-case' as kebab } from 'virtual:keywords';
// JSX Injection
const View = () => (
<K.Container>
<div />
</K.Container>
);
// Advanced TypeScript Inference
interface StateMachine {
[K.idle]: typeof K.active;
value: (typeof K)['kebab-case'];
}
// Module Re-exports
export { internalState as state } from 'virtual:keywords';
// UNSUPPORTED: Export All (Lacks static traceability)
export * from 'virtual:keywords';MIT


{ "compilerOptions": { "paths": { "virtual:keywords": ["./node_modules/.keywords/index.d.ts"], "virtual:keywords/local": ["./node_modules/.keywords/local.d.ts"] } } }