0.16.2 • Published 3 months ago

@topoconfig/extends v0.16.2

Weekly downloads
-
License
MIT
Repository
github
Last release
3 months ago

@topoconfig/extends

Populates extends references in configs

lcov npm (scoped)

Many tools provide extends feature for their configs, but it works a little differently in each place. For example, tsc applies deep merge to compilerOptions, while eslint concatenates elements within the overrides array, among others. As a result, developers have to implement these variances manually for each project, which can be both time-consuming and error-prone. Optimizing this routine process appears to be a practical solution:

const tsconfig = await populate('tsconfig.json', {
  compilerOptions: 'merge'
})

Moreover, now you can resolve a given config just as like tsc, but also do it properly, taking into account ts/issues/56436:

const tsconfig = await populate('tsconfig.json', {
  compilerOptions:               'merge',
  'compilerOptions.paths':       'merge',
  'compilerOptions.typeRoots':   'merge',
  'compilerOptions.typeRoots.*': 'rebase',
  'compilerOptions.outDir':      'rebase',
  'compilerOptions.paths.*.*':   'rebase'
})

Implementation notes

Key features

  • Recursive extras population (extends by default).
  • Multiple sources support
  • Configurable merging rules
    • prop/pattern-specific declarations
    • 5 built-in strategies: populate, ignore, merge, override, rebase
  • Sync and async modes
  • Immutability with prototype transits
  • Easy customization (opinionated)
  • Nodejs, Deno & Bun support

yargs/helpers is the closest one, but the differences are still noticeable:

import {applyExtends} from 'yargs/helpers'

const config = applyExtends({
  extends: './base.config.json',
  foo: 'foo',
  bar: {
    a: 'a'
  }
}, process.cwd())
  • No mjs/esm
  • No immutability
  • No multiple sources
  • No custom merge which is essential for some cases like arrays
  • No custom formats support
  • No async mode
  • No file urls support

Status

Working draft

Install

npm i @topoconfig/extends

Usage

populate

import { populate } from '@topoconfig/extends'

/** Imagine ../base.config.cjs contents
module.export = {
  bar: {
    b: 'b'
  }
}
*/

const config = {
  extends: '../base.config.cjs',
  foo: 'foo',
  bar: {
    a: 'a'
  }
}

const result = await populate(config, {
  bar: 'merge'
})

// returns
{
  foo: 'foo',
  bar: {
    a: 'a',
    b: 'b'
  } // ← bar holds both fields from the base and the current config
}

If the config param is a string it will be treated as a path and loaded.

const result = await populate('tsconfig.json', {
  compilerOptions: 'merge'
})

The sync version is also available. But keep in mind that .mjs (ESM) files cannot be processed in this mode.

import { populateSync } from '@topoconfig/extends'

const result = populateSync({
  extends: '../base.config.cjs',
  foo: 'foo',
  bar: {
    a: 'a'
  }
}, {
  bar: 'merge'
})

The config's extra property may hold objects, strings or string[]. The last two types will be processed via the internal load function. Extra key defaults to extends but can be remapped via merging rules.

const config = {
  extends: [
    '../base.config.cjs',
    {
      // Of cource, nested `extends` will be processed too
      extends: ['../../other.config.mjs']
    }
  ]
}

You can specify how to process config fields obtained from different sources. There are just five strategies: populate, ignore, merge and override. The last one is applied by default.

{
  foo:      'merge',
  bar:      'override',
  baz:      'merge',
  'baz.qu': 'merge',
  cwd:      'ignore',    // do not capture the `cwd` field from the source
  extends:  'populate',
  preset:   'populate',  // now both `preset` and `extends` fields will be populated
  'compilerOptions.typeRoots.*': 'rebase',  // to handle the value as a relative path and resolve it from the root / entry point cwd.
  'compilerOptions.outDir':      'rebase',
  'compilerOptions.paths.*.*':   'rebase'
}

To switch the default behavior use asterisk * as a key:

{
  '*': 'merge'
}

CLI

If you needed this, you definitely know why.

xtends <config.json> [<opts> [<output.json>]]

xtends tsconfig.json '{"compilerOtrions": "merge"}' > resolved.json
xtends prettier.json '{"overrides": "merge"}' resolved.json

Customization

Options define merging rules, but it's also suitable to override some internals:

OptionDescriptionDefault
cwdCurrent working directoryprocess.cwd()
resolveUtility to reveal resource paths#resolve
loadResource loader#load
parseParser function. Customize to handle non-std types like .yaml or .toml#parse
mergeMerge function. Smth like Object.assign or deepExtend should be ok.#extend
prepareHandler to preprocess data: initialize, validate, clone, etc.#prepare
vmapValue transformer.#vmap
rulesMerging rules{'*': 'override'}
const opts = {
  cwd: '/foo/bar',
  prepare: lodash.cloneDeep,
  rules: {
    '*': 'merge'
  }
}

