Skip to content
Permalink
Browse files

feat: Configuration file API for CSS Blocks.

Introduces a new package `@css-blocks/config` which presents a single
API for all packages that need to load css-blocks configuration from a
single, shared location. This will allow the CLI, VS Code, and build
integrations to share configuration.
  • Loading branch information
chriseppstein committed Nov 11, 2019
1 parent 3912c56 commit 736f46066445a287fabb7aa75646e22125d8b8c4
@@ -45,6 +45,9 @@
},
{
"path": "packages/@css-blocks/language-server"
},
{
"path": "packages/@css-blocks/config"
}
],
"settings": {
@@ -0,0 +1,46 @@
# CSS Blocks Configuration

Loads configuration for css-blocks from standardized locations so that build integrations, text editors, and the cli can all interoperate with the same configuration.

## Installation

```
yarn add @css-blocks/config
```

## Usage

```ts
import { Options as CSSBlocksOptions } from "@css-blocks/core";
import * as config from '@css-blocks/config';
// finds configuration starting in the current working directory.
let opts: CSSBlocksOptions | null = config.search();
// finds configuration starting in the specified directory;
opts = config.search(__dirname);
// loads a specific configuration file:
opts = config.load("config/css-blocks.js");
```

## Configuration Options

The values specified in the configuration files are expected to be legal options
for the [CSS Blocks configuration](../core/src/configuration/types.ts).
However, there are a few exceptions:

* `preprocesors` - This can be set to a file location of a javascript file that
exports one or more preprocessors. The properties exported should correspond to the
[supported syntaxes](../core/src/BlockParser/preprocessing.ts).
* `importer` - This can be set to a file location of a javascript file that
exports an object with keys of `importer` and (optionally) `data`. If data
is returned, it takes precedence over a configuration value for
`importerData` in the current configuration file.
* `extends` - If provided, this configuration file located at the provided path
is loaded and this configuration is deeply merged into it. Note: the values
for `importer` and `importerData` are not deeply merged.
* `rootDir` - If this configuration property is not set explicitly, the directory of the
configuration file is used.

Note: Any path to another file or directory is interpreted as being relative
to the directory of the file containing the path.
@@ -0,0 +1,56 @@
{
"name": "@css-blocks/config",
"version": "0.24.0",
"description": "Standardized access to css-blocks configuration files.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"bin",
"dist",
"src",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"test": "yarn run test:runner",
"test:runner": "mocha --opts test/mocha.opts dist/test",
"compile": "tsc --build",
"pretest": "yarn run compile",
"posttest": "yarn run lint",
"prepublish": "rm -rf dist && yarn run compile && yarn run lintall",
"lint": "tslint -t msbuild --project . -c tslint.cli.json",
"lintall": "tslint -t msbuild --project . -c tslint.release.json",
"lintfix": "tslint -t msbuild --project . -c tslint.cli.json --fix",
"coverage": "istanbul cover -i dist/src/**/*.js --dir ./build/coverage node_modules/mocha/bin/_mocha -- dist/test --opts test/mocha.opts",
"remap": "remap-istanbul -i build/coverage/coverage.json -o coverage -t html",
"watch": "watch 'yarn run test' src test --wait=1"
},
"keywords": [
"css-blocks"
],
"author": "Chris Eppstein <chris@eppsteins.net>",
"license": "BSD-2-Clause",
"bugs": {
"url": "https://github.com/linkedin/css-blocks/issues"
},
"engines": {
"node": "8.* || 10.* || >= 12.*"
},
"repository": "https://github.com/linkedin/css-blocks",
"homepage": "https://github.com/linkedin/css-blocks/tree/master/packages/@css-blocks/config#readme",
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@css-blocks/code-style": "^0.24.0",
"@types/lodash.merge": "^4.6.6",
"typescript": "~3.5",
"watch": "^1.0.2"
},
"dependencies": {
"@css-blocks/core": "^0.24.0",
"cosmiconfig": "^6.0.0",
"debug": "^4.1.1",
"lodash.merge": "^4.6.2"
}
}
@@ -0,0 +1,130 @@
import {
Options,
} from "@css-blocks/core";
import { Transform, cosmiconfig } from "cosmiconfig";
import * as debugGenerator from "debug";
import merge = require("lodash.merge");
import { dirname, resolve } from "path";

const debug = debugGenerator("css-blocks:config");

type UnknownObject = {[k: string]: unknown};

/**
* Resolves paths against the file's directory and recursively processes
* the 'extends' option.
*
* If preprocessors is a string, attempts to load a javascript file from that location.
*/
const transform: Transform = async (result) => {
if (!result) return null;
debug(`Processing raw configuration loaded from ${result.filepath}`);
let dir = dirname(result.filepath);
let config: UnknownObject = result.config;

if (typeof config.rootDir === "string") {
config.rootDir = resolve(dir, config.rootDir);
}

// if it's a string, load a file that exports one or more preprocessors.
if (typeof config.preprocessors === "string") {
let file = resolve(dir, config.preprocessors);
debug(`Loading preprocessors from ${file}`);
config.preprocessors = await import(file) as UnknownObject;
}

// if it's a string, load a file that exports an importer and optionally some data.
if (typeof config.importer === "string") {
let file = resolve(dir, config.importer);
debug(`Loading importer from ${file}`);
let {importer, data} = await import(file) as UnknownObject;
config.importer = importer;
if (data) {
config.importerData = data;
}
}

// If the config has an extends property, base this configuration on the
// configuration loaded at that path relative to the directory of the current
// configuration file.
if (typeof config.extends === "string") {
let baseConfigFile = resolve(dir, config.extends);
delete config.extends;
debug(`Extending configuration found at: ${baseConfigFile}`);
let baseConfig = await _load(baseConfigFile, transform);
// we don't want to merge or copy the importer object or the importer data object.
let importer = config.importer || baseConfig.importer;
let importerData = config.importerData || baseConfig.importerData;
config = merge({}, baseConfig, config);
if (importer) {
config.importer = importer;
}
if (importerData) {
config.importerData = importerData;
}
}
result.config = config;
return result;
};

