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>}Writing the creator function
Section titled “Writing the creator function”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.
transform
Section titled “transform”The transform function is where the main work happens. It takes the code, extracts messages and transforms the code, returning:
msgs: the messages extracted, andoutput: a function that receives the header (imports, and preparation code) and returns:code: the final edited codemap: a source map for the edits
How exactly it does is up to the author. But the conventional way to do it is:
- Parse the file into an AST
- Walk the AST to
- Collect the messages
- Make edits with
magic-string
- Do final edits and return code with map generated by
magic-string
A general guideline on how to write the transformer is discussed below.
loaderExts
Section titled “loaderExts”These are the extensions accepted for the loader files.
- If the loader is configured
customand 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.
defaultLoaderPath
Section titled “defaultLoaderPath”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 concatenationconst resolveLoaderPath = loaderPathResolver(import.meta.url, '../src/loaders', 'js')
resolveLoaderPath('foo') // /path/to/src/loaders/foo.jsAnd 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}'getRuntimeVars
Section titled “getRuntimeVars”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_',}Writing the transformer
Section titled “Writing the transformer”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.