Ultra simple macro system for TypeScript
npm i @hazae41/saumon
- Ultra simple and minimalist
- Ultra fast thanks to Bun
- Won't interfere with your existing tools
- Can output arbitrary code (TypeScript types, JSX components, JSON data)
- Resistant to supply-chain attacks
data.macro.ts
(input)
const data = $run$(() => fetch("/api/data").then(r => r.json()))
data.ts
(output)
const data = { ... }
log.macro.ts
(input)
function $log$(x: string) {
return `console.log("${x}")`
}
$log$("hello world")
log.ts
(output)
console.log("hello world")
A macro is like a regular JS function, but the compiler will replace all its calls by the string value it returns
You need to install Bun
You can transform a single file
saumon build ./src/test.macro.ts
Or a whole directory
saumon build -r ./src
The compiler will only transform files with .macro.*
extensions
All macros must be named with one dollar before and one dollar after their name
function $log$(x: string) {
return `console.log("${x}")`
}
function $random$(): number {
return `${Math.random()}` as any
}
const x = $random$() * 100
function $log$(x: string) {
return `console.log("${x}")`
}
$log$("hello world")
log.ts
export function $log$(x: string) {
return `console.log("${x}")`
}
main.macro.ts
import { $log$ } from "./log.ts"
$log$("hello from the main file")
main.macro.ts
import { $log$ } from "some-lib"
$log$("hello from the main file")
log.macro.ts
export function $log$(x: string) {
return `console.log("${x}")`
}
$log$("hello from the log file")
main.macro.ts
import { $log$ } from "./log.ts"
$log$("hello from the main file")
All comment blocks must start with /*
or /**
in the first line and @macro
in the second line, and end with */
This instruction will uncomment the given code and reparse the file
enabled.macro.ts
const enabled = true
/**
* @macro uncomment
* if (!enabled) {
* return exit(0)
* }
*/
enabled.ts
const enabled = true
if (!enabled) {
return exit(0)
}
You can use it to run macros in places where you are not supposed to call functions
something.macro.ts
function $log$(x: string) {
return `log() {
console.log("${x}")
}`
}
class Something {
/**
* @macro uncomment
* $log$("hello world")
*/
}
something.ts
class Something {
log() {
console.log("hello world")
}
}
This instruction will delete all the lines next to it until \n\n
(or end of file)
/**
* @macro delete-next-lines
*/
console.log("i will be deleted")
console.log("i will be deleted too")
You can use it to clean imports that are only used in macros
/**
* @macro delete-next-lines
*/
import { $log$ } from "./macros/log.ts"
import { $hello$ } from "./macros/hello.ts"
$log$($hello$())
console.log("hello world")
parse.macro.ts
function $parse$<T>(x: string): T {
return JSON.stringify(JSON.parse(x)) as any
}
export const data = $parse$<{ id: number }>(`{"id":123}`)
parse.ts
export const data = {"id":123}
Just return a Promise and the compiler will wait for it
fetch.macro.ts
function $fetch$<T>(url: string): T {
return (async () => {
const response = await fetch(url)
const object = await response.json()
return JSON.stringify(object)
})() as any
}
export const data = $fetch$<{ id: number }>("https://dummyjson.com/products/1")
fetch.ts
export const data = { "id": 1 }
function $f$(): Promise<number> {
return `Promise.resolve(123)` as any
}
await $f$()
You can run dynamic code thanks to callbacks
function $run$<T>(callback: () => T): Awaited<T> {
return (async () => {
return JSON.stringify(await callback())
})() as any
}
const data = $run$(() => fetch("/api/data").then(r => r.json()))
For your convenience, Saumon exports the $run$
macro so you can just import it
import { $run$ } from "@hazae41/saumon"
Those constraints only apply when calling in-file macros, not when calling imported macros
When calling an in-file macro, it MUST be defined as a regular function
❌
export const $log$ = function () {
return `console.log("hey")`
}
❌
export const $log$ = () => {
return `console.log("hey")`
}
✅
export function $log$() {
return `console.log("hey")`
}
When calling an in-file macro, it SHOULD be defined at top-level to avoid name conflicts
This is because the parser can't do code analysis to find which macro you want to use
❌
function f() {
function $log$() {
return `console.log("hey")`
}
$log$()
}
function g() {
function $log$() {
return `console.log("hey")`
}
$log$()
}
✅
function $log$() {
return `console.log("hey")`
}
function f() {
$log$()
}
function g() {
$log$()
}
When calling a macro in-file, variables MUST be primitive, global, or imported
This is because macro definitions and calls are ran isolated from their surrounding code
They can still access global variables and imports
❌ Calling an in-file macro that uses local variables
const debugging = true
function $debug$(x: string) {
if (!debugging)
return
return `console.debug("${x}")`
}
$debug$("hey")
✅ Calling an in-file macro that uses global or imported variables
import { debugging } from "./debugging.ts"
function $debug$(x: string) {
if (!debugging)
return
return `console.debug("${x}")`
}
$debug$("hey")
✅ Calling an imported macro
debug.ts
const debugging = true
export function $debug$(x: string) {
if (!debugging)
return
return `console.debug("${x}")`
}
main.macro.ts
import { $debug$ } from "./debug.ts"
$debug$("hey")
Similarly, passed parameters MUST also be primitive, global, or imported (and their type too)
❌ Calling an in-file macro whose parameters are local
class X {}
function $log$(i: number, x: X) {
return `console.log(${i}, "${JSON.stringify(x)}")`
}
$log$(123, new X())
✅ Calling an in-file macro whose parameters are imported
import type { X } from "./x.ts"
import { x } from "./x.ts"
function $log$(i: number, x: X) {
return `console.log(${i}, "${JSON.stringify(x)}")`
}
$log$(123, x)
✅ Calling an imported macro
log.ts
export class X {}
export function $log$(i: number, x: X) {
return `console.log(${i}, "${JSON.stringify(x)}")`
}
$log$(123, new X())
main.macro.ts
import { $log$, X } from "./log.ts"
$log$(123, new X())
Macro files are transformed ahead-of-time by the developer.
This means the output code is fully available in the Git, and won't interfere with code analysis tools.
The macro code SHOULD only be transformed when needed (e.g. when modified, when the fetched data is stale), and its output SHOULD be verified by the developer.
The developer SHOULD also provide the input macro file in the Git, so its output can be reproducible by people and automated tools.