Skip to content

Commit 736f460

Browse files
committed
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.
1 parent 3912c56 commit 736f460

File tree

21 files changed

+506
-1
lines changed

21 files changed

+506
-1
lines changed

css-blocks.code-workspace

+3
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
},
4646
{
4747
"path": "packages/@css-blocks/language-server"
48+
},
49+
{
50+
"path": "packages/@css-blocks/config"
4851
}
4952
],
5053
"settings": {

packages/@css-blocks/config/README.md

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# CSS Blocks Configuration
2+
3+
Loads configuration for css-blocks from standardized locations so that build integrations, text editors, and the cli can all interoperate with the same configuration.
4+
5+
## Installation
6+
7+
```
8+
yarn add @css-blocks/config
9+
```
10+
11+
## Usage
12+
13+
```ts
14+
import { Options as CSSBlocksOptions } from "@css-blocks/core";
15+
import * as config from '@css-blocks/config';
16+
// finds configuration starting in the current working directory.
17+
let opts: CSSBlocksOptions | null = config.search();
18+
19+
// finds configuration starting in the specified directory;
20+
opts = config.search(__dirname);
21+
22+
// loads a specific configuration file:
23+
opts = config.load("config/css-blocks.js");
24+
```
25+
26+
## Configuration Options
27+
28+
The values specified in the configuration files are expected to be legal options
29+
for the [CSS Blocks configuration](../core/src/configuration/types.ts).
30+
However, there are a few exceptions:
31+
32+
* `preprocesors` - This can be set to a file location of a javascript file that
33+
exports one or more preprocessors. The properties exported should correspond to the
34+
[supported syntaxes](../core/src/BlockParser/preprocessing.ts).
35+
* `importer` - This can be set to a file location of a javascript file that
36+
exports an object with keys of `importer` and (optionally) `data`. If data
37+
is returned, it takes precedence over a configuration value for
38+
`importerData` in the current configuration file.
39+
* `extends` - If provided, this configuration file located at the provided path
40+
is loaded and this configuration is deeply merged into it. Note: the values
41+
for `importer` and `importerData` are not deeply merged.
42+
* `rootDir` - If this configuration property is not set explicitly, the directory of the
43+
configuration file is used.
44+
45+
Note: Any path to another file or directory is interpreted as being relative
46+
to the directory of the file containing the path.
+56
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{
2+
"name": "@css-blocks/config",
3+
"version": "0.24.0",
4+
"description": "Standardized access to css-blocks configuration files.",
5+
"main": "dist/index.js",
6+
"types": "dist/index.d.ts",
7+
"files": [
8+
"bin",
9+
"dist",
10+
"src",
11+
"README.md",
12+
"CHANGELOG.md"
13+
],
14+
"scripts": {
15+
"test": "yarn run test:runner",
16+
"test:runner": "mocha --opts test/mocha.opts dist/test",
17+
"compile": "tsc --build",
18+
"pretest": "yarn run compile",
19+
"posttest": "yarn run lint",
20+
"prepublish": "rm -rf dist && yarn run compile && yarn run lintall",
21+
"lint": "tslint -t msbuild --project . -c tslint.cli.json",
22+
"lintall": "tslint -t msbuild --project . -c tslint.release.json",
23+
"lintfix": "tslint -t msbuild --project . -c tslint.cli.json --fix",
24+
"coverage": "istanbul cover -i dist/src/**/*.js --dir ./build/coverage node_modules/mocha/bin/_mocha -- dist/test --opts test/mocha.opts",
25+
"remap": "remap-istanbul -i build/coverage/coverage.json -o coverage -t html",
26+
"watch": "watch 'yarn run test' src test --wait=1"
27+
},
28+
"keywords": [
29+
"css-blocks"
30+
],
31+
"author": "Chris Eppstein <[email protected]>",
32+
"license": "BSD-2-Clause",
33+
"bugs": {
34+
"url": "https://github.com/linkedin/css-blocks/issues"
35+
},
36+
"engines": {
37+
"node": "8.* || 10.* || >= 12.*"
38+
},
39+
"repository": "https://github.com/linkedin/css-blocks",
40+
"homepage": "https://github.com/linkedin/css-blocks/tree/master/packages/@css-blocks/config#readme",
41+
"publishConfig": {
42+
"access": "public"
43+
},
44+
"devDependencies": {
45+
"@css-blocks/code-style": "^0.24.0",
46+
"@types/lodash.merge": "^4.6.6",
47+
"typescript": "~3.5",
48+
"watch": "^1.0.2"
49+
},
50+
"dependencies": {
51+
"@css-blocks/core": "^0.24.0",
52+
"cosmiconfig": "^6.0.0",
53+
"debug": "^4.1.1",
54+
"lodash.merge": "^4.6.2"
55+
}
56+
}
+130
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import {
2+
Options,
3+
} from "@css-blocks/core";
4+
import { Transform, cosmiconfig } from "cosmiconfig";
5+
import * as debugGenerator from "debug";
6+
import merge = require("lodash.merge");
7+
import { dirname, resolve } from "path";
8+
9+
const debug = debugGenerator("css-blocks:config");
10+
11+
type UnknownObject = {[k: string]: unknown};
12+
13+
/**
14+
* Resolves paths against the file's directory and recursively processes
15+
* the 'extends' option.
16+
*
17+
* If preprocessors is a string, attempts to load a javascript file from that location.
18+
*/
19+
const transform: Transform = async (result) => {
20+
if (!result) return null;
21+
debug(`Processing raw configuration loaded from ${result.filepath}`);
22+
let dir = dirname(result.filepath);
23+
let config: UnknownObject = result.config;
24+
25+
if (typeof config.rootDir === "string") {
26+
config.rootDir = resolve(dir, config.rootDir);
27+
}
28+
29+
// if it's a string, load a file that exports one or more preprocessors.
30+
if (typeof config.preprocessors === "string") {
31+
let file = resolve(dir, config.preprocessors);
32+
debug(`Loading preprocessors from ${file}`);
33+
config.preprocessors = await import(file) as UnknownObject;
34+
}
35+
36+
// if it's a string, load a file that exports an importer and optionally some data.
37+
if (typeof config.importer === "string") {
38+
let file = resolve(dir, config.importer);
39+
debug(`Loading importer from ${file}`);
40+
let {importer, data} = await import(file) as UnknownObject;
41+
config.importer = importer;
42+
if (data) {
43+
config.importerData = data;
44+
}
45+
}
46+
47+
// If the config has an extends property, base this configuration on the
48+
// configuration loaded at that path relative to the directory of the current
49+
// configuration file.
50+
if (typeof config.extends === "string") {
51+
let baseConfigFile = resolve(dir, config.extends);
52+
delete config.extends;
53+
debug(`Extending configuration found at: ${baseConfigFile}`);
54+
let baseConfig = await _load(baseConfigFile, transform);
55+
// we don't want to merge or copy the importer object or the importer data object.
56+
let importer = config.importer || baseConfig.importer;
57+
let importerData = config.importerData || baseConfig.importerData;
58+
config = merge({}, baseConfig, config);
59+
if (importer) {
60+
config.importer = importer;
61+
}
62+
if (importerData) {
63+
config.importerData = importerData;
64+
}
65+
}
66+
result.config = config;
67+
return result;
68+
};
69+
70+
/**
71+
* This transform only runs on the final configuration file. It does not run on
72+
* any configuration file that is being extended.
73+
*/
74+
const transformFinal: Transform = async (result) => {
75+
if (!result) return null;
76+
debug(`Using configuration file found at ${result.filepath}`);
77+
result = await transform(result);
78+
if (!result) return null;
79+
if (!result.config.rootDir) {
80+
let dir = dirname(result.filepath);
81+
debug(`No rootDir specified. Defaulting to: ${dir}`);
82+
result.config.rootDir = dir;
83+
}
84+
return result;
85+
};
86+
87+
/**
88+
* Starting in the directory provided, work up the directory hierarchy looking
89+
* for css-blocks configuration files.
90+
*
91+
* This will look for a "css-blocks" key in package.json, then look for a file
92+
* named "css-blocks.config.json", then look for a file named "css-blocks.config.js".
93+
*
94+
* @param [searchDirectory] (optional) The directory to start looking in.
95+
* Defaults to the current working directory.
96+
*/
97+
export async function search(searchDirectory?: string): Promise<Options | null> {
98+
let loader = cosmiconfig("css-blocks", {
99+
transform: transformFinal,
100+
searchPlaces: [
101+
"package.json",
102+
"css-blocks.config.json",
103+
"css-blocks.config.js",
104+
],
105+
});
106+
let result = await loader.search(searchDirectory);
107+
return result && result.config as Options;
108+
}
109+
110+
/**
111+
* Load configuration from a known path to the specific file.
112+
* Supports .js and .json files. If it's a file named "package.json",
113+
* it will load the configuration from the `"css-blocks"` property
114+
* of the package.json file.
115+
*
116+
* @param configPath path to the configuration file.
117+
* @throws If the file does not exist or is not readable.
118+
* @returns The options found
119+
*/
120+
export async function load(configPath: string): Promise<Options> {
121+
return _load(configPath, transformFinal);
122+
}
123+
124+
async function _load(configPath: string, transform: Transform): Promise<Options> {
125+
let loader = cosmiconfig("css-blocks", {
126+
transform,
127+
});
128+
let result = await loader.load(configPath);
129+
return result!.config as Options;
130+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { OutputMode } from "@css-blocks/core";
2+
import assert = require("assert");
3+
import path = require("path");
4+
import { chdir, cwd } from "process";
5+
6+
import { load, search } from "../src";
7+
8+
function fixture(...relativePathSegments: Array<string>): string {
9+
return path.resolve(__dirname, "..", "..", "test", "fixtures", ...relativePathSegments);
10+
}
11+
12+
const WORKING_DIR = cwd();
13+
14+
describe("validate", () => {
15+
afterEach(() => {
16+
chdir(WORKING_DIR);
17+
});
18+
it("can load configuration from the package.json in the current working directory", async () => {
19+
chdir(fixture("from-pkg-json"));
20+
let options = await search();
21+
if (options === null) {
22+
assert.fail("configuration wasn't found.");
23+
} else {
24+
assert.equal(options.outputMode, OutputMode.BEM);
25+
}
26+
});
27+
it("can load configuration from the package.json in a specified directory", async () => {
28+
let options = await search(fixture("from-pkg-json"));
29+
if (options === null) {
30+
assert.fail("configuration wasn't found.");
31+
} else {
32+
assert.equal(options.outputMode, OutputMode.BEM);
33+
}
34+
});
35+
it("will load configuration from the first package.json in ancestor directory", async () => {
36+
let options = await search(fixture("from-pkg-json", "subdir", "another-subdir"));
37+
if (options === null) {
38+
assert.fail("configuration wasn't found.");
39+
} else {
40+
assert.equal(options.outputMode, OutputMode.BEM);
41+
}
42+
});
43+
it("loads preprocessors if a file is specified.", async () => {
44+
let options = await search(fixture("from-pkg-json"));
45+
if (options === null) {
46+
assert.fail("configuration wasn't found.");
47+
} else {
48+
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
49+
}
50+
});
51+
it("can load configuration from css-blocks.config.json in the current working directory", async () => {
52+
chdir(fixture("from-json-file"));
53+
let options = await search();
54+
if (options === null) {
55+
assert.fail("configuration wasn't found.");
56+
} else {
57+
assert.equal(options.outputMode, OutputMode.BEM_UNIQUE);
58+
}
59+
});
60+
it("can load configuration from css-blocks.config.json in a specified directory", async () => {
61+
let options = await search(fixture("from-json-file"));
62+
if (options === null) {
63+
assert.fail("configuration wasn't found.");
64+
} else {
65+
assert.equal(options.outputMode, OutputMode.BEM_UNIQUE);
66+
}
67+
});
68+
it("will load configuration from css-blocks.config.json in an ancestor directory", async () => {
69+
let options = await search(fixture("from-json-file", "subdir", "another-subdir"));
70+
if (options === null) {
71+
assert.fail("configuration wasn't found.");
72+
} else {
73+
assert.equal(options.outputMode, OutputMode.BEM_UNIQUE);
74+
assert.equal(options.rootDir, fixture("from-json-file"));
75+
}
76+
});
77+
it("can extend configuration from another location", async () => {
78+
let options = await search(fixture("from-json-file"));
79+
if (options === null) {
80+
assert.fail("configuration wasn't found.");
81+
} else {
82+
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
83+
assert.equal(options.preprocessors && typeof options.preprocessors.less, "function");
84+
}
85+
});
86+
it("can specify a file containing an importer", async () => {
87+
let options = await search(fixture("from-json-file"));
88+
if (options === null) {
89+
assert.fail("configuration wasn't found.");
90+
} else {
91+
assert.equal(options.importer && typeof options.importer, "object");
92+
assert.equal(options.importerData && Array.isArray(options.importerData), true);
93+
}
94+
});
95+
it("can load configuration from css-blocks.config.js in the current working directory", async () => {
96+
chdir(fixture("from-js-file"));
97+
let options = await search();
98+
if (options === null) {
99+
assert.fail("configuration wasn't found.");
100+
} else {
101+
assert.equal(options.outputMode, OutputMode.BEM);
102+
assert.equal(options.maxConcurrentCompiles, 8);
103+
assert.equal(options.rootDir, fixture("from-js-file", "blocks"));
104+
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
105+
assert.equal(options.preprocessors && typeof options.preprocessors.styl, "function");
106+
}
107+
});
108+
it("rootDir is not overridden if specified explicitly", async () => {
109+
chdir(fixture("another-js-file"));
110+
let options = await search();
111+
if (options === null) {
112+
assert.fail("configuration wasn't found.");
113+
} else {
114+
assert.equal(options.outputMode, OutputMode.BEM);
115+
assert.equal(options.maxConcurrentCompiles, 8);
116+
assert.equal(options.rootDir, fixture("from-js-file", "blocks"));
117+
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
118+
assert.equal(options.preprocessors && typeof options.preprocessors.styl, "function");
119+
}
120+
});
121+
it("can load a config file from an explicit path.", async () => {
122+
let options = await load(fixture("another-js-file", "css-blocks.config.js"));
123+
if (options === null) {
124+
assert.fail("configuration wasn't found.");
125+
} else {
126+
assert.equal(options.outputMode, OutputMode.BEM);
127+
assert.equal(options.maxConcurrentCompiles, 8);
128+
assert.equal(options.rootDir, fixture("from-js-file", "blocks"));
129+
assert.equal(options.preprocessors && typeof options.preprocessors.scss, "function");
130+
assert.equal(options.preprocessors && typeof options.preprocessors.styl, "function");
131+
}
132+
});
133+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
extends: "../from-js-file/css-blocks.config.js"
3+
}

packages/@css-blocks/config/test/fixtures/from-js-file/blocks/.gitkeep

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
module.exports = {
2+
extends: "../from-pkg-json/package.json",
3+
maxConcurrentCompiles: 8,
4+
rootDir: "blocks",
5+
preprocessors: {
6+
styl: (_fullPath, content, _configuration, _sourceMap) => {return {content}; },
7+
}
8+
};

0 commit comments

Comments
 (0)