Skip to content

Adapter API

An adapter handles a file dealing with the specific syntax. It has to:

  • Extract the strings (messages and URLs) from the file
  • Transform the file into a version that uses the catalogs
  • Provide configuration parameters localesDir
  • Define behaviors like when to use the reactive runtime
  • Provide available loaders

An adapter uses some configuration values for itself internally and some, it has to pass to the main handler. But it’s important to provide a uniform interface to the user (for use in wuchale.config.js. Therefore, the adapter should be a function that takes a config object and creates an adapter object, to be used like this in the config:

adapter({
confKey: value,
...
})

This means some part of the config object should be “passed through”. The exact type of this part is:

export type AdapterPassThruOpts = {
files: GlobConf
localesDir: string
outDir?: string
granularLoad: boolean
bundleLoad: boolean
url?: {
patterns?: string[]
localize?: boolean | URLLocalizer
}
generateLoadID: (filename: string) => string
runtime: Partial<RuntimeConf>
}

Now to write the adapter creator we have to define the available loader keys first:

type LoadersAvailable = 'default' | 'someLoader' | 'otherLoader'

Now the full adapter config object should be of this type:

export type AdapterArgs<LoadersAvailable> = AdapterPassThruOpts & {
loader: LoaderChoice<LoadersAvailable>
heuristic: HeuristicFunc
patterns: CodePattern[]
}

All of these properties are documented in the common adapter options.

This is the type of the basic common argument object. AdapterArgs can be imported from wuchale.

And the full adapter object, returned from the creation function should be of this type:

export type Adapter = AdapterPassThruOpts & {
transform: TransformFunc | TransformFuncAsync
/** possible filename extensions for loader. E.g. `.js` */
loaderExts: string[]
/** default loaders to copy, `null` means custom */
defaultLoaderPath: LoaderPath | string | null
/** names to import from loaders, should avoid collision with code variables */
getRuntimeVars?: Partial<CatalogExpr>
}

As you might have noticed, only a few of the properties of AdapterPassThruOpts are optional. So we have to provide defaults at the adapter level, and make them optional, later merging whatever is provided with the defaults. This can be done with Partial and the deepMergeObjects from wuchale

The general form of an adapter creator function can be like this:

import { deepMergeObjects } from 'wuchale' // a utility to merge config objects with defaults.
type FooArgs = AdapterArgs<LoadersAvailable>
const defaultArgs: FooArgs = {
// ...
}
export const adapter = (args: Partial<FooArgs> = defaultArgs): Adapter => {
let {
heuristic,
patterns,
runtime,
loader,
...rest
} = deepMergeObjects(args, defaultArgs)
// prep logic...
return {
transform: ({ content, filename, index, expr, matchUrl }) => {
// transform logic...
return {
output: {code: '...', map: {/*...*/} },
msgs: [/*...*/],
}
},
loaderExts: ['.js', '.ts'],
defaultLoaderPath: '...',
runtime,
getRuntimeVars: {
plain: '_w_load_',
reactive: '_w_load_rx_',
},
...rest,
}
}

Each relevant property will be discussed below.

The transform function is where the main work happens. It takes the code, extracts messages and transforms the code, returning:

  • msgs: the messages extracted, and
  • output: a function that receives the header (imports, and preparation code) and returns:
    • code: the final edited code
    • map: a source map for the edits

How exactly it does is up to the author. But the conventional way to do it is:

  1. Parse the file into an AST
  2. Walk the AST to
  3. Do final edits and return code with map generated by magic-string

A general guideline on how to write the transformer is discussed below.

These are the extensions accepted for the loader files.

  • If the loader is configured custom and the file with any of these extensions exists, it will be used.
  • Otherwise, the loaders will be written with the first extension from this array.

This is the absolute path where the loader will be copied from. And the value of the loader can be used to decide which, in case of multiple. As the absolute location of the file is unknown, you can use import.meta.url of the file where this code is written and compute it relative to that. To assist with this, a simple helper is provided and it can be used like so:

import { loaderPathResolver } from 'wuchale/adapter-utils'
// the second arg is the directory, relative to the built file (when using typescript)
// the third argument is the extension of the files.
// it just does string concatenation
const resolveLoaderPath = loaderPathResolver(import.meta.url, '../src/loaders', 'js')
resolveLoaderPath('foo') // /path/to/src/loaders/foo.js

And the actual loader files have to be provided in the indicated directory.

Before writing loaders, you should read loading catalogs. And when writing loaders, you can use the following as placeholders and they will be replaced by their actual value.

  • ${PROXY}: The async proxy path
  • ${PROXY_SYNC}: The sync proxy path
  • ${DATA}: The data file path
  • ${KEY}: The adapter key as given in the config

For example:

export const key = '${KEY}'

If there is a need to import the function from the loader files in a specific name (as is the case for React with the hooks name), you can provide it here, being careful not to collide with user code:

getRuntimeVars: {
plain: 'someFunc_no_collide',
reactive: 'useWuchale_no_collide_',
}

While you can write the transformer any way you like, the conventional way to write the transformer is using a class. You can extend the vanilla transformer which handles JS/TS code and build up on that (that’s what the JSX and Svelte adapters do) or you can build it from scratch. Then a minimal recommended way to do it is:

import MagicString from "magic-string"
import {parseFoo, type AST} from "foo/compiler" // the specific compiler/parser for the file
class FooTransformer {
content: string
mstr: MagicString
constructor(content: string) {
this.content = content
this.mstr = new MagicString(content)
}
visitElement = (node: AST.Element): Message[] => {
const msgs: Message[] = []
for (const att of node.attributes) {
msgs.push(...this.visit(att))
}
for (const ch of node.children) {
msgs.push(...this.visit(ch))
}
return msgs
}
visitAttribute = (node: AST.Attribute): Message[] => {
const msgs: Message[] = []
// logic to transform and get messages
return msgs
}
visit = (node: AST.Node): Message[] => {
return this[`visit${node.type}`]?.(node) ?? []
}
transform() {
const ast = parseFoo(this.content)
return {
msgs: this.visit(ast),
output: header => {
this.mstr.prependRight(0, header)
return {
code: this.mstr.toString(),
map: this.mstr.generateMap(),
}
}
}
}
}

This way, there is no need to nest node type checking statements which are incomplete and error prone anyway.

If you choose to subclass the vanilla transformer, you automatically get Estree handling and some common things like the mstr, and the visit method being defined already.

Now the tricky part is nested node and expression handling. For that, the common logic has been extracted into a reusable class MixedVisitor and is already shared between the JSX and Svelte adapters.

Having said all of this, a good example for the actual implementation is the JSX adapter, as it is relatively smaller but covers all relevant points.