/**
* This transform only runs on the final configuration file. It does not run on
* any configuration file that is being extended.
*/
const transformFinal: Transform = async (result) => {
if (!result) return null;
debug(`Using configuration file found at ${result.filepath}`);
result = await transform(result);
if (!result) return null;
if (!result.config.rootDir) {
let dir = dirname(result.filepath);
debug(`No rootDir specified. Defaulting to: ${dir}`);
result.config.rootDir = dir;
}
return result;
};

/**
* Starting in the directory provided, work up the directory hierarchy looking
* for css-blocks configuration files.
*
* This will look for a "css-blocks" key in package.json, then look for a file
* named "css-blocks.config.json", then look for a file named "css-blocks.config.js".
*
* @param [searchDirectory] (optional) The directory to start looking in.
* Defaults to the current working directory.
*/
export async function search(searchDirectory?: string): Promise<Options | null> {
let loader = cosmiconfig("css-blocks", {
transform: transformFinal,
searchPlaces: [
"package.json",
"css-blocks.config.json",
"css-blocks.config.js",
],
});
let result = await loader.search(searchDirectory);
return result && result.config as Options;
}

/**
* Load configuration from a known path to the specific file.
* Supports .js and .json files. If it's a file named "package.json",
* it will load the configuration from the `"css-blocks"` property
* of the package.json file.
*
* @param configPath path to the configuration file.
* @throws If the file does not exist or is not readable.
* @returns The options found
*/
export async function load(configPath: string): Promise<Options> {
return _load(configPath, transformFinal);
}

async function _load(configPath: string, transform: Transform): Promise<Options> {
let loader = cosmiconfig("css-blocks", {
transform,
});
let result = await loader.load(configPath);
return result!.config as Options;
}
@@ -0,0 +1,133 @@
import { OutputMode } from "@css-blocks/core";
import assert = require("assert");
import path = require("path");
import { chdir, cwd } from "process";

import { load, search } from "../src";

function fixture(...relativePathSegments: Array<string>): string {
return path.resolve(__dirname, "..", "..", "test", "fixtures", ...relativePathSegments);
}

const WORKING_DIR = cwd();

describe("validate", () => {
afterEach(() => {
chdir(WORKING_DIR);
});
it("can load configuration from the package.json in the current working directory", async () => {
chdir(fixture("from-pkg-json"));
let options = await search();
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM);
}
});
it("can load configuration from the package.json in a specified directory", async () => {
let options = await search(fixture("from-pkg-json"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM);
}
});
it("will load configuration from the first package.json in ancestor directory", async () => {
let options = await search(fixture("from-pkg-json", "subdir", "another-subdir"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM);
}
});
it("loads preprocessors if a file is specified.", async () => {
let options = await search(fixture("from-pkg-json"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
}
});
it("can load configuration from css-blocks.config.json in the current working directory", async () => {
chdir(fixture("from-json-file"));
let options = await search();
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM_UNIQUE);
}
});
it("can load configuration from css-blocks.config.json in a specified directory", async () => {
let options = await search(fixture("from-json-file"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM_UNIQUE);
}
});
it("will load configuration from css-blocks.config.json in an ancestor directory", async () => {
let options = await search(fixture("from-json-file", "subdir", "another-subdir"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM_UNIQUE);
assert.equal(options.rootDir, fixture("from-json-file"));
}
});
it("can extend configuration from another location", async () => {
let options = await search(fixture("from-json-file"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
assert.equal(options.preprocessors && typeof options.preprocessors.less, "function");
}
});
it("can specify a file containing an importer", async () => {
let options = await search(fixture("from-json-file"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.importer && typeof options.importer, "object");
assert.equal(options.importerData && Array.isArray(options.importerData), true);
}
});
it("can load configuration from css-blocks.config.js in the current working directory", async () => {
chdir(fixture("from-js-file"));
let options = await search();
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM);
assert.equal(options.maxConcurrentCompiles, 8);
assert.equal(options.rootDir, fixture("from-js-file", "blocks"));
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
assert.equal(options.preprocessors && typeof options.preprocessors.styl, "function");
}
});
it("rootDir is not overridden if specified explicitly", async () => {
chdir(fixture("another-js-file"));
let options = await search();
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM);
assert.equal(options.maxConcurrentCompiles, 8);
assert.equal(options.rootDir, fixture("from-js-file", "blocks"));
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
assert.equal(options.preprocessors && typeof options.preprocessors.styl, "function");
}
});
it("can load a config file from an explicit path.", async () => {
let options = await load(fixture("another-js-file", "css-blocks.config.js"));
if (options === null) {
assert.fail("configuration wasn't found.");
} else {
assert.equal(options.outputMode, OutputMode.BEM);
assert.equal(options.maxConcurrentCompiles, 8);
assert.equal(options.rootDir, fixture("from-js-file", "blocks"));
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
assert.equal(options.preprocessors && typeof options.preprocessors.styl, "function");
}
});
});
@@ -0,0 +1,3 @@
module.exports = {
extends: "../from-js-file/css-blocks.config.js"
}
@@ -0,0 +1,8 @@
module.exports = {
extends: "../from-pkg-json/package.json",
maxConcurrentCompiles: 8,
rootDir: "blocks",
preprocessors: {
styl: (_fullPath, content, _configuration, _sourceMap) => {return {content}; },
}
};

0 comments on commit 736f460

Please sign in to comment.
You can’t perform that action at this time.