character-parser
character-parser is a zero-dependency scanner for finding balanced JavaScript-like sections inside templates. It reads UTF-16 code units, tracks parentheses, braces, brackets, quoted strings, template literals, regular-expression literals, and comments, then helps you stop at a delimiter that occurs outside those contexts. It is useful inside template and preprocessor tooling. The README explicitly says it is not a JavaScript validator, and its regex-versus-division decision is heuristic rather than a full grammar parse.
character-parser is a good narrow tool for template delimiters when a full parser would be unnecessary at the scanning stage. Do not mistake it for JavaScript validation, and work around the ignored parseUntil end option and heuristic slash handling.
Use it if
- You are building a template language and need to find a closing marker without stopping inside JavaScript strings, comments, or nested brackets
- Input arrives in chunks and you need to carry bracket, quote, comment, and escape state into the next scan
- A compiler utility needs a tiny ESM or CommonJS dependency with bundled TypeScript declarations and no runtime dependencies
- You only need structural scanning and will send the extracted JavaScript to a real parser or compiler afterward
- You need to validate or transform JavaScript syntax: the README says this is not a validator, and the source uses a short heuristic to decide whether slash begins a regex or means division
- You need an AST, identifiers, scopes, source locations, JSX, TypeScript, or syntax recovery: character-parser exposes only scanner state and substring boundaries
- You need parseUntil to honor an end option: version 4.0.0 declares end in TypeScript but the implementation loops to src.length and never reads options.end
- You need Unicode code-point iteration through parseChar: it requires a JavaScript string of length exactly one, so a single astral symbol such as many emoji is two UTF-16 code units and is rejected
- You need recent language syntax tracked through regular releases: npm 4.0.0 dates to December 2021, and although the repository was pushed in August 2024, the scanner's keyword list and slash heuristic remain hand-maintained
Setup reality
npm install character-parser adds no dependencies, native build, peers, credentials, or config. Version 4 exports both ESM and CommonJS targets and includes declarations. In ESM, import the default parse function or named exports such as parseUntil and parseChar. In CommonJS, use require('character-parser').parse or destructuring; require('character-parser') returns an exports object, not the parse function itself. The main choice is between parse and parseUntil. parse updates a State and can continue across chunks, but it only reports structural state. parseUntil always creates a fresh state, starts at options.start, and returns a half-open start/end range excluding the delimiter. It throws if no usable delimiter is found. Delimiters inside strings and comments are skipped, and bracket delimiters also respect nesting unless ignoreNesting is true. ignoreLineComment changes whether a delimiter inside a line comment can count. A RegExp delimiter is tested repeatedly against each remaining substring; avoid global or sticky flags because RegExp.lastIndex can make repeated test calls stateful. All syntax failures carry a code beginning CHARACTER_PARSER:, while parse and parseUntil also attach the failing index. The state stores the entire accumulated src plus reversed history, so feeding a very large stream one character at a time retains input and is not a constant-memory tokenizer. Extracted code still needs Acorn, Babel, TypeScript, or another real parser before you trust it as JavaScript.
Patterns
Inspect unclosed nestingparse-nesting-state
import parse from 'character-parser';
const state = parse('call(arg, {items: [1, 2]');
console.log(state.stack);The stack contains token-type constants, not literal closing characters, despite older examples showing bracket characters.
Carry parser state across chunkscontinue-across-chunks
import { defaultState, parse } from 'character-parser';
let state = defaultState();
state = parse('render({title: "hel', state);
state = parse('lo"})', state);
console.log(state.isNesting()); // falseReuse the returned State. It accumulates src and reversed history, so long-running streams retain all scanned content.
Scan only part of a source stringparse-source-range
import { parse } from 'character-parser';
const src = 'prefix fn({x: 1}) suffix';
const state = parse(src, undefined, { start: 7, end: 17 });The end index is exclusive. Because the implementation uses options.end || src.length, an end value of 0 cannot request an empty scan.
Extract code before a template delimiterextract-until-delimiter
import { parseUntil } from 'character-parser';
const section = parseUntil('user.name.replace("%>", "") %> rest', '%>');
console.log(section.src);
console.log(section.start, section.end);The delimiter inside the quoted string is ignored. end points to the first delimiter character and the delimiter is excluded from src.
Start after an opening template markerextract-from-offset
const input = '<% profile({name: "%>"}) %> tail';
const section = parseUntil(input, '%>', { start: 2 });
console.log(section.src);parseUntil creates fresh scanner state at start. Do not start in the middle of an already-open string, comment, regex, or bracket.
Stop at one of several delimiter spellingsmatch-regex-delimiter
const section = parseUntil('value + 1 -%> tail', /-?%>/);Avoid g and y flags. parseUntil repeatedly calls RegExp.test, and stateful lastIndex behavior can make global or sticky delimiters skip matches.
Stop without respecting nested bracketsignore-bracket-nesting
const section = parseUntil('#[p= [1, 2][i]]', ']', {
start: 2,
ignoreNesting: true,
});
console.log(section.src); // p= [1, 2This restores the simpler version 1 behavior and can cut out syntactically incomplete code.
Let a delimiter inside a line comment countallow-line-comment-delimiter
const section = parseUntil('value // close %> ignored', '%>', {
ignoreLineComment: true,
});By default a line comment counts as nesting until newline, so its delimiter is ignored. Block comments are still ignored.
Drive the scanner one character at a timeparse-single-character
import { defaultState, parseChar } from 'character-parser';
const state = defaultState();
for (const codeUnit of 'fn([1])'.split('')) {
parseChar(codeUnit, state);
}parseChar requires string length 1 in UTF-16 units. Iterating an astral symbol with for...of produces a length-2 string and triggers CHAR_LENGTH_NOT_ONE.
Check whether scanning is inside text or commentsinspect-current-context
const state = parse('fn({message: `hello ${name}`');
console.log(state.current());
console.log(state.isString());
console.log(state.isComment());
console.log(state.isNesting());Template interpolation adds a curly-bracket frame inside the template-quote frame, so current() reflects only the innermost context.
Handle coded syntax and delimiter failureshandle-parser-errors
try {
parseUntil('fn([1, 2)', '%>');
} catch (err) {
if (err.code?.startsWith('CHARACTER_PARSER:')) {
console.error(err.code, err.index);
} else {
throw err;
}
}parse and parseUntil attach an index. parseChar errors have a code but no source index unless you add one.
Use the exported keyword and punctuation checksinspect-lexical-helpers
import { isKeyword, isPunctuator } from 'character-parser';
console.log(isKeyword('return')); // true
console.log(isPunctuator('/')); // trueThese helpers support the package's regex-versus-division heuristic; they are not a complete ECMAScript tokenizer.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| balanced-match | npm | You only need the range between balanced delimiters and do not need JavaScript string or comment awareness |
| acorn | npm | You need a standards-focused JavaScript parser, tokens, or an AST rather than substring scanning |
| esprima | npm | You want a familiar ESTree parser and tokenizer for standard JavaScript source |
| @babel/parser | npm | The embedded language can include JSX, TypeScript, decorators, or other Babel syntax plugins |