yaml

No problem, js-yaml or yaml-js at your service:

import {load as parseYaml} from 'js-yaml'
import {populate} from '@topoconfig/extends'

const config = await populate('tsconfig.yaml', {
  parse({id, contents, ext}) {
    if (ext === '.yaml' || ext === '.yml') 
        return parseYaml(contents)
    if (ext === '.json') 
        return JSON.parse(contents)
    throw new Error(`Unsupported format: ${ext}`)
  }
})

cosmiconfig

Definitely yes! You can use it to find and load configs in various ways:

const raw = {
  a: 'a',
  extends: '../config.extra.in.yaml'
}
const config = await populate(raw, {
  load: async ({id, cwd}) => (await cosmiconfig('foo', {
    searchPlaces: [id]
  }).search(cwd))?.config
})

Or like this:

const {load} = cosmiconfig('foo')
const config = await populate(raw, {
  load: async ({id, cwd}) => (await load(path.resolve(cwd, id)))?.config
})

Or even like this:

import cosmiconfig from 'cosmiconfig'

const config = await populate('cosmiconfig:magic', {
  async load({cwd}) {
    return (await cosmiconfig('foobar').search(cwd))?.config
  }
})

Literally, there is no limitations:

import cosmiconfig from 'cosmiconfig'

const config = await populate('cosmiconfig:magic', {
  resolve({cwd}) {
    return cosmiconfigSync('foobar').search(cwd).filepath
  }
})

Internals

To simplify tweak ups some internals are exposed.

extend

Accepts objects and merges them according to the rules.

import { extend } from '@topoconfig/extends'

const sources = [
    {a: {b: {foo: 'foo'}}},
    {a: {b: {bar: 'bar'}, c: 'c'}},
    {a: {b: {baz: 'baz'}, c: 'C'}}
]
const rules = {
  a: 'merge',
  'a.b': 'merge'
}
const result = extend({sources, rules})
// gives
{
  a: {
    b: {
      foo: 'foo',
      bar: 'bar',
      baz: 'baz'
    },
    c: 'C'
  }
}

merge strategy for arrays means concatenation.

const sources = [
  {a: [1]},
  {a: ['a'], b: 'b'},
  {a: [{foo: 'bar'}], c: 'c'},
]
const rules = {
  a: 'merge',
}
const result = extend({sources, rules})
// returns
{
  a: [1, 'a', {foo: 'bar'}],
  b: 'b',
  c: 'c'
}

resolve

Utility to reveal resource paths.

import { resolve } from '@topoconfig/extends'

const local = resolve({id: '../foo.mjs', cwd: '/some/cwd/'}) // '/some/foo.mjs'
const external = resolve({id: 'foo-pkg', cwd: '/some/cwd/'}) // 'foo-pkg'

load

Resource loader in two flavors: sync and async. It uses import/require api for the standard formats (.json, .js, .cjs, .mjs), and fs.read for the rest.

import { load, loadSync } from '@topoconfig/extends'

const foo = await load({resolved: '/some/cwd/foo.mjs'})
const bar = loadSync({resolved: '/some/bar/bar.json'})

parse

Applies JSON.parse to any input.

export const parse = ({contents}: {id: string, contents: string, ext: string}) => JSON.parse(contents)

prepare

Defaults to internal clone function to ensure immutability.

import { prepare } from '@topoconfig/extends'
const copy = prepare({a: 'a', b() {}}) // {a: 'a', b() {}}

If necessary, you can replace it with a more advanced implementation, such as rfdc.

vmap

Value transformer. It's a good place to apply some custom logic like fields initialization. Default implementation is identity.

const vmap = ({value}) => value

Refs

License

MIT

0.14.0

3 months ago

0.15.0

3 months ago

0.14.1

3 months ago

0.16.0

3 months ago

0.16.1

3 months ago

0.16.2

3 months ago

0.13.0

3 months ago

0.12.0

3 months ago

0.12.1

3 months ago

0.11.0

3 months ago

0.10.1

3 months ago

0.10.2

3 months ago

0.10.0

3 months ago

0.9.0

3 months ago

0.8.0

3 months ago

0.7.5

3 months ago

0.7.4

3 months ago

0.7.2

3 months ago

0.7.1

3 months ago

0.7.3

3 months ago

0.7.0

3 months ago

0.6.2

3 months ago

0.6.1

3 months ago

0.6.0

3 months ago

0.5.0

3 months ago

0.5.1

3 months ago

0.4.0

3 months ago

0.3.0

3 months ago

0.2.0

3 months ago

0.1.8

3 months ago

0.1.9

3 months ago

0.1.7

4 months ago

0.1.6

4 months ago

0.1.5

5 months ago

0.1.4

5 months ago

0.1.3

5 months ago

0.1.2

5 months ago

0.1.1

5 months ago

0.1.0

5 months ago

0.0.0

5 months ago