Skip to content

Commit 3e69506

Browse files
authored
Merge pull request #1
feat: JSON template engine with shell-style variable substitution
2 parents db9cdf0 + b5c8ba0 commit 3e69506

13 files changed

Lines changed: 1671 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to AI coding assistants
4+
(Claude Code, GitHub Copilot, Cody, etc.) when working
5+
with the json-template repository.
6+
7+
## Project Overview
8+
9+
`@kagal/json-template` is a TypeScript template engine
10+
for JSON documents with shell-style `${var:-default}`
11+
variable substitution. It compiles a JSON template
12+
string once, then renders it to native JavaScript
13+
objects by resolving variables against a context.
14+
15+
## Structure
16+
17+
```text
18+
src/
19+
├── types.ts — TemplateVariable, CompileOptions
20+
├── errors.ts — TemplateParseError, UnresolvedVariableError
21+
├── json.ts — jsonNull, isNull, isNonStringPrimitive, isObject
22+
├── scanner.ts — scan(), ScannedExpr
23+
├── tree.ts — buildTree(), TNode, IPart, SENTINEL
24+
├── template.ts — Template class, compile(), listVariables()
25+
├── index.ts — barrel re-exports
26+
└── __tests__/
27+
├── index.test.ts — VERSION test
28+
├── scanner.test.ts — scanner and parse error tests
29+
└── template.test.ts — Template rendering tests
30+
```
31+
32+
## Common Commands
33+
34+
```bash
35+
pnpm build # Build with unbuild
36+
pnpm test # Run vitest
37+
pnpm lint # ESLint with auto-fix
38+
pnpm typecheck # tsc --noEmit
39+
pnpm precommit # build, lint, typecheck, test
40+
```
41+
42+
## Code Style Guidelines
43+
44+
Enforced by .editorconfig and @poupe/eslint-config:
45+
46+
- **Indentation**: 2 spaces
47+
- **Line Endings**: Unix (LF)
48+
- **Charset**: UTF-8
49+
- **Quotes**: Single quotes
50+
- **Semicolons**: Always
51+
- **Module System**: ES modules (`type: "module"`)
52+
- **Line Length**: Max 78 characters preferred
53+
- **Comments**: Use TSDoc format for documentation
54+
- **Naming**: camelCase for variables/functions,
55+
PascalCase for types/interfaces/classes
56+
- **Final Newline**: Always insert
57+
- **Trailing Whitespace**: Always trim
58+
59+
### JSON null handling
60+
61+
The `unicorn/no-null` rule is enforced project-wide.
62+
Use `jsonNull` and `isNull()` from `json.ts` where
63+
JSON null semantics are needed. A single
64+
`eslint-disable` exists at the `jsonNull` declaration.
65+
Use `undefined` where semantically viable.
66+
67+
## Development Practices
68+
69+
### Pre-commit (MANDATORY)
70+
71+
Before committing any changes, ALWAYS run:
72+
73+
1. `pnpm precommit` (if any source changed)
74+
2. Fix any issues found
75+
76+
### DO
77+
78+
- Write tests for all new functionality
79+
- Check existing code patterns before creating new ones
80+
- Follow strict TypeScript practices
81+
- Use `git -C <subpath>` instead of `cd` for git on
82+
subpaths, but not `-C .` at repo root
83+
84+
### DON'T
85+
86+
- Create files unless necessary — prefer editing
87+
existing ones
88+
- Add external dependencies without careful
89+
consideration
90+
- Ignore TypeScript errors or ESLint warnings
91+
- **NEVER use `git add .` or `git add -A`**
92+
- **NEVER commit without explicitly listing files**
93+
- **NEVER rely on the staging area — always list files
94+
explicitly**
95+
- **NEVER use `cd`** — it loses working directory
96+
context for all subsequent tool calls
97+
98+
## Git Workflow
99+
100+
### Commits
101+
102+
- Always use `-s` flag for sign-off
103+
- Write clear messages describing actual changes
104+
- No AI advertising in commit messages
105+
- Focus commit messages on the final result, not the
106+
iterations
107+
108+
### Direct Commits (MANDATORY)
109+
110+
ALWAYS list files explicitly in the commit command.
111+
Use `git add` only for new/untracked files, then pass
112+
all files (new and modified) to `git commit`.
113+
114+
```bash
115+
# Stage new files, then commit with explicit file list
116+
git add src/new-file.ts
117+
git commit -sF .tmp/commit-<slug>.txt -- src/new-file.ts src/changed.ts
118+
```
119+
120+
Temporary message files use a shared prefix with a
121+
meaningful slug:
122+
123+
- Commit messages: `.tmp/commit-<slug>.txt`
124+
- PR descriptions: `.tmp/pr-<slug>.md`
125+
126+
### Commit Message Guidelines
127+
128+
- First line: type(scope): brief description (50 chars)
129+
- Blank line
130+
- Body: what and why, not how (wrap at 72 chars)
131+
- Use bullet points for multiple changes
132+
- Reference issues/PRs when relevant
133+
134+
## Build and Test
135+
136+
- **Build**: unbuild (ESM + DTS, sourcemaps)
137+
- **Test**: Vitest with v8 coverage (90/90/85
138+
thresholds)
139+
- **Lint**: @poupe/eslint-config via `defineConfig()`
140+
- **Prepare**: `cross-test -s dist/index.mjs || unbuild --stub`
141+
142+
## Publishing
143+
144+
Published via GitHub Actions using npm's OIDC trusted
145+
publishing with `--provenance`. No tokens stored as
146+
secrets.
147+
148+
## Architecture
149+
150+
### Pipeline overview
151+
152+
Compilation is three phases. Rendering is a single
153+
tree walk.
154+
155+
```text
156+
template string
157+
158+
159+
scan() → ScannedExpr[]
160+
(phase 1: find expressions,
161+
track string context)
162+
163+
164+
sentinel replace → modified JSON str
165+
+ JSON.parse() → unknown
166+
(phase 2: substitute expressions
167+
with markers, parse once)
168+
169+
170+
buildTree() → TNode
171+
(phase 3: convert parsed JSON
172+
into template AST)
173+
174+
─── compile time ends here ───
175+
176+
Template.render() → unknown
177+
(per-call: walk tree, resolve
178+
vars, assemble object)
179+
```
180+
181+
### Key invariants
182+
183+
These are the things most likely to break during
184+
modification:
185+
186+
1. **Scanner string tracking determines everything
187+
downstream.** The `inString` flag when `${` is
188+
encountered determines whether the sentinel
189+
gets `B` (bare) or `E` (embedded) prefix. The
190+
scanner's backslash handling (`pos += 2` to skip
191+
`\"`) must exactly mirror JSON's escape rules.
192+
193+
2. **Expression index = sentinel index.** The
194+
`for (const [i, expr] of exprs.entries())` loop in
195+
`compile()` writes sentinels using `i` as the
196+
index. If expressions were ever reordered or
197+
filtered between `scan()` and sentinel replacement,
198+
every `TNode.idx` in the tree would point to the
199+
wrong `ScannedExpr`.
200+
201+
3. **`buildTree()` runs once at compile time;
202+
`Template.renderNode()` runs per-call.** Structural
203+
classification and default parsing belong at compile
204+
time. Variable resolution belongs in
205+
`Template.renderNode`. Moving compile-time work
206+
into render or vice versa is a correctness risk.
207+
208+
4. **Object keys are checked for sentinels in
209+
`buildTree`, not the scanner.** The scanner doesn't
210+
know about JSON structure. The key-rejection check
211+
happens after `JSON.parse`. If you add key support,
212+
you'd need a new `TNode` kind for interpolated keys
213+
and corresponding `Template.renderNode` logic.
214+
215+
5. **`SENTINEL_RE` is a module-level regex in
216+
`tree.ts` with the `g` flag.** Its `lastIndex` is
217+
reset before each use in `buildTree`. If you add
218+
another call site, you must also reset `lastIndex`.
219+
220+
6. **`compile()` rejects the sentinel character
221+
(U+E000) in input.** The PUA character is used as
222+
an in-band marker between `scan()` and
223+
`JSON.parse`. If it appeared in user input —
224+
either as a literal character or as a JSON-encoded
225+
`\uE000` escape — it would collide with the
226+
markers and cause miscompilation. Both forms are
227+
checked before `scan()`.
228+
229+
7. **Embedded non-primitives use `JSON.stringify`, not
230+
`String()`.** `String({})` produces
231+
`[object Object]`. The current code checks
232+
`isObject(value)` before choosing the serialisation
233+
path. If you change the coercion logic, test with
234+
objects and arrays in embedded positions.
235+
236+
## Known Limitations
237+
238+
### Worth fixing if the use case arises
239+
240+
**No variable interpolation in object keys.**
241+
Expressions in JSON keys are detected and rejected at
242+
compile time. Supporting this would require a new
243+
`TNode` variant for interpolated keys and changes to
244+
`Template.renderNode`'s object branch.
245+
246+
**No escape mechanism for literal `${`.** There's no
247+
way to include `${` verbatim. Since `${` has no meaning
248+
in standard JSON, this rarely matters. Workaround:
249+
use a variable with a default, e.g.
250+
`"${dollar:-$}{rest"`.
251+
252+
### By design
253+
254+
**Bare defaults that fail `JSON.parse` silently become
255+
strings.** `${name:-hello}` defaults to `"hello"`
256+
because `JSON.parse("hello")` throws and the engine
257+
falls back to the raw text. This allows simple unquoted
258+
string defaults without forcing `${name:-"hello"}`.
259+
The trade-off: a typo like `${cfg:-{broken}` produces
260+
a string instead of an error.
261+
262+
**`resolve()` only follows own properties.** Inherited
263+
keys like `toString` or `__proto__` are treated as
264+
missing, not resolved from the prototype chain. This
265+
prevents leaking prototype methods/objects through
266+
templates. It also means `resolve()` cannot distinguish
267+
a missing key from explicit `undefined` — `{ v:
268+
undefined }` is treated the same as `{}`. Both choices
269+
match POSIX shell `:-` semantics and are consistent
270+
with JSON (where `undefined` is not a valid value).
271+
272+
### Low priority
273+
274+
**No streaming or partial rendering.** The engine
275+
builds the complete object tree synchronously. Not a
276+
problem for config-sized templates.
277+
278+
## Claude Code Specific Instructions
279+
280+
- **CRITICAL: Always enumerate files explicitly in git
281+
commit commands**
282+
- **NEVER use bare `git commit` without file
283+
arguments**
284+
- **Check `git status --porcelain` before every
285+
commit**
286+
- NEVER apologise or explain why you did something
287+
wrong
288+
- Fix issues immediately without commentary
289+
- Stay focused on the task at hand

0 commit comments

Comments
 (0)