diff --git a/lib/container.js b/lib/container.js index 25ab486b3..42b1b417a 100644 --- a/lib/container.js +++ b/lib/container.js @@ -310,7 +310,7 @@ class Container extends Node { for (let i of nodes) { if (i.parent) i.parent.removeChild(i, 'ignore') } - } else if (nodes.type === 'root') { + } else if (nodes.type === 'root' && this.type !== 'document') { nodes = nodes.nodes.slice(0) for (let i of nodes) { if (i.parent) i.parent.removeChild(i, 'ignore') diff --git a/lib/document.d.ts b/lib/document.d.ts new file mode 100644 index 000000000..7b05fc8a8 --- /dev/null +++ b/lib/document.d.ts @@ -0,0 +1,264 @@ +import Container, { ContainerProps } from './container.js' +import { ProcessOptions } from './postcss.js' +import Result from './result.js' +import Root, { RootProps } from './root.js' + +export interface DocumentProps extends ContainerProps { + nodes?: Root[] +} + +type ChildNode = Root +type ChildProps = RootProps + +/** + * Represents a file and contains all its parsed nodes. + * + * ```js + * const document = postcss.parse('') + * document.type //=> 'document' + * document.nodes.length //=> 2 + * ``` + */ +export default class Document extends Container { + type: 'document' + parent: undefined + + constructor(defaults?: DocumentProps) + + /** + * Returns a `Result` instance representing the document’s CSS roots. + * + * ```js + * const root1 = postcss.parse(css1, { from: 'a.css' }) + * const root2 = postcss.parse(css2, { from: 'b.css' }) + * const document = postcss.document() + * document.append(root1) + * document.append(root2) + * const result = document.toResult({ to: 'all.css', map: true }) + * ``` + * + * @param opts Options. + * @return Result with current document’s CSS. + */ + toResult(options?: ProcessOptions): Result + + /** + * An array containing the container’s children. + * + * ```js + * const root = postcss.parse('a { color: black }') + * root.nodes.length //=> 1 + * root.nodes[0].selector //=> 'a' + * root.nodes[0].nodes[0].prop //=> 'color' + * ``` + */ + // @ts-expect-error + nodes: Root[] + + /** + * The container’s first child. + * + * ```js + * rule.first === rules.nodes[0] + * ``` + */ + // @ts-expect-error + get first(): ChildNode | undefined + + /** + * The container’s last child. + * + * ```js + * rule.last === rule.nodes[rule.nodes.length - 1] + * ``` + */ + // @ts-expect-error + get last(): ChildNode | undefined + + /** + * Iterates through the container’s immediate children, + * calling `callback` for each child. + * + * Returning `false` in the callback will break iteration. + * + * This method only iterates through the container’s immediate children. + * If you need to recursively iterate through all the container’s descendant + * nodes, use `Container#walk`. + * + * Unlike the for `{}`-cycle or `Array#forEach` this iterator is safe + * if you are mutating the array of child nodes during iteration. + * PostCSS will adjust the current index to match the mutations. + * + * ```js + * const root = postcss.parse('a { color: black; z-index: 1 }') + * const rule = root.first + * + * for (const decl of rule.nodes) { + * decl.cloneBefore({ prop: '-webkit-' + decl.prop }) + * // Cycle will be infinite, because cloneBefore moves the current node + * // to the next index + * } + * + * rule.each(decl => { + * decl.cloneBefore({ prop: '-webkit-' + decl.prop }) + * // Will be executed only for color and z-index + * }) + * ``` + * + * @param callback Iterator receives each node and index. + * @return Returns `false` if iteration was broke. + */ + // @ts-expect-error + each( + callback: (node: ChildNode, index: number) => false | void + ): false | undefined + + /** + * Inserts new nodes to the end of the container. + * + * ```js + * const decl1 = new Declaration({ prop: 'color', value: 'black' }) + * const decl2 = new Declaration({ prop: 'background-color', value: 'white' }) + * rule.append(decl1, decl2) + * + * root.append({ name: 'charset', params: '"UTF-8"' }) // at-rule + * root.append({ selector: 'a' }) // rule + * rule.append({ prop: 'color', value: 'black' }) // declaration + * rule.append({ text: 'Comment' }) // comment + * + * root.append('a {}') + * root.first.append('color: black; z-index: 1') + * ``` + * + * @param nodes New nodes. + * @return This node for methods chain. + */ + append(...nodes: (ChildProps | ChildProps[])[]): this + + /** + * Inserts new nodes to the start of the container. + * + * ```js + * const decl1 = new Declaration({ prop: 'color', value: 'black' }) + * const decl2 = new Declaration({ prop: 'background-color', value: 'white' }) + * rule.prepend(decl1, decl2) + * + * root.append({ name: 'charset', params: '"UTF-8"' }) // at-rule + * root.append({ selector: 'a' }) // rule + * rule.append({ prop: 'color', value: 'black' }) // declaration + * rule.append({ text: 'Comment' }) // comment + * + * root.append('a {}') + * root.first.append('color: black; z-index: 1') + * ``` + * + * @param nodes New nodes. + * @return This node for methods chain. + */ + prepend(...nodes: (ChildProps | ChildProps[])[]): this + + /** + * Add child to the end of the node. + * + * ```js + * rule.push(new Declaration({ prop: 'color', value: 'black' })) + * ``` + * + * @param child New node. + * @return This node for methods chain. + */ + // @ts-expect-error + push(child: ChildNode): this + + /** + * Insert new node before old node within the container. + * + * ```js + * rule.insertBefore(decl, decl.clone({ prop: '-webkit-' + decl.prop })) + * ``` + * + * @param oldNode Child or child’s index. + * @param newNode New node. + * @return This node for methods chain. + */ + // @ts-expect-error + insertBefore( + oldNode: ChildNode | number, + newNode: ChildNode | ChildProps | ChildNode[] | ChildProps[] + ): this + + /** + * Insert new node after old node within the container. + * + * @param oldNode Child or child’s index. + * @param newNode New node. + * @return This node for methods chain. + */ + // @ts-expect-error + insertAfter( + oldNode: ChildNode | number, + newNode: ChildNode | ChildProps | ChildNode[] | ChildProps[] + ): this + + /** + * Removes node from the container and cleans the parent properties + * from the node and its children. + * + * ```js + * rule.nodes.length //=> 5 + * rule.removeChild(decl) + * rule.nodes.length //=> 4 + * decl.parent //=> undefined + * ``` + * + * @param child Child or child’s index. + * @return This node for methods chain. + */ + // @ts-expect-error + removeChild(child: ChildNode | number): this + + /** + * Returns `true` if callback returns `true` + * for all of the container’s children. + * + * ```js + * const noPrefixes = rule.every(i => i.prop[0] !== '-') + * ``` + * + * @param condition Iterator returns true or false. + * @return Is every child pass condition. + */ + // @ts-expect-error + every( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean + + /** + * Returns `true` if callback returns `true` for (at least) one + * of the container’s children. + * + * ```js + * const hasPrefix = rule.some(i => i.prop[0] === '-') + * ``` + * + * @param condition Iterator returns true or false. + * @return Is some child pass condition. + */ + // @ts-expect-error + some( + condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + ): boolean + + /** + * Returns a `child`’s index within the `Container#nodes` array. + * + * ```js + * rule.index( rule.nodes[2] ) //=> 2 + * ``` + * + * @param child Child of the current container. + * @return Child index. + */ + // @ts-expect-error + index(child: ChildNode | number): number +} diff --git a/lib/document.js b/lib/document.js new file mode 100644 index 000000000..44689917f --- /dev/null +++ b/lib/document.js @@ -0,0 +1,33 @@ +'use strict' + +let Container = require('./container') + +let LazyResult, Processor + +class Document extends Container { + constructor(defaults) { + // type needs to be passed to super, otherwise child roots won't be normalized correctly + super({ type: 'document', ...defaults }) + + if (!this.nodes) { + this.nodes = [] + } + } + + toResult(opts = {}) { + let lazy = new LazyResult(new Processor(), this, opts) + + return lazy.stringify() + } +} + +Document.registerLazyResult = dependant => { + LazyResult = dependant +} + +Document.registerProcessor = dependant => { + Processor = dependant +} + +module.exports = Document +Document.default = Document diff --git a/lib/lazy-result.js b/lib/lazy-result.js index 6d9674969..d5503f335 100644 --- a/lib/lazy-result.js +++ b/lib/lazy-result.js @@ -7,8 +7,10 @@ let warnOnce = require('./warn-once') let Result = require('./result') let parse = require('./parse') let Root = require('./root') +let Document = require('./document') const TYPE_TO_CLASS_NAME = { + document: 'Document', root: 'Root', atrule: 'AtRule', rule: 'Rule', @@ -20,6 +22,7 @@ const PLUGIN_PROPS = { postcssPlugin: true, prepare: true, Once: true, + Document: true, Root: true, Declaration: true, Rule: true, @@ -30,6 +33,7 @@ const PLUGIN_PROPS = { AtRuleExit: true, CommentExit: true, RootExit: true, + DocumentExit: true, OnceExit: true } @@ -73,7 +77,9 @@ function getEvents(node) { function toStack(node) { let events - if (node.type === 'root') { + if (node.type === 'document') { + events = ['Document', CHILDREN, 'DocumentExit'] + } else if (node.type === 'root') { events = ['Root', CHILDREN, 'RootExit'] } else { events = getEvents(node) @@ -103,7 +109,11 @@ class LazyResult { this.processed = false let root - if (typeof css === 'object' && css !== null && css.type === 'root') { + if ( + typeof css === 'object' && + css !== null && + (css.type === 'root' || css.type === 'document') + ) { root = cleanMarks(css) } else if (css instanceof LazyResult || css instanceof Result) { root = cleanMarks(css.root) @@ -231,7 +241,13 @@ class LazyResult { this.walkSync(root) } if (this.listeners.OnceExit) { - this.visitSync(this.listeners.OnceExit, root) + if (root.type === 'document') { + for (let subRoot of root.nodes) { + this.visitSync(this.listeners.OnceExit, subRoot) + } + } else { + this.visitSync(this.listeners.OnceExit, root) + } } } @@ -287,7 +303,9 @@ class LazyResult { } catch (e) { throw this.handleError(e, node.proxyOf) } - if (node.type !== 'root' && !node.parent) return true + if (node.type !== 'root' && node.type !== 'document' && !node.parent) { + return true + } if (isPromise(promise)) { throw this.getAsyncError() } @@ -298,6 +316,18 @@ class LazyResult { this.result.lastPlugin = plugin try { if (typeof plugin === 'object' && plugin.Once) { + if (this.result.root.type === 'document') { + let roots = this.result.root.nodes.map(root => + plugin.Once(root, this.helpers) + ) + + if (isPromise(roots[0])) { + return Promise.all(roots) + } + + return roots + } + return plugin.Once(this.result.root, this.helpers) } else if (typeof plugin === 'function') { return plugin(this.result.root, this.result) @@ -385,7 +415,15 @@ class LazyResult { for (let [plugin, visitor] of this.listeners.OnceExit) { this.result.lastPlugin = plugin try { - await visitor(root, this.helpers) + if (root.type === 'document') { + let roots = root.nodes.map(subRoot => + visitor(subRoot, this.helpers) + ) + + await Promise.all(roots) + } else { + await visitor(root, this.helpers) + } } catch (e) { throw this.handleError(e) } @@ -439,7 +477,7 @@ class LazyResult { let visit = stack[stack.length - 1] let { node, visitors } = visit - if (node.type !== 'root' && !node.parent) { + if (node.type !== 'root' && node.type !== 'document' && !node.parent) { stack.pop() return } @@ -501,3 +539,4 @@ module.exports = LazyResult LazyResult.default = LazyResult Root.registerLazyResult(LazyResult) +Document.registerLazyResult(LazyResult) diff --git a/lib/node.d.ts b/lib/node.d.ts index 466058506..e9773913d 100644 --- a/lib/node.d.ts +++ b/lib/node.d.ts @@ -9,6 +9,7 @@ import Container from './container.js' import Result from './result.js' import Input from './input.js' import Root from './root.js' +import Document from './document.js' export type ChildNode = AtRule | Rule | Declaration | Comment @@ -381,6 +382,17 @@ export default abstract class Node { */ root(): Root + /** + * Finds the Document instance of the node’s tree. + * + * ```js + * document.nodes[0].nodes[0].document() === document + * ``` + * + * @return Document parent. + */ + document(): Document + /** * Returns a `Node#raws` value. If the node is missing * the code style property (because the node was manually built or cloned), diff --git a/lib/node.js b/lib/node.js index 5ea7de8e1..443281932 100644 --- a/lib/node.js +++ b/lib/node.js @@ -151,7 +151,18 @@ class Node { root() { let result = this - while (result.parent) result = result.parent + while (result.parent && result.parent.type !== 'document') { + result = result.parent + } + return result + } + + document() { + let result = this + while (result.parent) { + result = result.parent + } + return result } diff --git a/lib/postcss.d.ts b/lib/postcss.d.ts index dd1f116a9..980707ecb 100644 --- a/lib/postcss.d.ts +++ b/lib/postcss.d.ts @@ -11,6 +11,7 @@ import Node, { } from './node.js' import Declaration, { DeclarationProps } from './declaration.js' import Root, { RootProps } from './root.js' +import Document, { DocumentProps } from './document.js' import Comment, { CommentProps } from './comment.js' import AtRule, { AtRuleProps } from './at-rule.js' import Result, { Message } from './result.js' @@ -50,6 +51,7 @@ export { AtRule, Rule, Root, + Document, Result, LazyResult, Input @@ -74,6 +76,20 @@ type CommentProcessor = ( ) => Promise | void interface Processors { + /** + * Will be called on `Document` node. + * + * Will be called again on children changes. + */ + Document?: RootProcessor + + /** + * Will be called on `Document` node, when all children will be processed. + * + * Will be called again on children changes. + */ + DocumentExit?: RootProcessor + /** * Will be called on `Root` node once. */ @@ -347,7 +363,7 @@ export interface Postcss { stringify: Stringifier /** - * Parses source css and returns a new `Root` node, + * Parses source css and returns a new `Root` or `Document` node, * which contains the source CSS nodes. * * ```js @@ -415,6 +431,14 @@ export interface Postcss { */ root(defaults?: RootProps): Root + /** + * Creates a new `Document` node. + * + * @param defaults Properties for the new node. + * @return New document node. + */ + document(defaults?: DocumentProps): Document + CssSyntaxError: typeof CssSyntaxError Declaration: typeof Declaration Container: typeof Container diff --git a/lib/postcss.js b/lib/postcss.js index ce177a814..b03bb1803 100644 --- a/lib/postcss.js +++ b/lib/postcss.js @@ -7,6 +7,7 @@ let Container = require('./container') let Processor = require('./processor') let stringify = require('./stringify') let fromJSON = require('./fromJSON') +let Document = require('./document') let Warning = require('./warning') let Comment = require('./comment') let AtRule = require('./at-rule') @@ -73,10 +74,12 @@ postcss.atRule = defaults => new AtRule(defaults) postcss.decl = defaults => new Declaration(defaults) postcss.rule = defaults => new Rule(defaults) postcss.root = defaults => new Root(defaults) +postcss.document = defaults => new Document(defaults) postcss.CssSyntaxError = CssSyntaxError postcss.Declaration = Declaration postcss.Container = Container +postcss.Document = Document postcss.Comment = Comment postcss.Warning = Warning postcss.AtRule = AtRule diff --git a/lib/postcss.mjs b/lib/postcss.mjs index e6360c495..a8b633830 100644 --- a/lib/postcss.mjs +++ b/lib/postcss.mjs @@ -13,10 +13,12 @@ export const atRule = postcss.atRule export const rule = postcss.rule export const decl = postcss.decl export const root = postcss.root +export const document = postcss.document export const CssSyntaxError = postcss.CssSyntaxError export const Declaration = postcss.Declaration export const Container = postcss.Container +export const Document = postcss.Document export const Comment = postcss.Comment export const Warning = postcss.Warning export const AtRule = postcss.AtRule diff --git a/lib/processor.js b/lib/processor.js index 7fea35703..5d08513d6 100644 --- a/lib/processor.js +++ b/lib/processor.js @@ -2,6 +2,7 @@ let LazyResult = require('./lazy-result') let Root = require('./root') +let Document = require('./document') class Processor { constructor(plugins = []) { @@ -68,3 +69,4 @@ module.exports = Processor Processor.default = Processor Root.registerProcessor(Processor) +Document.registerProcessor(Processor) diff --git a/lib/root.d.ts b/lib/root.d.ts index 37d948f71..d8ee39531 100644 --- a/lib/root.d.ts +++ b/lib/root.d.ts @@ -3,6 +3,11 @@ import { ProcessOptions } from './postcss.js' import Result from './result.js' interface RootRaws { + /** + * The symbols before the first child to the start of file. + */ + before?: string + /** * The space symbols after the last child to the end of file. */ @@ -29,7 +34,7 @@ export interface RootProps extends ContainerProps { */ export default class Root extends Container { type: 'root' - parent: undefined + // parent: Document | undefined raws: RootRaws constructor(defaults?: RootProps) diff --git a/lib/stringifier.js b/lib/stringifier.js index a34394640..81e125182 100644 --- a/lib/stringifier.js +++ b/lib/stringifier.js @@ -37,6 +37,10 @@ class Stringifier { this[node.type](node, semicolon) } + document(node) { + this.body(node) + } + root(node) { this.body(node) if (node.raws.after) this.builder(node.raws.after) @@ -129,11 +133,16 @@ class Stringifier { let parent = node.parent - // Hack for first rule in CSS if (detect === 'before') { + // Hack for first rule in CSS if (!parent || (parent.type === 'root' && parent.first === node)) { return '' } + + // `root` nodes in `document` should use only their own raws + if (parent && parent.type === 'document') { + return '' + } } // Floating child without parent diff --git a/test/document.test.ts b/test/document.test.ts new file mode 100644 index 000000000..0d1253043 --- /dev/null +++ b/test/document.test.ts @@ -0,0 +1,26 @@ +import { Result, parse } from '../lib/postcss.js' +import Document from '../lib/document.js' + +it('generates result without map', () => { + let root = parse('a {}') + let document = new Document() + + document.append(root) + + let result = document.toResult() + + expect(result instanceof Result).toBe(true) + expect(result.css).toBe('a {}') +}) + +it('generates result with map', () => { + let root = parse('a {}') + let document = new Document() + + document.append(root) + + let result = document.toResult({ map: true }) + + expect(result instanceof Result).toBe(true) + expect(result.css).toMatch(/a {}\n\/\*# sourceMappingURL=/) +}) diff --git a/test/node.test.ts b/test/node.test.ts index d60141408..780c175a1 100644 --- a/test/node.test.ts +++ b/test/node.test.ts @@ -9,7 +9,8 @@ import postcss, { Declaration, parse, Result, - Plugin + Plugin, + Document } from '../lib/postcss.js' function stringify(node: AnyNode, builder: (str: string) => void): void { @@ -321,6 +322,49 @@ it('root() returns self on root', () => { expect(rule.root()).toBe(rule) }) +it('root() returns root in document', () => { + let css = new Document({ nodes: [parse('@page{a{color:black}}')] }) + + let root = css.first as Root + let page = root.first as AtRule + let a = page.first as Rule + let color = a.first as Declaration + expect(color.root()).toBe(root) +}) + +it('root() on root in document returns same root', () => { + let document = new Document() + let root = new Root() + document.append(root) + + expect(document.first?.root()).toBe(root) +}) + +it('root() returns self on document', () => { + let document = new Document() + expect(document.root()).toBe(document) +}) + +it('document() returns document', () => { + let css = new Document({ nodes: [parse('@page{a{color:black}}')] }) + + let root = css.first as Root + let page = root.first as AtRule + let a = page.first as Rule + let color = a.first as Declaration + expect(color.document()).toBe(css) +}) + +it('document() returns self on root', () => { + let rule = new Rule({ selector: 'a' }) + expect(rule.document()).toBe(rule) +}) + +it('document() returns self on document', () => { + let document = new Document() + expect(document.document()).toBe(document) +}) + it('cleanRaws() cleans style recursivelly', () => { let css = parse('@page{a{color:black}}') css.cleanRaws() diff --git a/test/postcss.test.ts b/test/postcss.test.ts index 7dd35e2df..8d3dd4a2e 100644 --- a/test/postcss.test.ts +++ b/test/postcss.test.ts @@ -135,6 +135,30 @@ it('allows to build own CSS', () => { ) }) +it('allows to build own CSS with Document', () => { + let document = postcss.document() + let root = postcss.root({ raws: { after: '\n' } }) + let comment = postcss.comment({ text: 'Example' }) + let media = postcss.atRule({ name: 'media', params: 'screen' }) + let rule = postcss.rule({ selector: 'a' }) + let decl = postcss.decl({ prop: 'color', value: 'black' }) + + root.append(comment) + rule.append(decl) + media.append(rule) + root.append(media) + document.append(root) + + expect(document.toString()).toEqual( + '/* Example */\n' + + '@media screen {\n' + + ' a {\n' + + ' color: black\n' + + ' }\n' + + '}\n' + ) +}) + it('contains list module', () => { expect(postcss.list.space('a b')).toEqual(['a', 'b']) }) diff --git a/test/stringifier.test.js b/test/stringifier.test.js index f293bf2bf..c199ded1b 100644 --- a/test/stringifier.test.js +++ b/test/stringifier.test.js @@ -1,4 +1,4 @@ -let { Declaration, AtRule, Node, Root, Rule, parse } = require('..') +let { Declaration, AtRule, Node, Root, Rule, Document, parse } = require('..') let Stringifier = require('../lib/stringifier') let str @@ -172,3 +172,115 @@ it('handles nested roots', () => { expect(root.toString()).toEqual('@foo') }) + +it('handles root', () => { + let root = new Root() + root.append(new AtRule({ name: 'foo' })) + + let s = root.toString() + + expect(s).toEqual('@foo') +}) + +it('handles root with after', () => { + let root = new Root({ raws: { after: ' ' } }) + root.append(new AtRule({ name: 'foo' })) + + let s = root.toString() + + expect(s).toEqual('@foo ') +}) + +it('pass nodes to document', () => { + let root = new Root() + let document = new Document({ nodes: [root] }) + + expect(document.toString()).toEqual('') +}) + +it('handles document with one root', () => { + let root = new Root() + root.append(new AtRule({ name: 'foo' })) + + let document = new Document() + document.append(root) + + let s = document.toString() + + expect(s).toEqual('@foo') +}) + +it('handles document with one root and after raw', () => { + let document = new Document() + let root = new Root({ raws: { after: ' ' } }) + root.append(new AtRule({ name: 'foo' })) + document.append(root) + + let s = document.toString() + + expect(s).toEqual('@foo ') +}) + +it('handles document with one root and before raw', () => { + let document = new Document() + let root = new Root({ raws: { before: ' ' } }) + root.append(new AtRule({ name: 'foo' })) + document.append(root) + + let s = document.toString() + + expect(s).toEqual(' @foo') +}) + +it('handles document with one root and before and after', () => { + let document = new Document() + let root = new Root({ raws: { before: 'BEFORE', after: 'AFTER' } }) + root.append(new AtRule({ name: 'foo' })) + document.append(root) + + let s = document.toString() + + expect(s).toEqual('BEFORE@fooAFTER') +}) + +it('handles document with three roots without raws', () => { + let root1 = new Root() + root1.append(new AtRule({ name: 'foo' })) + + let root2 = new Root() + root2.append(new Rule({ selector: 'a' })) + + let root3 = new Root() + root3.append(new Declaration({ prop: 'color', value: 'black' })) + + let document = new Document() + document.append(root1) + document.append(root2) + document.append(root3) + + let s = document.toString() + + expect(s).toEqual('@fooa {}color: black') +}) + +it('handles document with three roots, with before and after raws', () => { + let root1 = new Root({ raws: { before: 'BEFORE_ONE', after: 'AFTER_ONE' } }) + root1.append(new Rule({ selector: 'a.one' })) + + let root2 = new Root({ raws: { after: 'AFTER_TWO' } }) + root2.append(new Rule({ selector: 'a.two' })) + + let root3 = new Root({ raws: { after: 'AFTER_THREE' } }) + root3.append(new Rule({ selector: 'a.three' })) + + let document = new Document() + document.append(root1) + document.append(root2) + document.append(root3) + + let s = document.toString() + + expect(s).toEqual( + 'BEFORE_ONEa.one {}AFTER_ONEa.two {}AFTER_TWOa.three {}AFTER_THREE' + ) +}) diff --git a/test/visitor.test.ts b/test/visitor.test.ts index 76102847c..5a45992ac 100644 --- a/test/visitor.test.ts +++ b/test/visitor.test.ts @@ -29,6 +29,9 @@ function buildVisitor(): [[string, string][], Plugin] { let visits: [string, string][] = [] let visitor: Plugin = { postcssPlugin: 'visitor', + Document(i) { + visits.push(['Document', `${i.nodes.length}`]) + }, Once(i) { visits.push(['Once', `${i.nodes.length}`]) }, @@ -64,6 +67,9 @@ function buildVisitor(): [[string, string][], Plugin] { }, OnceExit(i) { visits.push(['OnceExit', `${i.nodes.length}`]) + }, + DocumentExit(i) { + visits.push(['DocumentExit', `${i.nodes.length}`]) } } return [visits, visitor] @@ -255,9 +261,10 @@ it('works classic plugin replace-color', async () => { }) it('works visitor plugin will-change', async () => { - let { css } = postcss([ - willChangeVisitor - ]).process('.foo { will-change: transform; }', { from: 'a.css' }) + let { css } = postcss([willChangeVisitor]).process( + '.foo { will-change: transform; }', + { from: 'a.css' } + ) expect(css).toEqual( '.foo { backface-visibility: hidden; will-change: transform; }' ) @@ -276,11 +283,39 @@ it('works visitor plugin add-prop', async () => { ) }) +it('works visitor plugin add-prop in document with single root', async () => { + let document = postcss.document({ + nodes: [postcss.parse('.a{ color: red; } .b{ will-change: transform; }')] + }) + + let { css } = await postcss([addPropsVisitor]).process(document, { + from: 'a.css' + }) + expect(css).toEqual( + '.a{ will-change: transform; color: red; } ' + + '.b{ will-change: transform; }' + ) +}) + +it('works visitor plugin add-prop in document with two roots', async () => { + let document = postcss.document({ + nodes: [ + postcss.parse('.a{ color: red; }'), + postcss.parse('.b{ will-change: transform; }') + ] + }) + + let { css } = await postcss([addPropsVisitor]).process(document, { + from: 'a.css' + }) + expect(css).toEqual('.a{ color: red; }' + '.b{ will-change: transform; }') +}) + it('works with at-rule params', () => { - let { css } = postcss([ - replacePrintToMobile, - replaceScreenToPrint - ]).process('@media (screen) {}', { from: 'a.css' }) + let { css } = postcss([replacePrintToMobile, replaceScreenToPrint]).process( + '@media (screen) {}', + { from: 'a.css' } + ) expect(css).toEqual('@media (mobile) {}') }) @@ -338,6 +373,35 @@ it('work of three plug-ins; sequence 2', async () => { expect(css).toEqual(expectedThree) }) +const cssThreeDocument = postcss.document({ + nodes: [ + postcss.parse('.a{ color: red; }'), + postcss.parse('.b{ will-change: transform; }') + ] +}) + +const expectedThreeDocument = + '.a{ color: green; }' + + '.b{ backface-visibility: hidden; will-change: transform; }' + +it('work of three plug-ins in a document; sequence 1', async () => { + let { css } = await postcss([ + replaceColorGreenClassic, + willChangeVisitor, + addPropsVisitor + ]).process(cssThreeDocument, { from: 'a.css' }) + expect(css).toEqual(expectedThreeDocument) +}) + +it('work of three plug-ins in a document; sequence 2', async () => { + let { css } = await postcss([ + addPropsVisitor, + replaceColorGreenClassic, + willChangeVisitor + ]).process(cssThreeDocument, { from: 'a.css' }) + expect(css).toEqual(expectedThreeDocument) +}) + const cssThroughProps = '.a{color: yellow;}' const expectedThroughProps = '.a{color: red;}' @@ -586,6 +650,49 @@ it('passes helpers', async () => { await postcss([asyncPlugin]).process('a{}', { from: 'a.css' }) }) +it('passes helpers in a document', async () => { + function check(node: AnyNode, helpers: Helpers): void { + expect(helpers.result.messages).toEqual([]) + expect(typeof helpers.postcss).toEqual('function') + expect(helpers.comment().type).toEqual('comment') + expect(new helpers.Comment().type).toEqual('comment') + expect(helpers.list).toBe(postcss.list) + } + + let syncPlugin: Plugin = { + postcssPlugin: 'syncPlugin', + Once: check, + Rule: check, + RuleExit: check, + OnceExit: check + } + + let asyncPlugin: Plugin = { + postcssPlugin: 'syncPlugin', + async Once(node, helpers) { + await delay(1) + check(node, helpers) + }, + async Rule(node, helpers) { + await delay(1) + check(node, helpers) + }, + async OnceExit(node, helpers) { + await delay(1) + check(node, helpers) + } + } + + postcss([syncPlugin]).process( + postcss.document({ nodes: [postcss.parse('a{}')] }), + { from: 'a.css' } + ).css + await postcss([asyncPlugin]).process( + postcss.document({ nodes: [postcss.parse('a{}')] }), + { from: 'a.css' } + ) +}) + it('detects non-changed values', () => { let plugin: Plugin = { postcssPlugin: 'test', @@ -766,6 +873,56 @@ for (let type of ['sync', 'async']) { ) }) + it(`walks ${type} through tree in a document`, async () => { + let document = postcss.document({ + nodes: [ + postcss.parse(`@media screen { + body { + /* comment */ + background: white; + padding: 10px; + } + a { + color: blue; + } + }`) + ] + }) + + let [visits, visitor] = buildVisitor() + let processor = postcss([visitor]).process(document, { from: 'a.css' }) + if (type === 'sync') { + processor.css + } else { + await processor + } + + expect(addIndex(visits)).toEqual( + addIndex([ + ['Once', '1'], + ['Document', '1'], + ['Root', '1'], + ['AtRule', 'media'], + ['Rule', 'body'], + ['Comment', 'comment'], + ['CommentExit', 'comment'], + ['Declaration', 'background: white'], + ['DeclarationExit', 'background: white'], + ['Declaration', 'padding: 10px'], + ['DeclarationExit', 'padding: 10px'], + ['RuleExit', 'body'], + ['Rule', 'a'], + ['Declaration', 'color: blue'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', 'a'], + ['AtRuleExit', 'media'], + ['RootExit', '1'], + ['DocumentExit', '1'], + ['OnceExit', '1'] + ]) + ) + }) + it(`walks ${type} during transformations`, async () => { let [visits, visitor] = buildVisitor() let result = postcss([ @@ -882,6 +1039,136 @@ for (let type of ['sync', 'async']) { ) }) + it(`walks ${type} during transformations in a document`, async () => { + let document = postcss.document({ + nodes: [ + postcss.parse( + `.first { + color: red; + } + @define-mixin { + b { + color: red; + } + } + a { + color: red; + } + @media (screen) { + @insert-first; + } + .foo { + background: red; + } + @apply-mixin;` + ) + ] + }) + + let [visits, visitor] = buildVisitor() + let result = postcss([ + visitor, + redToGreen, + greenToBlue, + mixins, + fooToBar, + insertFirst + ]).process(document, { from: 'a.css' }) + let output + if (type === 'sync') { + output = result.css + } else { + output = (await result).css + } + + expect(output).toEqual( + `a { + color: blue; + } + @media (screen) {.first { + color: blue; + } + } + .bar { + background: red; + } + b { + color: blue; + }` + ) + expect(addIndex(visits)).toEqual( + addIndex([ + ['Once', '6'], + ['Document', '1'], + ['Root', '6'], + ['Rule', '.first'], + ['Declaration', 'color: red'], + ['DeclarationExit', 'color: green'], + ['RuleExit', '.first'], + ['AtRule', 'define-mixin'], + ['Rule', 'a'], + ['Declaration', 'color: red'], + ['DeclarationExit', 'color: green'], + ['RuleExit', 'a'], + ['AtRule', 'media'], + ['AtRule', 'insert-first'], + ['AtRuleExit', 'media'], + ['Rule', '.foo'], + ['Declaration', 'background: red'], + ['DeclarationExit', 'background: red'], + ['RuleExit', '.bar'], + ['AtRule', 'apply-mixin'], + ['RootExit', '4'], + ['DocumentExit', '1'], + ['Document', '1'], + ['Root', '4'], + ['Rule', 'a'], + ['Declaration', 'color: green'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', 'a'], + ['AtRule', 'media'], + ['Rule', '.first'], + ['Declaration', 'color: green'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', '.first'], + ['AtRuleExit', 'media'], + ['Rule', 'b'], + ['Declaration', 'color: red'], + ['DeclarationExit', 'color: green'], + ['RuleExit', 'b'], + ['RootExit', '4'], + ['DocumentExit', '1'], + ['Document', '1'], + ['Root', '4'], + ['Rule', 'a'], + ['Declaration', 'color: blue'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', 'a'], + ['AtRule', 'media'], + ['Rule', '.first'], + ['Declaration', 'color: blue'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', '.first'], + ['AtRuleExit', 'media'], + ['Rule', 'b'], + ['Declaration', 'color: green'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', 'b'], + ['RootExit', '4'], + ['DocumentExit', '1'], + ['Document', '1'], + ['Root', '4'], + ['Rule', 'b'], + ['Declaration', 'color: blue'], + ['DeclarationExit', 'color: blue'], + ['RuleExit', 'b'], + ['RootExit', '4'], + ['DocumentExit', '1'], + ['OnceExit', '4'] + ]) + ) + }) + it(`has ${type} property and at-rule name filters`, async () => { let filteredDecls: string[] = [] let allDecls: string[] = [] @@ -918,9 +1205,7 @@ for (let type of ['sync', 'async']) { } } - let result = postcss([ - scanner - ]).process( + let result = postcss([scanner]).process( `@charset "UTF-8"; @media (screen) { COLOR: black; z-index: 1 }`, { from: 'a.css' } ) @@ -938,6 +1223,65 @@ for (let type of ['sync', 'async']) { expect(allAtRules).toEqual(['charset', 'media']) }) + it(`has ${type} property and at-rule name filters in a document`, async () => { + let filteredDecls: string[] = [] + let allDecls: string[] = [] + let filteredAtRules: string[] = [] + let allAtRules: string[] = [] + let allExits: string[] = [] + let filteredExits: string[] = [] + + let scanner: Plugin = { + postcssPlugin: 'test', + Declaration: { + 'color': decl => { + filteredDecls.push(decl.prop) + }, + '*': decl => { + allDecls.push(decl.prop) + } + }, + DeclarationExit: { + 'color': decl => { + filteredExits.push(decl.prop) + }, + '*': decl => { + allExits.push(decl.prop) + } + }, + AtRule: { + 'media': atRule => { + filteredAtRules.push(atRule.name) + }, + '*': atRule => { + allAtRules.push(atRule.name) + } + } + } + + let document = postcss.document({ + nodes: [ + postcss.parse( + `@charset "UTF-8"; @media (screen) { COLOR: black; z-index: 1 }` + ) + ] + }) + + let result = postcss([scanner]).process(document, { from: 'a.css' }) + if (type === 'sync') { + result.css + } else { + await result + } + + expect(filteredDecls).toEqual(['COLOR']) + expect(allDecls).toEqual(['COLOR', 'z-index']) + expect(filteredExits).toEqual(['COLOR']) + expect(allExits).toEqual(['COLOR', 'z-index']) + expect(filteredAtRules).toEqual(['media']) + expect(allAtRules).toEqual(['charset', 'media']) + }) + it(`has ${type} OnceExit listener`, async () => { let rootExit = 0 let OnceExit = 0 @@ -966,6 +1310,82 @@ for (let type of ['sync', 'async']) { expect(rootExit).toBe(2) expect(OnceExit).toBe(1) }) + + it(`has ${type} OnceExit listener in a document with one root`, async () => { + let RootExit = 0 + let OnceExit = 0 + let DocumentExit = 0 + + let plugin: Plugin = { + postcssPlugin: 'test', + Rule(rule) { + rule.remove() + }, + RootExit() { + RootExit += 1 + }, + DocumentExit() { + DocumentExit += 1 + }, + OnceExit() { + OnceExit += 1 + } + } + + let document = postcss.document({ + nodes: [postcss.parse('a{}')] + }) + + let result = postcss([plugin]).process(document, { from: 'a.css' }) + + if (type === 'sync') { + result.css + } else { + await result + } + + expect(RootExit).toBe(2) + expect(DocumentExit).toBe(2) + expect(OnceExit).toBe(1) + }) + + it(`has ${type} OnceExit listener in a document with two roots`, async () => { + let RootExit = 0 + let OnceExit = 0 + let DocumentExit = 0 + + let plugin: Plugin = { + postcssPlugin: 'test', + Rule(rule) { + rule.remove() + }, + RootExit() { + RootExit += 1 + }, + DocumentExit() { + DocumentExit += 1 + }, + OnceExit() { + OnceExit += 1 + } + } + + let document = postcss.document({ + nodes: [postcss.parse('a{}'), postcss.parse('b{}')] + }) + + let result = postcss([plugin]).process(document, { from: 'a.css' }) + + if (type === 'sync') { + result.css + } else { + await result + } + + expect(RootExit).toBe(4) + expect(DocumentExit).toBe(2) + expect(OnceExit).toBe(2) // 2 roots === 2 OnceExit + }) } it('throws error from async OnceExit', async () => { @@ -1009,6 +1429,28 @@ it('rescan Root in another processor', () => { ]) }) +it('rescan Root in another processor in a document', () => { + let [visits, visitor] = buildVisitor() + let root = postcss([visitor]).process('a{z-index:1}', { from: 'a.css' }).root + let document = postcss.document({ nodes: [root] }) + + visits.splice(0, visits.length) + postcss([visitor]).process(document, { from: 'a.css' }).root + + expect(visits).toEqual([ + ['Once', '1'], + ['Document', '1'], + ['Root', '1'], + ['Rule', 'a'], + ['Declaration', 'z-index: 1'], + ['DeclarationExit', 'z-index: 1'], + ['RuleExit', 'a'], + ['RootExit', '1'], + ['DocumentExit', '1'], + ['OnceExit', '1'] + ]) +}) + it('marks cleaned nodes as dirty on moving', () => { let mover: Plugin = { postcssPlugin: 'mover', @@ -1046,3 +1488,49 @@ it('marks cleaned nodes as dirty on moving', () => { ['OnceExit', '1'] ]) }) + +it('marks cleaned nodes as dirty on moving in a document', () => { + let mover: Plugin = { + postcssPlugin: 'mover', + Rule(rule) { + if (rule.selector === 'b') { + let a = rule.prev() + if (a) rule.append(a) + } + } + } + let [visits, visitor] = buildVisitor() + + let document = postcss.document({ + nodes: [postcss.parse('a { color: black } b { }')] + }) + + postcss([mover, visitor]).process(document, { + from: 'a.css' + }).root + + expect(visits).toEqual([ + ['Once', '2'], + ['Document', '1'], + ['Root', '2'], + ['Rule', 'a'], + ['Declaration', 'color: black'], + ['DeclarationExit', 'color: black'], + ['RuleExit', 'a'], + ['Rule', 'b'], + ['Rule', 'a'], + ['Declaration', 'color: black'], + ['DeclarationExit', 'color: black'], + ['RuleExit', 'a'], + ['RuleExit', 'b'], + ['RootExit', '1'], + ['DocumentExit', '1'], + ['Document', '1'], + ['Root', '1'], + ['Rule', 'b'], + ['RuleExit', 'b'], + ['RootExit', '1'], + ['DocumentExit', '1'], + ['OnceExit', '1'] + ]) +})