kaia-jsc
v0.1.1
Published
Safe compile-time macros and AST transforms for JS/TS bundlers. Easy like value-macros, general like AST rewrites, structured like a TokenStream.
Downloads
283
Maintainers
Readme
kaia-jsc
Kaia JS Compile-time — safe compile-time macros and AST transforms for JS/TS bundlers.
Easy like value-macros, general like AST rewrites, structured like a
TokenStream.
kaia-jsc is an independent project. It draws design inspiration from community tools such as value-macro plugins and AST transform plugins
Why
| Approach | Easy values | Arbitrary code (getters, IIFEs) | Safe emission |
| ------------------------------------------- | ----------- | ------------------------------- | -------------------------------- |
| Value-only macros (serialize return values) | ✅ | ❌ getters become {} | ⚠️ stringly risks if raw is added |
| Pure AST transforms | ❌ verbose | ✅ | ✅ |
| kaia-jsc | ✅ | ✅ via code.* | ✅ ESTree-only emission |
Design rules
- Macros return values or structured
Code— never free-form injectable source on the default path. code.quote/code.object/ … — TokenStream-style builders;- OXC stack —
oxc-parser·oxc-walker·esrap. - No
new Functionfor macro output — functions are re-parsed to ESTree, then printed. - Returned strings are string literals —
"foo()"stays a string, not executable code. - Optional AST transformers — same plugin, general rewrites after macro expansion.
Install
pnpm add -D kaia-jscSetup
// tsdown.config.ts / rolldown.config.ts
import { defineConfig } from 'tsdown'
import Jsc from 'kaia-jsc/rolldown'
export default defineConfig({
plugins: [Jsc()],
})Vite / Rollup / Webpack / Rspack / esbuild entries: kaia-jsc/vite, kaia-jsc/rollup, …
Macros (easy path)
// macros.ts
export function getRandom() {
return Math.random()
}
export const buildTime = Date.now()// main.ts
import { getRandom, buildTime } from './macros.ts' with { type: 'macro' }
getRandom() // → 0.42 (inlined)
buildTime // → 1710000000000Structured Code (safe code generation)
When you need syntax (getters, classes, statements), return code.* — not a hand-built object hoping the serializer keeps accessors.
import { code, type MacroContext } from 'kaia-jsc/api'
// $once is a *user* macro — core has no once() special case
export function $once<T>(this: MacroContext, create: () => T) {
const factory = code.fn(create)
return code.quote`(() => {
let _v;
return {
get value() { return _v ??= ${factory}(); }
};
})()`
}
export function $double(this: MacroContext, n: number) {
return code.quote`(${code.lit(n)} * 2)`
}import { $once } from './macros.ts' with { type: 'macro' }
const te = $once(() => new TextEncoder())
te.value.encode('Hello')code API (TokenStream-style)
| Helper | Purpose |
| --------------------------- | --------------------------------------- |
| code.lit(x) | primitive literal |
| code.ident(name) | identifier (validated) |
| code.value(x) | JSON-like data → expression |
| code.fn(fn) | re-parse a real function to AST |
| code.quote\…${hole}` | hygienic template; holes spliced as AST |
|code.object([...]) | object incl. **get/set/method** |
|code.call/code.member| call / member expressions |
|code.iife(body) | IIFE wrapper |
|code.fromNode(node)` | wrap an ESTree node |
AST transformers (general path)
import Jsc from 'kaia-jsc/rolldown'
import { RemoveWrapperFunction } from 'kaia-jsc/transformers'
export default {
plugins: [
Jsc({
transformer: [RemoveWrapperFunction(['defineConfig'])],
}),
],
}Custom transformer:
import type { Transformer } from 'kaia-jsc/api'
const StripDebug: Transformer = {
onNode: (node) =>
node.type === 'CallExpression' &&
node.callee?.type === 'Identifier' &&
node.callee.name === 'debug',
transform: () => false, // remove
}MacroContext
import type { MacroContext } from 'kaia-jsc/api'
import path from 'node:path'
export function $callsite(this: MacroContext) {
// oxc only gives start/end; kaia-jsc attaches loc before calling the macro
const { line, column } = this.ast.call.loc!.start
return `${path.basename(this.id)}:${line}:${column}`
}| Field | Meaning |
| --------------------------- | ----------------------------------------------------------------- |
| id | file path |
| source | full source |
| ast.call | call expression node |
| ast.parent | parent AST node (e.g. VariableDeclarator for const x = macro()) |
| ast.program | program node |
| replaceWith(code\|string) | explicit emit escape hatch |
| emitFile | emit assets |
Safety model
macro runs ──► returns value | Code
│
▼
Code → ESTree → esrap → source text
value → code.value/fn → same pipeline
❌ default path never treats a returned string as source code
❌ no raw string injection API on the happy path
✅ quote holes are placeholder identifiers replaced in the ASTArgument evaluation still requires isolated expression eval for non-literal args (same practical limit as other compile-time macro systems: no outer free variables). Output emission does not use that path.
License
MIT
