diff --git a/README.md b/README.md index f7e2ac28..ac0148e1 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,17 @@ This option is only for adding additional directories to default resolver. If you provide your own resolver via the `resolve` configuration option above, then this value will be ignored. +#### `nameLayer` + +Type: `Function` +Default: `null` + +You can provide a custom naming function for anonymous layers (`@import 'baz.css' layer;`). +This function gets `(index, rootFilename)` arguments and should return a unique string. + +This option only influences imports without a layer name. +Without this option the plugin will warn on anonymous layers. + #### Example with some options ```js diff --git a/index.js b/index.js index e6a3dc33..6cb912e1 100755 --- a/index.js +++ b/index.js @@ -19,6 +19,7 @@ function AtImport(options) { load: loadContent, plugins: [], addModulesDirectories: [], + nameLayer: null, ...options, } @@ -37,9 +38,12 @@ function AtImport(options) { const state = { importedFiles: {}, hashFiles: {}, + rootFilename: null, + anonymousLayerCounter: 0, } if (styles.source && styles.source.input && styles.source.input.file) { + state.rootFilename = styles.source.input.file state.importedFiles[styles.source.input.file] = {} } @@ -47,6 +51,10 @@ function AtImport(options) { throw new Error("plugins option must be an array") } + if (options.nameLayer && typeof options.nameLayer !== "function") { + throw new Error("nameLayer option must be a function") + } + return parseStyles(result, styles, options, state, [], []).then( bundle => { applyRaws(bundle) @@ -81,7 +89,30 @@ function AtImport(options) { if (stmt.type === "import") { stmt.node.params = `${stmt.fullUri} ${stmt.media.join(", ")}` } else if (stmt.type === "media") { - stmt.node.params = stmt.media.join(", ") + if (stmt.layer.length) { + const layerNode = atRule({ + name: "layer", + params: stmt.layer.filter(layer => layer !== "").join("."), + source: stmt.node.source, + }) + + if (stmt.parentMedia && stmt.parentMedia.length) { + const mediaNode = atRule({ + name: "media", + params: stmt.parentMedia.join(", "), + source: stmt.node.source, + }) + + mediaNode.append(layerNode) + layerNode.append(stmt.node) + stmt.node = mediaNode + } else { + layerNode.append(stmt.node) + stmt.node = layerNode + } + } else { + stmt.node.params = stmt.media.join(", ") + } } else { const { nodes } = stmt const { parent } = nodes[0] @@ -170,6 +201,7 @@ function AtImport(options) { return stmts.reduce((promise, stmt) => { return promise.then(() => { stmt.media = joinMedia(media, stmt.media || []) + stmt.parentMedia = media stmt.layer = joinLayer(layer, stmt.layer || []) // skip protocol base uri (protocol://url) or protocol-relative @@ -283,18 +315,38 @@ function AtImport(options) { function loadImportContent(result, stmt, filename, options, state) { const atRule = stmt.node const { media, layer } = stmt + layer.forEach((layerPart, i) => { + if (layerPart === "") { + if (options.nameLayer) { + layer[i] = options + .nameLayer(state.anonymousLayerCounter++, state.rootFilename) + .toString() + } else { + throw atRule.error( + `When using anonymous layers in @import you must also set the "nameLayer" plugin option` + ) + } + } + }) + if (options.skipDuplicates) { // skip files already imported at the same scope if ( state.importedFiles[filename] && - state.importedFiles[filename][media] + state.importedFiles[filename][media] && + state.importedFiles[filename][media][layer] ) { return } // save imported files to skip them next time - if (!state.importedFiles[filename]) state.importedFiles[filename] = {} - state.importedFiles[filename][media] = true + if (!state.importedFiles[filename]) { + state.importedFiles[filename] = {} + } + if (!state.importedFiles[filename][media]) { + state.importedFiles[filename][media] = {} + } + state.importedFiles[filename][media][layer] = true } return Promise.resolve(options.load(filename, options)).then( @@ -305,8 +357,13 @@ function AtImport(options) { } // skip previous imported files not containing @import rules - if (state.hashFiles[content] && state.hashFiles[content][media]) + if ( + state.hashFiles[content] && + state.hashFiles[content][media] && + state.hashFiles[content][media][layer] + ) { return + } return processContent( result, @@ -324,8 +381,13 @@ function AtImport(options) { }) if (!hasImport) { // save hash files to skip them next time - if (!state.hashFiles[content]) state.hashFiles[content] = {} - state.hashFiles[content][media] = true + if (!state.hashFiles[content]) { + state.hashFiles[content] = {} + } + if (!state.hashFiles[content][media]) { + state.hashFiles[content][media] = {} + } + state.hashFiles[content][media][layer] = true } } diff --git a/test/fixtures/imports/layer-anonymous.css b/test/fixtures/imports/layer-anonymous.css new file mode 100644 index 00000000..589aeeb1 --- /dev/null +++ b/test/fixtures/imports/layer-anonymous.css @@ -0,0 +1,33 @@ +@import url("layer-level-2-anonymous.css") layer; + +body { + order: 1; +} + +@media (min-width: 50rem) { + body { + order: 2; + } +} + +@keyframes RED_TO_BLUE { + 0% { + background-color: red; + } + 100% { + background-color: blue; + } +} + +@supports (display: grid) { + body { + display: grid; + order: 3; + } +} + +@layer A { + body { + order: 4; + } +} diff --git a/test/fixtures/imports/layer-level-2-anonymous.css b/test/fixtures/imports/layer-level-2-anonymous.css new file mode 100644 index 00000000..36d69c28 --- /dev/null +++ b/test/fixtures/imports/layer-level-2-anonymous.css @@ -0,0 +1,7 @@ +@import url("layer-level-3.css") layer (min-width: 320px); + +@layer Y { + body { + color: purple; + } +} diff --git a/test/fixtures/imports/layer-level-2.css b/test/fixtures/imports/layer-level-2.css new file mode 100644 index 00000000..8ec5e25b --- /dev/null +++ b/test/fixtures/imports/layer-level-2.css @@ -0,0 +1,7 @@ +@import url("layer-level-3.css") layer(level-3) (min-width: 320px); + +@layer Y { + body { + color: purple; + } +} diff --git a/test/fixtures/imports/layer-level-3.css b/test/fixtures/imports/layer-level-3.css new file mode 100644 index 00000000..1567ae96 --- /dev/null +++ b/test/fixtures/imports/layer-level-3.css @@ -0,0 +1,5 @@ +@layer Z { + body { + color: cyan; + } +} diff --git a/test/fixtures/imports/layer-rule-grouping.css b/test/fixtures/imports/layer-rule-grouping.css new file mode 100644 index 00000000..c3febe5e --- /dev/null +++ b/test/fixtures/imports/layer-rule-grouping.css @@ -0,0 +1,13 @@ +rule-one {} + +rule-two {} + +@media (min-width: 50rem) { + rule-three {} + + rule-four {} +} + +rule-five {} + +rule-six {} diff --git a/test/fixtures/imports/layer.css b/test/fixtures/imports/layer.css new file mode 100644 index 00000000..9e554b76 --- /dev/null +++ b/test/fixtures/imports/layer.css @@ -0,0 +1,33 @@ +@import url("layer-level-2.css") layer(level-2); + +body { + order: 1; +} + +@media (min-width: 50rem) { + body { + order: 2; + } +} + +@keyframes RED_TO_BLUE { + 0% { + background-color: red; + } + 100% { + background-color: blue; + } +} + +@supports (display: grid) { + body { + display: grid; + order: 3; + } +} + +@layer A { + body { + order: 4; + } +} diff --git a/test/fixtures/layer-import-atrules-anonymous.css b/test/fixtures/layer-import-atrules-anonymous.css new file mode 100644 index 00000000..f2d93560 --- /dev/null +++ b/test/fixtures/layer-import-atrules-anonymous.css @@ -0,0 +1,3 @@ +@import url("layer-anonymous.css") layer screen; + +@import url("layer-anonymous.css") layer; diff --git a/test/fixtures/layer-import-atrules-anonymous.expected.css b/test/fixtures/layer-import-atrules-anonymous.expected.css new file mode 100644 index 00000000..be1df273 --- /dev/null +++ b/test/fixtures/layer-import-atrules-anonymous.expected.css @@ -0,0 +1,127 @@ +@media screen and (min-width: 320px) { +@layer import-anon-layer-0.import-anon-layer-1.import-anon-layer-2 { +@layer Z { + body { + color: cyan; + } +} +} +} + +@media screen { +@layer import-anon-layer-0.import-anon-layer-1 { + +@layer Y { + body { + color: purple; + } +} +} +} + +@media screen { +@layer import-anon-layer-0 { + +body { + order: 1; +} +} +} + +@media screen { +@layer import-anon-layer-0 { + +@media (min-width: 50rem) { + body { + order: 2; + } +} +} +} + +@media screen { +@layer import-anon-layer-0 { + +@keyframes RED_TO_BLUE { + 0% { + background-color: red; + } + 100% { + background-color: blue; + } +} + +@supports (display: grid) { + body { + display: grid; + order: 3; + } +} + +@layer A { + body { + order: 4; + } +} +} +} + +@media (min-width: 320px) { +@layer import-anon-layer-3.import-anon-layer-4.import-anon-layer-5 { +@layer Z { + body { + color: cyan; + } +} +} +} + +@layer import-anon-layer-3.import-anon-layer-4 { + +@layer Y { + body { + color: purple; + } +} +} + +@layer import-anon-layer-3 { + +body { + order: 1; +} +} + +@layer import-anon-layer-3 { + +@media (min-width: 50rem) { + body { + order: 2; + } +} +} + +@layer import-anon-layer-3 { + +@keyframes RED_TO_BLUE { + 0% { + background-color: red; + } + 100% { + background-color: blue; + } +} + +@supports (display: grid) { + body { + display: grid; + order: 3; + } +} + +@layer A { + body { + order: 4; + } +} +} diff --git a/test/fixtures/layer-import-atrules.css b/test/fixtures/layer-import-atrules.css new file mode 100644 index 00000000..dab4c0e7 --- /dev/null +++ b/test/fixtures/layer-import-atrules.css @@ -0,0 +1,3 @@ +@import url("layer.css") layer(imported-with-media) screen; + +@import url("layer.css") layer(imported-without-media); diff --git a/test/fixtures/layer-import-atrules.expected.css b/test/fixtures/layer-import-atrules.expected.css new file mode 100644 index 00000000..e4d6c195 --- /dev/null +++ b/test/fixtures/layer-import-atrules.expected.css @@ -0,0 +1,127 @@ +@media screen and (min-width: 320px) { +@layer imported-with-media.level-2.level-3 { +@layer Z { + body { + color: cyan; + } +} +} +} + +@media screen { +@layer imported-with-media.level-2 { + +@layer Y { + body { + color: purple; + } +} +} +} + +@media screen { +@layer imported-with-media { + +body { + order: 1; +} +} +} + +@media screen { +@layer imported-with-media { + +@media (min-width: 50rem) { + body { + order: 2; + } +} +} +} + +@media screen { +@layer imported-with-media { + +@keyframes RED_TO_BLUE { + 0% { + background-color: red; + } + 100% { + background-color: blue; + } +} + +@supports (display: grid) { + body { + display: grid; + order: 3; + } +} + +@layer A { + body { + order: 4; + } +} +} +} + +@media (min-width: 320px) { +@layer imported-without-media.level-2.level-3 { +@layer Z { + body { + color: cyan; + } +} +} +} + +@layer imported-without-media.level-2 { + +@layer Y { + body { + color: purple; + } +} +} + +@layer imported-without-media { + +body { + order: 1; +} +} + +@layer imported-without-media { + +@media (min-width: 50rem) { + body { + order: 2; + } +} +} + +@layer imported-without-media { + +@keyframes RED_TO_BLUE { + 0% { + background-color: red; + } + 100% { + background-color: blue; + } +} + +@supports (display: grid) { + body { + display: grid; + order: 3; + } +} + +@layer A { + body { + order: 4; + } +} +} diff --git a/test/fixtures/layer-rule-grouping.css b/test/fixtures/layer-rule-grouping.css new file mode 100644 index 00000000..afa489e5 --- /dev/null +++ b/test/fixtures/layer-rule-grouping.css @@ -0,0 +1,6 @@ +@import url("layer-rule-grouping.css") screen; + +@import url("layer-rule-grouping.css") layer; +@import url("layer-rule-grouping.css") layer; + +@import url("layer-rule-grouping.css") layer(named); diff --git a/test/fixtures/layer-rule-grouping.expected.css b/test/fixtures/layer-rule-grouping.expected.css new file mode 100644 index 00000000..a4e34d0e --- /dev/null +++ b/test/fixtures/layer-rule-grouping.expected.css @@ -0,0 +1,88 @@ +@media screen { + +rule-one {} + +rule-two {} +} + +@media screen and (min-width: 50rem) { + rule-three {} + + rule-four {} +} + +@media screen { + +rule-five {} + +rule-six {} +} + +@layer import-anon-layer-0 { + +rule-one {} + +rule-two {} +} + +@layer import-anon-layer-0 { + +@media (min-width: 50rem) { + rule-three {} + + rule-four {} +} +} + +@layer import-anon-layer-0 { + +rule-five {} + +rule-six {} +} + +@layer import-anon-layer-1 { + +rule-one {} + +rule-two {} +} + +@layer import-anon-layer-1 { + +@media (min-width: 50rem) { + rule-three {} + + rule-four {} +} +} + +@layer import-anon-layer-1 { + +rule-five {} + +rule-six {} +} + +@layer named { + +rule-one {} + +rule-two {} +} + +@layer named { + +@media (min-width: 50rem) { + rule-three {} + + rule-four {} +} +} + +@layer named { + +rule-five {} + +rule-six {} +} diff --git a/test/fixtures/layer.css b/test/fixtures/layer.css index 1d1c59a4..b9894dab 100644 --- a/test/fixtures/layer.css +++ b/test/fixtures/layer.css @@ -1,9 +1,9 @@ @layer layer-alpha, layer-beta.one; -@import "foo.css" layer; +@import "foo.css" layer(foo); @import 'bar.css' layer(bar); @import 'bar.css' layer(bar) level-1 and level-2; -@import url(baz.css) layer; +@import url(baz.css) layer(baz); @import url("foobar.css") layer(foobar); @import url("foo-layered.css") layer(foo-layered); diff --git a/test/fixtures/layer.expected.css b/test/fixtures/layer.expected.css index 0a189869..bc73a558 100644 --- a/test/fixtures/layer.expected.css +++ b/test/fixtures/layer.expected.css @@ -1,5 +1,5 @@ @layer layer-alpha, layer-beta.one; -@layer{ +@layer foo{ foo{} } @layer bar{ @@ -10,7 +10,7 @@ bar{} bar{} } } -@layer{ +@layer baz{ baz{} } @layer foobar{ diff --git a/test/layer.js b/test/layer.js index 1b50f4c5..69d27afd 100644 --- a/test/layer.js +++ b/test/layer.js @@ -1,8 +1,80 @@ "use strict" // external tooling const test = require("ava") +const postcss = require("postcss") + +const crypto = require("crypto") +const cwd = process.cwd() + +// plugin +const atImport = require("..") // internal tooling const checkFixture = require("./helpers/check-fixture") test("should resolve layers of import statements", checkFixture, "layer") + +test( + "should correctly wrap imported at rules in layers", + checkFixture, + "layer-import-atrules", + { + nameLayer: hashLayerName, + } +) + +test( + "should correctly wrap imported at rules in anonymous layers", + checkFixture, + "layer-import-atrules-anonymous", + { + nameLayer: hashLayerName, + } +) + +test("should group rules", checkFixture, "layer-rule-grouping", { + nameLayer: hashLayerName, +}) + +test("should pass the root file name to the nameLayer function", t => { + return postcss() + .use(atImport({ path: "test/fixtures/imports", nameLayer: hashLayerName })) + .process('@import "foo.css" layer;', { from: "layer.css" }) + .then(result => { + t.is(result.css, `@layer import-anon-layer-52ff1597784c{\nfoo{}\n}`) + }) +}) + +test("should error when value is not a function", t => { + return postcss() + .use(atImport({ nameLayer: "not a function" })) + .process("", { from: undefined }) + .catch(error => t.is(error.message, "nameLayer option must be a function")) +}) + +test("should throw when using anonymous layers without the nameLayer plugin option", t => { + return postcss() + .use(atImport({ path: "test/fixtures/imports" })) + .process('@import "foo.css" layer;', { from: undefined }) + .catch(err => { + t.is( + err.message, + 'postcss-import: :1:1: When using anonymous layers in @import you must also set the "nameLayer" plugin option' + ) + }) +}) + +function hashLayerName(index, rootFilename) { + if (!rootFilename) { + return `import-anon-layer-${index}` + } + + // A stable, deterministic and unique layer name: + // - layer index + // - relative rootFilename to current working directory + return `import-anon-layer-${crypto + .createHash("sha256") + .update(`${index}-${rootFilename.split(cwd)[1]}`) + .digest("hex") + .slice(0, 12)}` +}