This file provides guidance to AI coding assistants (Claude Code, GitHub Copilot, Cody, etc.) when working with the json-template repository.
@kagal/json-template is a TypeScript template engine
for JSON documents with shell-style ${var:-default}
variable substitution. It compiles a JSON template
string once, then renders it to native JavaScript
objects by resolving variables against a context.
src/
├── types.ts — TemplateVariable, CompileOptions
├── errors.ts — TemplateParseError, UnresolvedVariableError
├── json.ts — jsonNull, isNull, isNonStringPrimitive, isObject
├── scanner.ts — scan(), ScannedExpr
├── tree.ts — buildTree(), TNode, IPart, SENTINEL
├── template.ts — Template class, compile(), listVariables()
├── index.ts — barrel re-exports
└── __tests__/
├── index.test.ts — VERSION test
├── scanner.test.ts — scanner and parse error tests
└── template.test.ts — Template rendering tests
pnpm build # Build with unbuild
pnpm test # Run vitest
pnpm lint # ESLint with auto-fix
pnpm typecheck # tsc --noEmit
pnpm precommit # build, lint, typecheck, testEnforced by .editorconfig and @poupe/eslint-config:
- Indentation: 2 spaces
- Line Endings: Unix (LF)
- Charset: UTF-8
- Quotes: Single quotes
- Semicolons: Always
- Module System: ES modules (
type: "module") - Line Length: Max 78 characters preferred
- Comments: Use TSDoc format for documentation
- Naming: camelCase for variables/functions, PascalCase for types/interfaces/classes
- Final Newline: Always insert
- Trailing Whitespace: Always trim
The unicorn/no-null rule is enforced project-wide.
Use jsonNull and isNull() from json.ts where
JSON null semantics are needed. A single
eslint-disable exists at the jsonNull declaration.
Use undefined where semantically viable.
Before committing any changes, ALWAYS run:
pnpm precommit(if any source changed)- Fix any issues found
- Write tests for all new functionality
- Check existing code patterns before creating new ones
- Follow strict TypeScript practices
- Use
git -C <subpath>instead ofcdfor git on subpaths, but not-C .at repo root
- Create files unless necessary — prefer editing existing ones
- Add external dependencies without careful consideration
- Ignore TypeScript errors or ESLint warnings
- NEVER use
git add .orgit add -A - NEVER commit without explicitly listing files
- NEVER rely on the staging area — always list files explicitly
- NEVER use
cd— it loses working directory context for all subsequent tool calls
- Always use
-sflag for sign-off - Write clear messages describing actual changes
- No AI advertising in commit messages
- Focus commit messages on the final result, not the iterations
ALWAYS list files explicitly in the commit command.
Use git add only for new/untracked files, then pass
all files (new and modified) to git commit.
# Stage new files, then commit with explicit file list
git add src/new-file.ts
git commit -sF .tmp/commit-<slug>.txt -- src/new-file.ts src/changed.tsTemporary message files use a shared prefix with a meaningful slug:
- Commit messages:
.tmp/commit-<slug>.txt - PR descriptions:
.tmp/pr-<slug>.md
- First line: type(scope): brief description (50 chars)
- Blank line
- Body: what and why, not how (wrap at 72 chars)
- Use bullet points for multiple changes
- Reference issues/PRs when relevant
- Build: unbuild (ESM + DTS, sourcemaps)
- Test: Vitest with v8 coverage (90/90/85 thresholds)
- Lint: @poupe/eslint-config via
defineConfig() - Prepare:
cross-test -s dist/index.mjs || unbuild --stub
Published via GitHub Actions using npm's OIDC trusted
publishing with --provenance. No tokens stored as
secrets.
Compilation is three phases. Rendering is a single tree walk.
template string
│
▼
scan() → ScannedExpr[]
(phase 1: find expressions,
track string context,
pre-split dotted paths)
│
▼
sentinel replace → modified JSON str
+ JSON.parse() → unknown
(phase 2: substitute expressions
with markers, parse once)
│
▼
buildTree() → TNode
(phase 3: convert parsed JSON
into template AST)
─── compile time ends here ───
Template.render() → unknown
(per-call: walk tree, resolve
vars, assemble object)
These are the things most likely to break during modification:
-
Scanner string tracking determines everything downstream. The
inStringflag when${is encountered determines whether the sentinel getsB(bare) orE(embedded) prefix. The scanner's backslash handling (pos += 2to skip\") must exactly mirror JSON's escape rules. -
Expression index = sentinel index. The
for (const [i, expr] of exprs.entries())loop incompile()writes sentinels usingias the index. If expressions were ever reordered or filtered betweenscan()and sentinel replacement, everyTNode.idxin the tree would point to the wrongScannedExpr. -
buildTree()runs once at compile time;Template.renderNode()runs per-call. Structural classification and default parsing belong at compile time. Variable resolution belongs inTemplate.renderNode. Moving compile-time work into render or vice versa is a correctness risk. -
Object keys are checked for sentinels in
buildTree, not the scanner. The scanner doesn't know about JSON structure. The key-rejection check happens afterJSON.parse. If you add key support, you'd need a newTNodekind for interpolated keys and correspondingTemplate.renderNodelogic. -
SENTINEL_REis a module-level regex intree.tswith thegflag. ItslastIndexis reset before each use inbuildTree. If you add another call site, you must also resetlastIndex. -
compile()rejects the sentinel character (U+E000) in input. The PUA character is used as an in-band marker betweenscan()andJSON.parse. If it appeared in user input — either as a literal character or as a JSON-encoded\uE000escape — it would collide with the markers and cause miscompilation. Both forms are checked beforescan(). -
Embedded non-primitives use
JSON.stringify, notString().String({})produces[object Object]. The current code checksisObject(value)before choosing the serialisation path. If you change the coercion logic, test with objects and arrays in embedded positions.
No variable interpolation in object keys.
Expressions in JSON keys are detected and rejected at
compile time. Supporting this would require a new
TNode variant for interpolated keys and changes to
Template.renderNode's object branch.
No escape mechanism for literal ${. There's no
way to include ${ verbatim. Since ${ has no meaning
in standard JSON, this rarely matters. Workaround:
use a variable with a default, e.g.
"${dollar:-$}{rest".
Bare defaults that fail JSON.parse silently become
strings. ${name:-hello} defaults to "hello"
because JSON.parse("hello") throws and the engine
falls back to the raw text. This allows simple unquoted
string defaults without forcing ${name:-"hello"}.
The trade-off: a typo like ${cfg:-{broken} produces
a string instead of an error.
resolve() only follows own properties. Inherited
keys like toString or __proto__ are treated as
missing, not resolved from the prototype chain. This
prevents leaking prototype methods/objects through
templates. It also means resolve() cannot distinguish
a missing key from explicit undefined — { v: undefined } is treated the same as {}. Both choices
match POSIX shell :- semantics and are consistent
with JSON (where undefined is not a valid value).
No streaming or partial rendering. The engine builds the complete object tree synchronously. Not a problem for config-sized templates.
- CRITICAL: Always enumerate files explicitly in git commit commands
- NEVER use bare
git commitwithout file arguments - Check
git status --porcelainbefore every commit - NEVER apologise or explain why you did something wrong
- Fix issues immediately without commentary
- Stay focused on the task at hand