Initial commit, v1.0

pull/1/head
Sven Slootweg 4 years ago
commit c519e107e3

4
.gitignore vendored

@ -0,0 +1,4 @@
node_modules
yarn.lock
npm-debug.log
package-lock.json

@ -0,0 +1,81 @@
# icssify
A Browserify plugin for handling CSS (through PostCSS), with **full and correct support** for [ICSS](https://github.com/css-modules/icss) and [CSS modules](https://github.com/css-modules/css-modules/blob/master/README.md). Works with `css-extract`. Allows specifying custom PostCSS transforms.
Inspired by [postcssify-iss](https://github.com/carlhopf/postcssify-icss), but essentially an entirely new implementation, due to different architectural requirements.
The `index.js` file in this module's repository contains inline documentation on how the plugin works. If you're looking to implement your own ICSS / CSS modules implementation for another bundler (or just want to know how this one works!), it may be useful to give it a read.
## Why use icssify and not ________?
* __[css-loader](https://github.com/webpack-contrib/css-loader):__ This is a Webpack plugin, so it can't be used with Browserify.
* __[css-modulesify](https://github.com/css-modules/css-modulesify):__ Outdated, seemingly no longer maintained, heavily relies on an also-no-longer-maintained 'core' library.
* __[postcssify-icss](https://github.com/carlhopf/postcssify-icss):__ Outdated approach, ICSS imports do not work correctly, `extract-css` not supported.
## Considerations
This plugin changes quite a few things in the Browserify pipeline to make CSS work correctly, and it tries to make those changes as unobtrusively as possible. However, due to design limitations in Browserify, it's *not guaranteed* that this plugin will work together with other Browserify plugins.
In particular, the following things should be kept in mind:
- This plugin changes Browserify's sorting algorithm, so that files are always processed in 'dependency order'. While this *shouldn't* be an issue because the new algorithm is deterministic just like the old one, it's possible for a different plugin to break this one, if it changes the sorting algorithm. To prevent this, always load `icssify` __last__ (but still before `watchify` and `css-extract`, if you're using those).
- The sorting algorithm cannot currently deal with circular dependencies. __Trying to use this plugin will break, when you have circular dependencies.__ You really shouldn't have those, but this is still worth keeping in mind. PRs to fix this are welcome.
- CSS files are not JS files. This plugin sneaks the CSS past Browserify's syntax checker, but it will still send CSS files through the pipeline. Plugins that operate on JS *must* ignore non-JS files, otherwise they will break.
This plugin will always bundle all CSS __into a single file__. For complexity reasons, there is currently no support for splitting up CSS into multiple bundles. PRs that add support for this (without breaking ICSS or `css-extract` support!) are welcome.
By default, this plugin will use `insert-css` to automatically load the bundled CSS into the browser when any part of it is `require()`d. If you want to serve the CSS as a separate file, rather than as a part of the bundle, use [`css-extract`](https://www.npmjs.com/package/css-extract).
## License
Most of this library is licensed under the [WTFPL](http://www.wtfpl.net/txt/copying/) or [CC0](https://creativecommons.org/publicdomain/zero/1.0/), at your choice. This basically means you can treat it as public domain, and use it in any way you want. Attribution is appreciated, but not required!
Some parts (namely, the PostCSS plugins) are derived from `css-loader`, and are therefore under the [MIT](https://opensource.org/licenses/MIT) license. The affected files contain a licensing header saying so.
Any contributions made to this projects are assumed to be dual-licensed under the WTFPL/CC0.
## Usage examples
Using Babel and `icssify`, and bundling the CSS in with the JS, auto-loading it:
```
browserify -t [ babelify ] -p [ icssify ] src/index.js > dist/bundle.js
```
The same, but extracting the CSS into a separate file:
```
browserify -t [ babelify ] -p [ icssify ] -p [ css-extract -o dist/bundle.css ] src/index.js > dist/bundle.js
```
Or through the programmatic Browserify API:
```js
const icssify = require("icssify");
const cssExtract = require("css-extract");
// ... browserify setup code goes here ...
browserifyInstance.plugin(icssify, { before: [ /* your custom PostCSS transforms go here */ ] });
/* And, if you want to extract the CSS, also do: */
browserifyInstance.plugin(cssExtract, { out: "dist/bundle.css" });
// ... more browserify code goes here ...
```
For further usage examples, refer to the usual Browserify documentation. `icssify` is just a plugin like any other, and shouldn't require special handling, other than what's listed in the "considerations" section above.
## API
Plugin options (all optional):
* __mode:__ Whether to assume that untagged class names in your CSS (ie. those without a `:local` or `:global` tag) are local or global. Defaults to `"local"`, but you can set this to `"global"` if you want to make the class name mangling *opt-in*. You'll generally want to leave this at the default setting.
* __before:__ PostCSS transforms to run *before* the ICSS transforms, ie. before imports/exports are analyzed. This is usually where you want custom PostCSS plugins to go.
* __after:__ PostCSS transforms to run *after* the ICSS transforms, ie. after mangling the class names, but before bundling it all together into a single file. You'll rarely need to use this.
## Changelog
### v1.0.0 (November 24, 2019)
Initial release.

@ -0,0 +1,5 @@
"use strict";
var insertCss = require("insert-css");
insertCss("## CONTENT MARKER ##");

@ -0,0 +1,45 @@
"use strict";
const createTransform = require("./src/transform");
const createCssDepsStream = require("./src/phase-streams/deps-css");
const createSyntaxHideStream = require("./src/phase-streams/syntax-hide");
const createSyntaxUnhideStream = require("./src/phase-streams/syntax-unhide");
const createKahnSortingStream = require("./src/phase-streams/sort-kahn");
const createDedupeBundleCssStream = require("./src/phase-streams/dedupe-bundle-css");
function setupPipeline(browserify, options) {
/* Not shown here: the default 'deps' phase stream will process all the CSS files through our custom transform (defined elsewhere). That transform handles the discovery of CSS dependencies, by pre-processing the CSS files (normalizing 'composes' statements etc. into :import/:export statements), and then extracting all the imports from them, emitting them to Browserify as additional dependencies to fetch. */
let depsPhase = browserify.pipeline.get("deps");
/* This stream marks CSS files as 'discardable' when they are only required by other CSS files and not by any JS files; this allows for their to be stripped from the final bundle, saving space. */
depsPhase.push(createCssDepsStream());
let syntaxPhase = browserify.pipeline.get("syntax");
/* These streams sneak the CSS past the JS syntax checker contained in Browserify, by encoding it in a "JS" file that contains a comment (which will always pass validation), and unpacking it into CSS again after the syntax check has occurred. This effectively opts the CSS files out of the syntax check, which only knows about JS. */
syntaxPhase.unshift(createSyntaxHideStream());
syntaxPhase.push(createSyntaxUnhideStream());
let sortPhase = browserify.pipeline.get("sort");
/* This stream re-sorts the dependencies into dependency order using Kahn's algorithm; that is to say, each dependency always appears before the file(s) that depend on it. This is needed for our final CSS processing (when we resolve the exported/imported identifiers between them), to make sure that we've processed all the dependencies of a file before processing the file itself (as otherwise we wouldn't know all the resolve identifiers to use). This is a deterministic algorith, which means it *does not* break the determinism guarantee of Browserify's own hash-sorting mechanism, and shouldn't interfere with anything else. It could be broken by other plugins adding their own sorting mechanism after this one, though; so this plugin should be loaded last. */
sortPhase.push(createKahnSortingStream());
let dedupePhase = browserify.pipeline.get("dedupe");
/* This stream does the actual bundling of CSS files. It does the final processing of all CSS files (ICSS :import/:export resolution, identifier replacing, etc.), then throws away all the files that were marked as 'discardable' earlier, converts the remaining files into JS-formatted class name exports (for use with eg. JSX), and finally inserts the fully-bundled CSS into the loader shim. Ta-da! */
dedupePhase.push(createDedupeBundleCssStream(options));
}
module.exports = function (browserify, options) {
browserify.transform(createTransform, options);
browserify.on("reset", () => {
setupPipeline(browserify, options);
});
setupPipeline(browserify, options);
return browserify;
};

@ -0,0 +1,44 @@
{
"name": "icssify",
"version": "1.0.0",
"description": "A Browserify plugin for handling CSS (through PostCSS), with **full and correct support** for ICSS and CSS modules.",
"main": "index.js",
"repository": {
"type": "git",
"url": "http://git.cryto.net/joepie91/icssify.git"
},
"keywords": [
"postcss",
"browserify",
"transform",
"icss",
"css",
"modules",
"css-modulesify"
],
"author": "Sven Slootweg <admin@cryto.net>",
"license": "(WTFPL OR CC0-1.0) AND MIT",
"bugs": {
"url": "https://git.cryto.net/joepie91/icssify/issues"
},
"dependencies": {
"assure-array": "^1.0.0",
"bl": "^4.0.0",
"bluebird": "^3.7.1",
"default-value": "^1.0.0",
"generic-names": "^2.0.1",
"insert-css": "^2.0.0",
"object.fromentries": "^2.0.1",
"postcss": "^6.0.9",
"postcss-modules-extract-imports": "^2.0.0",
"postcss-modules-local-by-default": "^3.0.2",
"postcss-modules-scope": "^2.1.0",
"postcss-modules-values": "^3.0.0",
"through2": "^2.0.3",
"validatem": "^0.2.0"
},
"devDependencies": {
"@joepie91/eslint-config": "^1.1.0",
"eslint": "^5.9.0"
}
}

@ -0,0 +1,14 @@
"use strict";
const stream = require("./stream");
const isCss = require("./is-css");
module.exports = function cssOnlyStream(handler, flushHandler) {
return stream((item) => {
if (isCss(item)) {
return handler(item);
} else {
return [ item ];
}
}, flushHandler);
};

@ -0,0 +1,6 @@
"use strict";
module.exports = function hideCss(css) {
// This sneaks the CSS past the syntax checker, by packaging it in a JS comment, so that it can be extracted back out later for further processing
return `/* HIDDEN CSS BY ICSSIFY */ // ${JSON.stringify(css)}`;
};

@ -0,0 +1,5 @@
"use strict";
module.exports = function isCss(item) {
return /\.css$/.test(item.file);
};

@ -0,0 +1,40 @@
"use strict";
const util = require("util");
// TODO: Publish this as a stand-alone module
module.exports = function kahn(nodes) {
let queue = [];
let list = [];
// Start with all the zero-dependency nodes
for (let node of nodes) {
if (node.parents.length === 0) {
queue.push(node);
}
}
while (queue.length > 0) {
let current = queue.shift();
list.push(current);
// For each dependent/parent of this item
for (let child of current.children) {
child.parents.splice(child.parents.indexOf(current), 1);
// If all its dependencies have been fully processed and removed
if (child.parents.length === 0) {
queue.push(child);
} else {
// TODO: Add a proper loop checker!
if (child.children.some((item) => item === child) || child.parents.some((item) => item === child)) {
throw new Error(`Dependency on self encountered: ${util.inspect(child)}`);
}
}
}
}
return list;
};

@ -0,0 +1,5 @@
"use strict";
const path = require("path");
module.exports = path.resolve(__dirname, "../global-css-loader.js");

@ -0,0 +1,58 @@
"use strict";
const Promise = require("bluebird");
const path = require("path");
const stream = require("../stream");
const createFilePostprocessor = require("../postcss/postprocess");
const isCss = require("../is-css");
const loaderShimPath = require("../loader-shim-path");
// NOTE: This stream is inserted directly after the 'dedupe' phase, and *not* before the 'pack' phase like in postcssify-icss, because that would break `extract-css` - which inserts itself before the 'label' phase that comes after the 'dedupe' phase, and by which time all the CSS needs to be prepared.
module.exports = function createDedupeBundleCssStream(options) {
// TODO: Reuse instance?
let processFile = createFilePostprocessor(options);
let allCss = "";
let loaderItem;
// Get the (absolute!) path of the folder containing the initial entry file, as a reference point for relative paths in the output
let entryPoint = path.dirname(path.resolve(options._flags.entries[0]));
return stream((item) => {
// And the same for the loader shim path. All this relative-path stuff is to prevent absolute filesystem URLs from leaking into the output, as those might contain sensitive information.
let relativeLoaderPath = path.relative(path.dirname(item.file), loaderShimPath);
if (isCss(item)) {
return Promise.try(() => {
return processFile(item);
}).then(({ result, icssExports }) => {
allCss += `/* from ${path.relative(entryPoint, item.file)} */\n\n${result.css}\n\n`;
if (!item.__icssify__discardable) {
return {
... item,
source: `require(${JSON.stringify(relativeLoaderPath)}); module.exports = ${JSON.stringify(icssExports)};`
};
}
});
} else if (item.file === loaderShimPath) {
// Stockpile this item, to emit at the very end, right before the browser-pack operation (because by that time, we'll have processed and therefore concatenated all the CSS files)
loaderItem = item;
return null;
} else {
return item;
}
}, () => {
if (allCss.length > 0) {
if (loaderItem != null) {
return {
... loaderItem,
source: loaderItem.source.replace('"## CONTENT MARKER ##"', JSON.stringify(allCss))
};
} else {
throw new Error("Processed CSS, but global loader was not encountered. This should never happen, please report it as a bug!");
}
}
});
};

@ -0,0 +1,38 @@
"use strict";
const stream = require("../stream");
const isCss = require("../is-css");
module.exports = function createCssDepsStream() {
let dependents = new Map();
let depsPhaseItems = [];
function handleItem(item) {
Object.values(item.deps).forEach((dependency) => {
if (!dependents.has(dependency)) {
dependents.set(dependency, new Set());
}
dependents.get(dependency).add(item);
});
depsPhaseItems.push(item);
}
function flush() {
return depsPhaseItems.map((item) => {
let selfIsCss = isCss(item);
let dependentsForItem = dependents.get(item.id);
let hasDependents = (dependentsForItem != null);
let dependentsAreCss = hasDependents && Array.from(dependentsForItem).every((dependent) => isCss(dependent));
return {
... item,
__icssify__discardable: (selfIsCss && dependentsAreCss)
};
});
}
return stream(handleItem, flush);
};

@ -0,0 +1,23 @@
"use strict";
const through2 = require("through2");
const sortDependencies = require("../sort-dependencies");
const stream = require("../stream");
module.exports = function createKahnSortingStream() {
let sortables = [];
function handleItem(item) {
sortables.push(item);
}
function flush() {
let sorted = sortDependencies(sortables);
// TODO: Verify that the 'null' push here is necessary
return sorted.concat([ null ]);
}
return stream(handleItem, flush);
};

@ -0,0 +1,13 @@
"use strict";
const cssOnlyStream = require("../css-only-stream");
const hideCss = require("../hide-css");
module.exports = function createSyntaxHideStream() {
return cssOnlyStream((file) => {
return {
... file,
source: hideCss(file.source)
};
});
};

@ -0,0 +1,13 @@
"use strict";
const cssOnlyStream = require("../css-only-stream");
const unhideCss = require("../unhide-css");
module.exports = function createSyntaxHideStream() {
return cssOnlyStream((file) => {
return {
... file,
source: unhideCss(file.source)
};
});
};

@ -0,0 +1,38 @@
"use strict";
/*
This module is derived from: css-loader (https://github.com/webpack-contrib/css-loader)
(c) JS Foundation and other contributors
Modifications by:
(c) 2019, Sven Slootweg <admin@cryto.net>
Licensed under:
MIT (https://opensource.org/licenses/MIT)
*/
const postcss = require("postcss");
const icssUtils = require("icss-utils");
const loaderUtils = require("loader-utils");
const pluginName = 'postcss-icss-find-imports';
module.exports = postcss.plugin(pluginName, (_options = {}) => {
return function process(css, result) {
let discoveredImports = new Set();
let { icssImports, icssExports } = icssUtils.extractICSS(css, false);
for (let importUrl of Object.keys(icssImports)) {
discoveredImports.add(loaderUtils.parseString(importUrl));
}
for (let url of discoveredImports) {
result.messages.push({
pluginName: pluginName,
type: "import",
url: url
});
}
};
});

@ -0,0 +1,65 @@
"use strict";
/*
This module is derived from: css-loader (https://github.com/webpack-contrib/css-loader)
(c) JS Foundation and other contributors
Modifications by:
(c) 2019, Sven Slootweg <admin@cryto.net>
Licensed under:
MIT (https://opensource.org/licenses/MIT)
*/
const postcss = require("postcss");
const icssUtils = require("icss-utils");
const loaderUtils = require("loader-utils");
const { validateOptions, required, isFunction } = require("validatem");
const pluginName = 'postcss-icss-parser';
module.exports = postcss.plugin(pluginName, (options = {}) => {
validateOptions([options], {
keyReplacer: [ required, isFunction ]
});
return function process(css, result) {
const importReplacements = Object.create(null);
const { icssImports, icssExports } = icssUtils.extractICSS(css);
let index = 0;
for (const [ importUrl, imports ] of Object.entries(icssImports)) {
const url = loaderUtils.parseString(importUrl);
for (const [ localKey, remoteKey ] of Object.entries(imports)) {
index += 1;
let newKey = options.keyReplacer({ localKey, remoteKey, index, url });
importReplacements[localKey] = newKey;
result.messages.push({
pluginName,
type: 'icss-import',
item: { url, localKey, remoteKey, newKey, index },
});
}
}
icssUtils.replaceSymbols(css, importReplacements);
for (const [ name, value ] of Object.entries(icssExports)) {
/* This is to handle re-exports of imported items */
const parsedValue = icssUtils.replaceValueSymbols(
value,
importReplacements
);
result.messages.push({
pluginName: pluginName,
type: "icss-export",
item: { name, value: parsedValue }
});
}
}
});

@ -0,0 +1,66 @@
"use strict";
const Promise = require("bluebird");
const postcss = require("postcss");
const defaultValue = require("default-value");
const objectFromEntries = require("object.fromentries");
const icssParser = require("./postcss-icss-parser");
module.exports = function createFilePostprocessor(options) {
let allExports = new Map(); // url -> (remoteKey -> mangledName)
return function processFile(item) {
return Promise.try(() => {
// TODO: Reuse instance, figure out how to pass the file metadata to the callback for an individual `process` call
let postcssInstance = postcss([
icssParser({
keyReplacer: ({ url, remoteKey }) => {
let resolvedSourcePath = item.deps[url];
if (resolvedSourcePath == null) {
throw new Error(`Referenced file '${sourceFile}' not resolved. This should never happen; please file a bug!`);
}
let sourceFile = allExports.get(resolvedSourcePath);
if (sourceFile == null) {
throw new Error(`Referenced file '${sourceFile}' not processed. This should never happen; please file a bug!`);
}
let mangledName = sourceFile.get(remoteKey);
if (mangledName == null) {
// TODO: Error type
throw new Error(`No export named '${remoteKey}' found in file ${url}`);
}
/* The replacement is to deal with the difference in multiple-class notation between CSS and HTML; in CSS they are dot-delimited, but in HTML (which the ICSS spec targets) they are space-delimited. We need the CSS notation here. */
return mangledName.replace(/ /g, ".");
}
}),
... defaultValue(options.after, [])
]);
return postcssInstance.process(item.source, {
from: item.file,
... options
});
}).then((result) => {
let exportMessages = result.messages.filter((message) => {
return (message.pluginName === "postcss-icss-parser" && message.type === "icss-export");
});
let exportEntries = exportMessages.map((message) => {
return [ message.item.name, message.item.value ];
});
let exportMap = new Map(exportEntries);
let exportObject = objectFromEntries(exportEntries);
allExports.set(item.file, exportMap);
return { result, icssExports: exportObject };
});
};
};

@ -0,0 +1,33 @@
"use strict";
const postcss = require("postcss");
const defaultValue = require("default-value");
const postcssValues = require("postcss-modules-values");
const postcssLocalByDefault = require("postcss-modules-local-by-default");
const postcssExtractImports = require("postcss-modules-extract-imports");
const postcssScope = require("postcss-modules-scope");
const genericNames = require("generic-names");
const postcssFindImports = require("./postcss-icss-find-imports");
module.exports = function createFilePreprocessor(options) {
let generateScopedName = defaultValue(options.generateScopedName, genericNames("[name]__[local]---[hash:base64:5]"));
let postcssInstance = postcss([
... defaultValue(options.before, []),
postcssValues,
postcssLocalByDefault({ mode: defaultValue(options.mode, "local") }),
postcssExtractImports(),
postcssScope({
generateScopedName: generateScopedName
}),
postcssFindImports()
]);
return function preprocessFile(item) {
return postcssInstance.process(item.source, {
from: item.file,
... options
});
};
};

@ -0,0 +1,33 @@
"use strict";
const kahn = require("./kahn");
module.exports = function sortDependencies(items) {
let dependencyMap = new Map();
let nodeMap = new Map();
// The below code 1) inverts child-specifying nodes to parent-specifying nodes, and 2) ensures that nodes reference in-memory objects rather than IDs, so they work with the implementation of Kahn's algorithm. TODO: This should probably be made nicer at some point...
items.forEach((item) => {
dependencyMap.set(item.id, item);
nodeMap.set(item.id, { id: item.id, children: [] });
});
items.forEach((item) => {
Object.values(item.deps)
.filter((dep => dep !== item.id))
.forEach((dep) => {
nodeMap.get(dep).children.push(nodeMap.get(item.id));
});
nodeMap.get(item.id).parents = Object.values(item.deps)
.filter((id => id !== item.id))
.map((id) => nodeMap.get(id));
});
let sortedNodes = kahn(Array.from(nodeMap.values()));
return sortedNodes.map((node) => {
return dependencyMap.get(node.id);
});
};

@ -0,0 +1,31 @@
"use strict";
const Promise = require("bluebird");
const through2 = require("through2");
const assureArray = require("assure-array");
function wrapStreamHandler(stream, handler, callback, item) {
return Promise.try(() => {
return handler(item);
}).then((result) => {
return assureArray(result);
}).each((newItem) => {
stream.push(newItem);
}).then(() => {
callback();
}).catch((err) => {
callback(err);
});
}
module.exports = function stream(handler, flushHandler) {
let flushHandlerWrapper = (flushHandler == null)
? undefined
: function (callback) {
wrapStreamHandler(this, flushHandler, callback);
};
return through2.obj(function (item, _encoding, callback) {
wrapStreamHandler(this, handler, callback, item);
}, flushHandlerWrapper);
};

@ -0,0 +1,60 @@
"use strict";
const Promise = require("bluebird");
const bl = require("bl");
const through2 = require("through2");
const path = require("path");
const isCss = require("./is-css");
const createFilePreprocessor = require("./postcss/preprocess");
const loaderShimPath = require("./loader-shim-path");
module.exports = function createTransform(file, options) {
// TODO: Reuse instance?
let preprocessFile = createFilePreprocessor(options);
if (isCss({ file: file })) {
let buffer = new bl.BufferList();
function chunkHandler(chunk, encoding, callback) {
if (encoding === "buffer") {
buffer.append(chunk);
callback();
} else {
throw new Error(`Expected Buffer, got ${encoding} string instead`);
}
}
function endHandler(callback) {
return Promise.try(() => {
return preprocessFile({ file: file, source: buffer.toString() });
}).then((result) => {
let imports = result.messages
.filter((message) => {
return (
message.pluginName === "postcss-icss-find-imports"
&& message.type === "import"
);
})
.map((message) => message.url);
imports.forEach((importUrl) => {
this.emit("dep", importUrl);
});
// This ensures that whenever *any* CSS file is processed, the global loader file gets included as well (which is the file that eventually inserts the concatenated CSS)
let relativeLoaderPath = path.relative(path.dirname(file), loaderShimPath);
this.emit("dep", relativeLoaderPath);
this.push(result.css);
callback();
}).catch((error) => {
callback(error);
});
}
return through2.obj(chunkHandler, endHandler);
} else {
return through2.obj();
}
};

@ -0,0 +1,14 @@
"use strict";
let hiddenCssMatcher = /^\/\* HIDDEN CSS BY ICSSIFY \*\/ \/\/ (.+)/;
module.exports = function unhideCss(css) {
let match = hiddenCssMatcher.exec(css);
if (match != null) {
return JSON.parse(match[1]);
} else {
// This was apparently not (hidden) CSS, but it should have been
throw new Error("Expected hidden CSS, but did not find it");
}
}
Loading…
Cancel
Save