@typed-macro/core
For basic concepts, see documentation for typed-macro.
Guide
Define your macro
It's pretty easy to define a macro, as long as you know the
basic principles of AST and basic operation APIs of Babel.
import { defineMacro } from '@typed-macro/core'
const echoMacro = defineMacro('echo')
.withSignature('(msg: Message, repeat?: number): void')
.withCustomType(`export type Message = string`)
.withHandler(({ path, args }, { template, types }) => {
if (args.length === 0) throw new Error('empty arguments is invalid')
const firstArg = args[0]
if (!firstArg.isStringLiteral())
throw new Error('please use literal string as message')
const msg = firstArg.node.value
let repeat = 5
if (args.length > 1) {
const secondArg = args[1]
if (!secondArg.isNumericLiteral())
throw new Error('please use literal number as repeat')
repeat = secondArg.node.value
}
path.replaceWith(
template.statement.ast(`console.log("${msg.repeat(repeat)}")`)
)
})
The above example is JUST to show how to define multiple signatures.
The above scenario is more suitable for using optional parameters
rather than overloading. See Do's and Don'ts.
As the above example shows, you should give a name, at least one signature,
maybe with a comment, and a handler function to a macro.
The custom type is optional.
Ideally, macros should be transparent to users;
that is, users can use macros like normal functions.
So it's essential to write types/comments correctly to provide a friendly development experience.
A macro handler receives three arguments: ctx
, babel
, and helper
.
ctx
- the transformation context, including the node path of
the call expression currently being processed, traversal states, argument nodes, and so on.babel
- a collection of Babel tools, containing
@babel/types,
@babel/traverse,
@babel/parser,
@babel/template.helper
- some functions that wrap Babel tools to make writing macro handlers easier.
In order to handle nested macros correctly,
and reduce the conflict on modifying the AST,
you can't use asynchronous macro handlers.
If the handler is a normal function like the above example shows,
the nested macros inside the current call expression will be expanded
automatically before calling the handler.
If the handler is a generator function, you can:
- yield node paths of import statements to collect macros from them,
note that macros must be collected before used, or you should wait for the next traversal
because the runtime collects imported macros automatically before every traversal
- yield node paths to actively expand macros inside them
- yield undefined to do nothing 😂
e.g.
const helloMacro = defineMacro(`hello`)
.withSignature(`(msg?: string): void`, `output hello message`)
.withHandler(function* (
{ path, args },
{ template },
{ prependImports, appendToBody }
) {
yield args
yield prependImports({
moduleName: '@other-macros',
exportName: 'macro',
localName: '__macro',
})
yield appendToBody(template.statement.ast(`__macro()`))
})
Though sometimes writing these lexical macros themselves is cumbersome enough,
please always keep the following in mind:
- don't forget to remove or replace the macro call expressions,
otherwise the plugin will process this call expression
again and again until reach max traversal times
because there is always a macro call remaining in the source.
- If you replace the current call expression with another call expression,
the next traversed node will be this new call expression.
Therefore, if you replace current macro call expression with another macro call,
please make sure this replacement is not recursive.
Organize your macros
It is not enough to have defined macros only. Macros should be organized,
at least, into some modules
so that users can import them.
The most basic organizational unit is Exportable
.
An Exportable
contains either Javascript code and corresponding types,
or macros and additional types.
type Exportable =
| { macros: Macro[]; types?: string }
| { code: string; types?: string }
Macro authors often prefer to use external helpers in the expanded code
in order to reduce the final size.
So Exportable
is designed to be able to contain Javascript code.
ModularizedExportable
is a collection of Exportable
s,
establishing the mapping relationship between module name and exportable.
type ModularizedExportable = { [moduleName: string]: Exportable }
Finally ModularizedExportable
s should be packaged into providers.
import { defineMacroProvider } from '@typed-macro/core'
defineMacroProvider({
id: 'echo',
exports: {
'@macros': {
macros: [helloMacro],
types: `export type SomeThing<T> = T`,
},
'@helper': {
code: `export const n = 1`,
},
},
})
defineMacroProvider
accepts a builder function if needed.
import { defineMacroProvider } from '@typed-macro/core'
defineMacroProvider((env) => {
return {
id: 'test',
exports: {
'@macros': {
macros: [],
types: env.dev ? '...' : '...',
},
},
hooks: {
onStart() {
env.watcher?.add(someFile)
},
},
options: {
parserPlugins: ['decimal'],
},
}
})
You can get properties of env
via parameter ctx
within macro handler,
so you don't need to define macros inside provider builder function.
defineMacro(`test`)
.withSignature(`(): void`)
.withHandler((ctx) => {
ctx.dev
ctx.ssr
ctx.host
})
There are two special object in env
: env.watcher
and env.modules
.
Suppose your macro needs to be re-expanded when an external file changes,
you can use them like below.
withHandler((ctx) => {
ctx.modules?.setTag(ctx.filepath, 'some_xyz')
})
{
onStart: () => {
env.watcher?.add(someFile)
env.watcher?.on('change', (path) => {
if (path === someFile) {
env.modules?.invalidateByTag(/^some/)
}
})
}
}
Note that env.watcher
and env.modules
may be undefined,
of which different runtime wrappers may have different strategies.
Test your macros
See @typed-macro/test-utils.