diff --git a/docs/syntax.md b/docs/syntax.md index 88716022b..01b81ef7c 100644 --- a/docs/syntax.md +++ b/docs/syntax.md @@ -39,7 +39,7 @@ A good example of a parser is [Safe Parser], which parses malformed/broken CSS. Because there is no point to generate broken output, this package only provides a parser. -The parser API is a function which receives a string & returns a [`Root`] node. +The parser API is a function which receives a string & returns a [`Root`] or [`Document`] node. The second argument is a function which receives an object with PostCSS options. ```js @@ -54,6 +54,7 @@ module.exports = function parse (css, opts) { [Safe Parser]: https://github.com/postcss/postcss-safe-parser [`Root`]: https://postcss.org/api/#root +[`Document`]: https://postcss.org/api/#document ### Main Theory @@ -170,7 +171,7 @@ The Stringifier API is little bit more complicated, than the parser API. PostCSS generates a source map, so a stringifier can’t just return a string. It must link every substring with its source node. -A Stringifier is a function which receives [`Root`] node and builder callback. +A Stringifier is a function which receives [`Root`] or [`Document`] node and builder callback. Then it calls builder with every node’s string and node instance. ```js diff --git a/lib/at-rule.d.ts b/lib/at-rule.d.ts index 6e988071d..09e1f1733 100644 --- a/lib/at-rule.d.ts +++ b/lib/at-rule.d.ts @@ -72,6 +72,7 @@ export interface AtRuleProps extends ContainerProps { */ export default class AtRule extends Container { type: 'atrule' + parent: Container | undefined raws: AtRuleRaws /** diff --git a/lib/comment.d.ts b/lib/comment.d.ts index f9109906f..1d4fd459a 100644 --- a/lib/comment.d.ts +++ b/lib/comment.d.ts @@ -1,3 +1,4 @@ +import Container from './container.js' import Node, { NodeProps } from './node.js' interface CommentRaws { @@ -39,6 +40,7 @@ export interface CommentProps extends NodeProps { */ export default class Comment extends Node { type: 'comment' + parent: Container | undefined raws: CommentRaws /** diff --git a/lib/container.d.ts b/lib/container.d.ts index c69f84a15..3005a1614 100644 --- a/lib/container.d.ts +++ b/lib/container.d.ts @@ -27,7 +27,9 @@ export interface ContainerProps extends NodeProps { * Note that all containers can store any content. If you write a rule inside * a rule, PostCSS will parse it. */ -export default abstract class Container extends Node { +export default abstract class Container< + Child extends Node = ChildNode +> extends Node { /** * An array containing the container’s children. * @@ -38,7 +40,7 @@ export default abstract class Container extends Node { * root.nodes[0].nodes[0].prop //=> 'color' * ``` */ - nodes: ChildNode[] + nodes: Child[] /** * The container’s first child. @@ -47,7 +49,7 @@ export default abstract class Container extends Node { * rule.first === rules.nodes[0] * ``` */ - get first(): ChildNode | undefined + get first(): Child | undefined /** * The container’s last child. @@ -56,7 +58,7 @@ export default abstract class Container extends Node { * rule.last === rule.nodes[rule.nodes.length - 1] * ``` */ - get last(): ChildNode | undefined + get last(): Child | undefined /** * Iterates through the container’s immediate children, @@ -92,7 +94,7 @@ export default abstract class Container extends Node { * @return Returns `false` if iteration was broke. */ each( - callback: (node: ChildNode, index: number) => false | void + callback: (node: Child, index: number) => false | void ): false | undefined /** @@ -304,7 +306,7 @@ export default abstract class Container extends Node { * @param child New node. * @return This node for methods chain. */ - push(child: ChildNode): this + push(child: Child): this /** * Insert new node before old node within the container. @@ -318,14 +320,8 @@ export default abstract class Container extends Node { * @return This node for methods chain. */ insertBefore( - oldNode: ChildNode | number, - newNode: - | ChildNode - | ChildProps - | string - | ChildNode[] - | ChildProps[] - | string[] + oldNode: Child | number, + newNode: Child | ChildProps | string | Child[] | ChildProps[] | string[] ): this /** @@ -336,14 +332,8 @@ export default abstract class Container extends Node { * @return This node for methods chain. */ insertAfter( - oldNode: ChildNode | number, - newNode: - | ChildNode - | ChildProps - | string - | ChildNode[] - | ChildProps[] - | string[] + oldNode: Child | number, + newNode: Child | ChildProps | string | Child[] | ChildProps[] | string[] ): this /** @@ -360,7 +350,7 @@ export default abstract class Container extends Node { * @param child Child or child’s index. * @return This node for methods chain. */ - removeChild(child: ChildNode | number): this + removeChild(child: Child | number): this /** * Removes all children from the container @@ -420,7 +410,7 @@ export default abstract class Container extends Node { * @return Is every child pass condition. */ every( - condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + condition: (node: Child, index: number, nodes: Child[]) => boolean ): boolean /** @@ -435,7 +425,7 @@ export default abstract class Container extends Node { * @return Is some child pass condition. */ some( - condition: (node: ChildNode, index: number, nodes: ChildNode[]) => boolean + condition: (node: Child, index: number, nodes: Child[]) => boolean ): boolean /** @@ -448,5 +438,5 @@ export default abstract class Container extends Node { * @param child Child of the current container. * @return Child index. */ - index(child: ChildNode | number): number + index(child: Child | number): number } 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/declaration.d.ts b/lib/declaration.d.ts index 000b49bdf..56cfb46c0 100644 --- a/lib/declaration.d.ts +++ b/lib/declaration.d.ts @@ -1,3 +1,4 @@ +import Container from './container.js' import Node from './node.js' interface DeclarationRaws { @@ -56,6 +57,7 @@ export interface DeclarationProps { */ export default class Declaration extends Node { type: 'decl' + parent: Container | undefined raws: DeclarationRaws /** diff --git a/lib/document.d.ts b/lib/document.d.ts new file mode 100644 index 000000000..d2adc0482 --- /dev/null +++ b/lib/document.d.ts @@ -0,0 +1,54 @@ +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[] + + /** + * Information to generate byte-to-byte equal node string as it was + * in the origin input. + * + * Every parser saves its own properties. + */ + raws?: Record +} + +type ChildNode = Root +type ChildProps = RootProps + +/** + * Represents a file and contains all its parsed nodes. + * + * **Experimental:** some aspects of this node could change within minor or patch version releases. + * + * ```js + * const document = htmlParser('') + * 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 +} 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 5124516db..9e46e8764 100644 --- a/lib/node.d.ts +++ b/lib/node.d.ts @@ -5,14 +5,15 @@ import AtRule, { AtRuleProps } from './at-rule.js' import Rule, { RuleProps } from './rule.js' import { WarningOptions } from './warning.js' import CssSyntaxError from './css-syntax-error.js' -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' +import Container from './container.js' export type ChildNode = AtRule | Rule | Declaration | Comment -export type AnyNode = AtRule | Rule | Declaration | Comment | Root +export type AnyNode = AtRule | Rule | Declaration | Comment | Root | Document export type ChildProps = | AtRuleProps @@ -97,7 +98,7 @@ export default abstract class Node { * root.nodes[0].parent === root * ``` */ - parent: Container | undefined + parent: Document | Container | undefined /** * The input source of the node. diff --git a/lib/node.js b/lib/node.js index 5daca501f..c5121dfe4 100644 --- a/lib/node.js +++ b/lib/node.js @@ -158,7 +158,9 @@ class Node { root() { let result = this - while (result.parent) result = result.parent + while (result.parent && result.parent.type !== 'document') { + result = result.parent + } return result } diff --git a/lib/postcss.d.ts b/lib/postcss.d.ts index 6e0f16dd8..445880663 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' @@ -40,6 +41,7 @@ export { ChildProps, AtRuleProps, RootProps, + DocumentProps, Warning, CssSyntaxError, Node, @@ -50,6 +52,7 @@ export { AtRule, Rule, Root, + Document, Result, LazyResult, Input @@ -61,6 +64,10 @@ export type SourceMap = SourceMapGenerator & { export type Helpers = { result: Result; postcss: Postcss } & Postcss +type DocumentProcessor = ( + document: Document, + helper: Helpers +) => Promise | void type RootProcessor = (root: Root, helper: Helpers) => Promise | void type DeclarationProcessor = ( decl: Declaration, @@ -74,6 +81,20 @@ type CommentProcessor = ( ) => Promise | void interface Processors { + /** + * Will be called on `Document` node. + * + * Will be called again on children changes. + */ + Document?: DocumentProcessor + + /** + * Will be called on `Document` node, when all children will be processed. + * + * Will be called again on children changes. + */ + DocumentExit?: DocumentProcessor + /** * Will be called on `Root` node once. */ @@ -200,11 +221,11 @@ export type AcceptedPlugin = } | Processor -export interface Parser { +export interface Parser { ( css: string | { toString(): string }, opts?: Pick - ): Root + ): RootNode } export interface Builder { @@ -224,7 +245,7 @@ export interface Syntax { /** * Function to generate AST by string. */ - parse?: Parser + parse?: Parser /** * Class to generate string by AST. @@ -347,7 +368,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 +436,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 a583639f7..741e88ed0 100644 --- a/lib/root.d.ts +++ b/lib/root.d.ts @@ -1,8 +1,9 @@ import Container, { ContainerProps } from './container.js' +import Document from './document.js' import { ProcessOptions } from './postcss.js' import Result from './result.js' -interface RootRaws { +interface RootRaws extends Record { /** * The space symbols after the last child to the end of file. */ @@ -30,7 +31,7 @@ export interface RootProps extends ContainerProps { */ export default class Root extends Container { type: 'root' - parent: undefined + parent: Document | undefined raws: RootRaws /** diff --git a/lib/rule.d.ts b/lib/rule.d.ts index 83068ed16..75ddf31ed 100644 --- a/lib/rule.d.ts +++ b/lib/rule.d.ts @@ -65,6 +65,7 @@ export interface RuleProps extends ContainerProps { */ export default class Rule extends Container { type: 'rule' + parent: Container | undefined raws: RuleRaws /** 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 b8b2994c5..a20a5cfe6 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 { @@ -333,6 +334,29 @@ 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('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/processor.test.ts b/test/processor.test.ts index 9a73f8a0c..d4f83cc57 100644 --- a/test/processor.test.ts +++ b/test/processor.test.ts @@ -7,10 +7,14 @@ import postcss, { Node, Root, parse, - PluginCreator + PluginCreator, + Document, + Parser, + Stringifier } from '../lib/postcss.js' import LazyResult from '../lib/lazy-result.js' import Processor from '../lib/processor.js' +import Rule from '../lib/rule.js' afterEach(() => { jest.resetAllMocks() @@ -547,3 +551,60 @@ it('supports plugin creators returning processors', () => { processor.use(other) expect(processor.plugins).toEqual([a]) }) + +it('uses custom syntax for document', async () => { + let customParser: Parser = () => { + return new Document({ + nodes: [ + new Root({ + raws: { + markupBefore: '\n\n\n\n' + }, + nodes: [new Rule({ selector: 'b' })] + }) + ] + }) + } + + let customStringifier: Stringifier = (doc, builder) => { + if (doc.type === 'document') { + for (let root of doc.nodes) { + if (root.raws.markupBefore) { + builder(root.raws.markupBefore, root) + } + + builder(root.toString(), root) + + if (root.raws.markupAfter) { + builder(root.raws.markupAfter, root) + } + } + } + } + + let processor = new Processor([() => {}]) + let result = await processor.process('a{}', { + syntax: { + parse: customParser, + stringify: customStringifier + }, + from: undefined + }) + + expect(result.css).toEqual( + '\n\n\n\n' + ) +}) diff --git a/test/stringifier.test.js b/test/stringifier.test.js index f293bf2bf..e929e9c6a 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,102 @@ 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 and after', () => { + let document = new Document() + let root = new Root({ raws: { after: 'AFTER' } }) + root.append(new AtRule({ name: 'foo' })) + document.append(root) + + let s = document.toString() + + expect(s).toEqual('@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: { 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('a.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'] + ]) +})