diff --git a/docs/extensibility/theme-support.md b/docs/extensibility/theme-support.md index f3520e4b29410..82bda5af758aa 100644 --- a/docs/extensibility/theme-support.md +++ b/docs/extensibility/theme-support.md @@ -177,7 +177,20 @@ This flag will make sure users are only able to choose colors from the `editor-c ## Editor styles -A theme can provide a stylesheet that will change the editor's appearance. You can use this to change colors, fonts, and any other visual aspect of the editor. +Gutenberg supports the theme's [editor styles](https://codex.wordpress.org/Editor_Style). This support is opt-in because these styles are applied differently from the classic editor. + + - In the classic editor, the stylesheet is applied as is in the iframe of the post content editor. + - Since Gutenberg doesn't make use of iFrames, this is not possible. Instead Gutenberg wrap all the provided styles with `.editor-block-list__block` to avoid leaking styles outside the editor's content area. + +This technique should allow the editor styles to work properly in both editors in most cases. + +Enabling editor styles support is done using: + +```php +add_theme_support( 'editor-styles' ); +``` + +Alternatively, a theme can provide a stylesheet that will change the editor's appearance entirely. You can use this to change colors, fonts, and any other visual aspect of the editor. ### Add the stylesheet diff --git a/docs/manifest.json b/docs/manifest.json index 343178dc7a8f6..fccd97003d15e 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -443,6 +443,12 @@ "markdown_source": "https://github.com/raw/WordPress/gutenberg/master/packages/postcss-themes/README.md", "parent": "packages" }, + { + "title": "@wordpress/postcss-url", + "slug": "packages-postcss-url", + "markdown_source": "https://github.com/raw/WordPress/gutenberg/master/packages/postcss-url/README.md", + "parent": "packages" + }, { "title": "@wordpress/redux-routine", "slug": "packages-redux-routine", diff --git a/docs/reference/faq.md b/docs/reference/faq.md index d5162a405be16..8842bad8ab378 100644 --- a/docs/reference/faq.md +++ b/docs/reference/faq.md @@ -151,7 +151,7 @@ Other features, like the new _wide_ and _full-wide_ alignment options, will simp ## How will editor styles work? -Themes can provide editor styles for blocks by using the following hook: +Regular editor styles are opt-in and will work as is in most cases. Themes can also load extra stylesheets by using the following hook: ```php function gutenbergtheme_editor_styles() { @@ -160,6 +160,8 @@ function gutenbergtheme_editor_styles() { add_action( 'enqueue_block_editor_assets', 'gutenbergtheme_editor_styles' ); ``` +*Details:* [Editor Styles](../../docs/extensibility/theme-support.md#editor-styles) + ## Should I be concerned that Gutenberg will make my plugin obsolete? The goal of Gutenberg is not to put anyone out of business. It's to evolve WordPress so there's more business to be had in the future, for everyone. diff --git a/edit-post/components/visual-editor/style.scss b/edit-post/components/visual-editor/style.scss index c0e6eddae9a4c..fe7b90ad18519 100644 --- a/edit-post/components/visual-editor/style.scss +++ b/edit-post/components/visual-editor/style.scss @@ -2,32 +2,6 @@ position: relative; padding: 50px 0; - &, - & p { - font-family: $editor-font; - font-size: $editor-font-size; - line-height: $editor-line-height; - } - - &, - & p { - color: $dark-gray-700; - } - - & ul, - & ol { - margin: 0; - padding: 0; - } - - & ul:not(.wp-block-gallery) { - list-style-type: disc; - } - - & ol { - list-style-type: decimal; - } - & .components-button { font-family: $default-font; } diff --git a/lib/client-assets.php b/lib/client-assets.php index 8a2b226eea04b..180799dd853db 100644 --- a/lib/client-assets.php +++ b/lib/client-assets.php @@ -1400,6 +1400,31 @@ function gutenberg_editor_scripts_and_styles( $hook ) { $max_upload_size = 0; } + // Editor Styles. + global $editor_styles; + $styles = array( + array( + 'css' => file_get_contents( + gutenberg_dir_path() . 'build/editor/editor-styles.css' + ), + ), + ); + if ( $editor_styles && current_theme_supports( 'editor-styles' ) ) { + foreach ( $editor_styles as $style ) { + if ( filter_var( $style, FILTER_VALIDATE_URL ) ) { + $styles[] = array( + 'css' => file_get_contents( $style ), + ); + } else { + $file = get_theme_file_path( $style ); + $styles[] = array( + 'css' => file_get_contents( get_theme_file_path( $style ) ), + 'baseURL' => get_theme_file_uri( $style ), + ); + } + } + } + $editor_settings = array( 'alignWide' => $align_wide || ! empty( $gutenberg_theme_support[0]['wide-images'] ), // Backcompat. Use `align-wide` outside of `gutenberg` array. 'availableTemplates' => $available_templates, @@ -1412,6 +1437,7 @@ function gutenberg_editor_scripts_and_styles( $hook ) { 'autosaveInterval' => 10, 'maxUploadFileSize' => $max_upload_size, 'allowedMimeTypes' => get_allowed_mime_types(), + 'styles' => $styles, ); $post_autosave = get_autosave_newer_than_post_save( $post ); diff --git a/package-lock.json b/package-lock.json index 20c08dc159964..bf0cd38cb2b06 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2461,6 +2461,7 @@ "classnames": "^2.2.5", "dom-scroll-into-view": "^1.2.1", "element-closest": "^2.0.2", + "inherits": "^2.0.3", "lodash": "^4.17.10", "memize": "^1.0.5", "react-autosize-textarea": "^3.0.2", @@ -2470,7 +2471,23 @@ "rememo": "^3.0.0", "tinycolor2": "^1.4.1", "tinymce": "^4.7.2", + "traverse": "^0.6.6", "uuid": "^3.1.0" + }, + "dependencies": { + "postcss": { + "version": "7.0.2", + "bundled": true, + "requires": { + "chalk": "^2.4.1", + "source-map": "^0.6.1", + "supports-color": "^5.4.0" + } + }, + "source-map": { + "version": "0.6.1", + "bundled": true + } } }, "@wordpress/element": { @@ -2814,7 +2831,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, "requires": { "color-convert": "^1.9.0" } @@ -4796,7 +4812,6 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", - "dev": true, "requires": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", @@ -5302,7 +5317,6 @@ "version": "1.9.2", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz", "integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==", - "dev": true, "requires": { "color-name": "1.1.1" } @@ -5310,8 +5324,7 @@ "color-name": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz", - "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=", - "dev": true + "integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok=" }, "color-string": { "version": "0.3.0", @@ -7063,8 +7076,7 @@ "escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", - "dev": true + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=" }, "escodegen": { "version": "1.10.0", @@ -9524,8 +9536,7 @@ "has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", - "dev": true + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" }, "has-symbol-support-x": { "version": "1.4.2", @@ -19244,7 +19255,6 @@ "version": "5.4.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", "integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==", - "dev": true, "requires": { "has-flag": "^3.0.0" } @@ -19641,6 +19651,11 @@ "punycode": "^2.1.0" } }, + "traverse": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.6.tgz", + "integrity": "sha1-y99WD9e5r2MlAv7UD5GMFX6pcTc=" + }, "tree-kill": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.0.tgz", diff --git a/packages/block-library/src/heading/editor.scss b/packages/block-library/src/heading/editor.scss index ddc9aff37ff2b..b3b695a511e8a 100644 --- a/packages/block-library/src/heading/editor.scss +++ b/packages/block-library/src/heading/editor.scss @@ -5,6 +5,7 @@ h4, h5, h6 { + color: inherit; margin: 0; } diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md index 8fa4727e06472..7554a5c62f140 100644 --- a/packages/editor/CHANGELOG.md +++ b/packages/editor/CHANGELOG.md @@ -1,5 +1,9 @@ ## 3.0.0 (Unreleased) +### New Features + +- Add editor styles support. + ### Breaking Changes - The `wideAlign` block supports hook has been removed. Use `alignWide` instead. diff --git a/packages/editor/package.json b/packages/editor/package.json index 88f301c6c5866..1dd0dd02a7fb8 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -46,6 +46,7 @@ "classnames": "^2.2.5", "dom-scroll-into-view": "^1.2.1", "element-closest": "^2.0.2", + "inherits": "^2.0.3", "lodash": "^4.17.10", "memize": "^1.0.5", "react-autosize-textarea": "^3.0.2", @@ -55,6 +56,7 @@ "rememo": "^3.0.0", "tinycolor2": "^1.4.1", "tinymce": "^4.7.2", + "traverse": "^0.6.6", "uuid": "^3.1.0" }, "devDependencies": { diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index a6216bad3a464..ab27bf981ff09 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -1,11 +1,12 @@ /** * External dependencies */ -import { flow } from 'lodash'; +import { flow, map } from 'lodash'; /** * WordPress Dependencies */ +import { compose } from '@wordpress/compose'; import { createElement, Component } from '@wordpress/element'; import { DropZoneProvider, SlotFillProvider } from '@wordpress/components'; import { withDispatch } from '@wordpress/data'; @@ -13,6 +14,7 @@ import { withDispatch } from '@wordpress/data'; /** * Internal dependencies */ +import { traverse, wrap, urlRewrite, editorWidth } from '../../editor-styles'; import RichTextProvider from '../rich-text/provider'; class EditorProvider extends Component { @@ -26,6 +28,28 @@ class EditorProvider extends Component { } } + componentDidMount() { + if ( ! this.props.settings.styles ) { + return; + } + + map( this.props.settings.styles, ( { css, baseURL } ) => { + const transforms = [ + editorWidth, + wrap( '.editor-block-list__block', [ '.wp-block' ] ), + ]; + if ( baseURL ) { + transforms.push( urlRewrite( baseURL ) ); + } + const updatedCSS = traverse( css, compose( transforms ) ); + if ( updatedCSS ) { + const node = document.createElement( 'style' ); + node.innerHTML = updatedCSS; + document.body.appendChild( node ); + } + } ); + } + componentDidUpdate( prevProps ) { if ( this.props.settings !== prevProps.settings ) { this.props.updateEditorSettings( this.props.settings ); diff --git a/packages/editor/src/editor-styles.scss b/packages/editor/src/editor-styles.scss new file mode 100644 index 0000000000000..5f9966c075184 --- /dev/null +++ b/packages/editor/src/editor-styles.scss @@ -0,0 +1,29 @@ +.wp-block { + width: 610px; +} + +body { + font-family: $editor-font; + line-height: $editor-line-height; + color: $dark-gray-700; + font-size: $editor-font-size; +} + +p { + font-size: $editor-font-size; +} + + +ul, +ol { + margin: 0; + padding: 0; +} + +ul:not(.wp-block-gallery) { + list-style-type: disc; +} + +ol { + list-style-type: decimal; +} diff --git a/packages/editor/src/editor-styles/ast/index.js b/packages/editor/src/editor-styles/ast/index.js new file mode 100644 index 0000000000000..b4dc1de499f47 --- /dev/null +++ b/packages/editor/src/editor-styles/ast/index.js @@ -0,0 +1,5 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +export { default as parse } from './parse'; +export { default as stringify } from './stringify'; diff --git a/packages/editor/src/editor-styles/ast/parse.js b/packages/editor/src/editor-styles/ast/parse.js new file mode 100644 index 0000000000000..707340ac93854 --- /dev/null +++ b/packages/editor/src/editor-styles/ast/parse.js @@ -0,0 +1,685 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +// http://www.w3.org/TR/CSS21/grammar.htm +// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027 +const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g; + +export default function( css, options ) { + options = options || {}; + + /** + * Positional. + */ + + let lineno = 1; + let column = 1; + + /** + * Update lineno and column based on `str`. + */ + + function updatePosition( str ) { + const lines = str.match( /\n/g ); + if ( lines ) { + lineno += lines.length; + } + const i = str.lastIndexOf( '\n' ); + // eslint-disable-next-line no-bitwise + column = ~i ? str.length - i : column + str.length; + } + + /** + * Mark position and patch `node.position`. + */ + + function position() { + const start = { line: lineno, column: column }; + return function( node ) { + node.position = new Position( start ); + whitespace(); + return node; + }; + } + + /** + * Store position information for a node + */ + + function Position( start ) { + this.start = start; + this.end = { line: lineno, column: column }; + this.source = options.source; + } + + /** + * Non-enumerable source string + */ + + Position.prototype.content = css; + + /** + * Error `msg`. + */ + + const errorsList = []; + + function error( msg ) { + const err = new Error( options.source + ':' + lineno + ':' + column + ': ' + msg ); + err.reason = msg; + err.filename = options.source; + err.line = lineno; + err.column = column; + err.source = css; + + if ( options.silent ) { + errorsList.push( err ); + } else { + throw err; + } + } + + /** + * Parse stylesheet. + */ + + function stylesheet() { + const rulesList = rules(); + + return { + type: 'stylesheet', + stylesheet: { + source: options.source, + rules: rulesList, + parsingErrors: errorsList, + }, + }; + } + + /** + * Opening brace. + */ + + function open() { + return match( /^{\s*/ ); + } + + /** + * Closing brace. + */ + + function close() { + return match( /^}/ ); + } + + /** + * Parse ruleset. + */ + + function rules() { + let node; + const accumulator = []; + whitespace(); + comments( accumulator ); + while ( css.length && css.charAt( 0 ) !== '}' && ( node = atrule() || rule() ) ) { + if ( node !== false ) { + accumulator.push( node ); + comments( accumulator ); + } + } + return accumulator; + } + + /** + * Match `re` and return captures. + */ + + function match( re ) { + const m = re.exec( css ); + if ( ! m ) { + return; + } + const str = m[ 0 ]; + updatePosition( str ); + css = css.slice( str.length ); + return m; + } + + /** + * Parse whitespace. + */ + + function whitespace() { + match( /^\s*/ ); + } + + /** + * Parse comments; + */ + + function comments( accumulator ) { + let c; + accumulator = accumulator || []; + // eslint-disable-next-line no-cond-assign + while ( c = comment() ) { + if ( c !== false ) { + accumulator.push( c ); + } + } + return accumulator; + } + + /** + * Parse comment. + */ + + function comment() { + const pos = position(); + if ( '/' !== css.charAt( 0 ) || '*' !== css.charAt( 1 ) ) { + return; + } + + let i = 2; + while ( '' !== css.charAt( i ) && ( '*' !== css.charAt( i ) || '/' !== css.charAt( i + 1 ) ) ) { + ++i; + } + i += 2; + + if ( '' === css.charAt( i - 1 ) ) { + return error( 'End of comment missing' ); + } + + const str = css.slice( 2, i - 2 ); + column += 2; + updatePosition( str ); + css = css.slice( i ); + column += 2; + + return pos( { + type: 'comment', + comment: str, + } ); + } + + /** + * Parse selector. + */ + + function selector() { + const m = match( /^([^{]+)/ ); + if ( ! m ) { + return; + } + /* @fix Remove all comments from selectors + * http://ostermiller.org/findcomment.html */ + return trim( m[ 0 ] ) + .replace( /\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '' ) + .replace( /"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, function( matched ) { + return matched.replace( /,/g, '\u200C' ); + } ) + .split( /\s*(?![^(]*\)),\s*/ ) + .map( function( s ) { + return s.replace( /\u200C/g, ',' ); + } ); + } + + /** + * Parse declaration. + */ + + function declaration() { + const pos = position(); + + // prop + let prop = match( /^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/ ); + if ( ! prop ) { + return; + } + prop = trim( prop[ 0 ] ); + + // : + if ( ! match( /^:\s*/ ) ) { + return error( "property missing ':'" ); + } + + // val + const val = match( /^((?:'(?:\\'|.)*?'|"(?:\\"|.)*?"|\([^\)]*?\)|[^};])+)/ ); + + const ret = pos( { + type: 'declaration', + property: prop.replace( commentre, '' ), + value: val ? trim( val[ 0 ] ).replace( commentre, '' ) : '', + } ); + + // ; + match( /^[;\s]*/ ); + + return ret; + } + + /** + * Parse declarations. + */ + + function declarations() { + const decls = []; + + if ( ! open() ) { + return error( "missing '{'" ); + } + comments( decls ); + + // declarations + let decl; + // eslint-disable-next-line no-cond-assign + while ( decl = declaration() ) { + if ( decl !== false ) { + decls.push( decl ); + comments( decls ); + } + } + + if ( ! close() ) { + return error( "missing '}'" ); + } + return decls; + } + + /** + * Parse keyframe. + */ + + function keyframe() { + let m; + const vals = []; + const pos = position(); + + // eslint-disable-next-line no-cond-assign + while ( m = match( /^((\d+\.\d+|\.\d+|\d+)%?|[a-z]+)\s*/ ) ) { + vals.push( m[ 1 ] ); + match( /^,\s*/ ); + } + + if ( ! vals.length ) { + return; + } + + return pos( { + type: 'keyframe', + values: vals, + declarations: declarations(), + } ); + } + + /** + * Parse keyframes. + */ + + function atkeyframes() { + const pos = position(); + let m = match( /^@([-\w]+)?keyframes\s*/ ); + + if ( ! m ) { + return; + } + const vendor = m[ 1 ]; + + // identifier + m = match( /^([-\w]+)\s*/ ); + if ( ! m ) { + return error( '@keyframes missing name' ); + } + const name = m[ 1 ]; + + if ( ! open() ) { + return error( "@keyframes missing '{'" ); + } + + let frame; + let frames = comments(); + // eslint-disable-next-line no-cond-assign + while ( frame = keyframe() ) { + frames.push( frame ); + frames = frames.concat( comments() ); + } + + if ( ! close() ) { + return error( "@keyframes missing '}'" ); + } + + return pos( { + type: 'keyframes', + name: name, + vendor: vendor, + keyframes: frames, + } ); + } + + /** + * Parse supports. + */ + + function atsupports() { + const pos = position(); + const m = match( /^@supports *([^{]+)/ ); + + if ( ! m ) { + return; + } + const supports = trim( m[ 1 ] ); + + if ( ! open() ) { + return error( "@supports missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@supports missing '}'" ); + } + + return pos( { + type: 'supports', + supports: supports, + rules: style, + } ); + } + + /** + * Parse host. + */ + + function athost() { + const pos = position(); + const m = match( /^@host\s*/ ); + + if ( ! m ) { + return; + } + + if ( ! open() ) { + return error( "@host missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@host missing '}'" ); + } + + return pos( { + type: 'host', + rules: style, + } ); + } + + /** + * Parse media. + */ + + function atmedia() { + const pos = position(); + const m = match( /^@media *([^{]+)/ ); + + if ( ! m ) { + return; + } + const media = trim( m[ 1 ] ); + + if ( ! open() ) { + return error( "@media missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@media missing '}'" ); + } + + return pos( { + type: 'media', + media: media, + rules: style, + } ); + } + + /** + * Parse custom-media. + */ + + function atcustommedia() { + const pos = position(); + const m = match( /^@custom-media\s+(--[^\s]+)\s*([^{;]+);/ ); + if ( ! m ) { + return; + } + + return pos( { + type: 'custom-media', + name: trim( m[ 1 ] ), + media: trim( m[ 2 ] ), + } ); + } + + /** + * Parse paged media. + */ + + function atpage() { + const pos = position(); + const m = match( /^@page */ ); + if ( ! m ) { + return; + } + + const sel = selector() || []; + + if ( ! open() ) { + return error( "@page missing '{'" ); + } + let decls = comments(); + + // declarations + let decl; + // eslint-disable-next-line no-cond-assign + while ( decl = declaration() ) { + decls.push( decl ); + decls = decls.concat( comments() ); + } + + if ( ! close() ) { + return error( "@page missing '}'" ); + } + + return pos( { + type: 'page', + selectors: sel, + declarations: decls, + } ); + } + + /** + * Parse document. + */ + + function atdocument() { + const pos = position(); + const m = match( /^@([-\w]+)?document *([^{]+)/ ); + if ( ! m ) { + return; + } + + const vendor = trim( m[ 1 ] ); + const doc = trim( m[ 2 ] ); + + if ( ! open() ) { + return error( "@document missing '{'" ); + } + + const style = comments().concat( rules() ); + + if ( ! close() ) { + return error( "@document missing '}'" ); + } + + return pos( { + type: 'document', + document: doc, + vendor: vendor, + rules: style, + } ); + } + + /** + * Parse font-face. + */ + + function atfontface() { + const pos = position(); + const m = match( /^@font-face\s*/ ); + if ( ! m ) { + return; + } + + if ( ! open() ) { + return error( "@font-face missing '{'" ); + } + let decls = comments(); + + // declarations + let decl; + // eslint-disable-next-line no-cond-assign + while ( decl = declaration() ) { + decls.push( decl ); + decls = decls.concat( comments() ); + } + + if ( ! close() ) { + return error( "@font-face missing '}'" ); + } + + return pos( { + type: 'font-face', + declarations: decls, + } ); + } + + /** + * Parse import + */ + + const atimport = _compileAtrule( 'import' ); + + /** + * Parse charset + */ + + const atcharset = _compileAtrule( 'charset' ); + + /** + * Parse namespace + */ + + const atnamespace = _compileAtrule( 'namespace' ); + + /** + * Parse non-block at-rules + */ + + function _compileAtrule( name ) { + const re = new RegExp( '^@' + name + '\\s*([^;]+);' ); + return function() { + const pos = position(); + const m = match( re ); + if ( ! m ) { + return; + } + const ret = { type: name }; + ret[ name ] = m[ 1 ].trim(); + return pos( ret ); + }; + } + + /** + * Parse at rule. + */ + + function atrule() { + if ( css[ 0 ] !== '@' ) { + return; + } + + return atkeyframes() || + atmedia() || + atcustommedia() || + atsupports() || + atimport() || + atcharset() || + atnamespace() || + atdocument() || + atpage() || + athost() || + atfontface(); + } + + /** + * Parse rule. + */ + + function rule() { + const pos = position(); + const sel = selector(); + + if ( ! sel ) { + return error( 'selector missing' ); + } + comments(); + + return pos( { + type: 'rule', + selectors: sel, + declarations: declarations(), + } ); + } + + return addParent( stylesheet() ); +} + +/** + * Trim `str`. + */ + +function trim( str ) { + return str ? str.replace( /^\s+|\s+$/g, '' ) : ''; +} + +/** + * Adds non-enumerable parent node reference to each node. + */ + +function addParent( obj, parent ) { + const isNode = obj && typeof obj.type === 'string'; + const childParent = isNode ? obj : parent; + + for ( const k in obj ) { + const value = obj[ k ]; + if ( Array.isArray( value ) ) { + value.forEach( function( v ) { + addParent( v, childParent ); + } ); + } else if ( value && typeof value === 'object' ) { + addParent( value, childParent ); + } + } + + if ( isNode ) { + Object.defineProperty( obj, 'parent', { + configurable: true, + writable: true, + enumerable: false, + value: parent || null, + } ); + } + + return obj; +} diff --git a/packages/editor/src/editor-styles/ast/stringify/compiler.js b/packages/editor/src/editor-styles/ast/stringify/compiler.js new file mode 100644 index 0000000000000..fb17a2f15e9dd --- /dev/null +++ b/packages/editor/src/editor-styles/ast/stringify/compiler.js @@ -0,0 +1,54 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * Expose `Compiler`. + */ + +export default Compiler; + +/** + * Initialize a compiler. + * + * @param {Type} name + * @return {Type} + * @api public + */ + +function Compiler( opts ) { + this.options = opts || {}; +} + +/** + * Emit `str` + */ + +Compiler.prototype.emit = function( str ) { + return str; +}; + +/** + * Visit `node`. + */ + +Compiler.prototype.visit = function( node ) { + return this[ node.type ]( node ); +}; + +/** + * Map visit over array of `nodes`, optionally using a `delim` + */ + +Compiler.prototype.mapVisit = function( nodes, delim ) { + let buf = ''; + delim = delim || ''; + + for ( let i = 0, length = nodes.length; i < length; i++ ) { + buf += this.visit( nodes[ i ] ); + if ( delim && i < length - 1 ) { + buf += this.emit( delim ); + } + } + + return buf; +}; diff --git a/packages/editor/src/editor-styles/ast/stringify/compress.js b/packages/editor/src/editor-styles/ast/stringify/compress.js new file mode 100644 index 0000000000000..6b3f0468c5ad6 --- /dev/null +++ b/packages/editor/src/editor-styles/ast/stringify/compress.js @@ -0,0 +1,205 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * External dependencies + */ +import inherits from 'inherits'; + +/** + * Internal dependencies + */ +import Base from './compiler'; + +/** + * Expose compiler. + */ + +export default Compiler; + +/** + * Initialize a new `Compiler`. + */ + +function Compiler( options ) { + Base.call( this, options ); +} + +/** + * Inherit from `Base.prototype`. + */ + +inherits( Compiler, Base ); + +/** + * Compile `node`. + */ + +Compiler.prototype.compile = function( node ) { + return node.stylesheet + .rules.map( this.visit, this ) + .join( '' ); +}; + +/** + * Visit comment node. + */ + +Compiler.prototype.comment = function( node ) { + return this.emit( '', node.position ); +}; + +/** + * Visit import node. + */ + +Compiler.prototype.import = function( node ) { + return this.emit( '@import ' + node.import + ';', node.position ); +}; + +/** + * Visit media node. + */ + +Compiler.prototype.media = function( node ) { + return this.emit( '@media ' + node.media, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit document node. + */ + +Compiler.prototype.document = function( node ) { + const doc = '@' + ( node.vendor || '' ) + 'document ' + node.document; + + return this.emit( doc, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit charset node. + */ + +Compiler.prototype.charset = function( node ) { + return this.emit( '@charset ' + node.charset + ';', node.position ); +}; + +/** + * Visit namespace node. + */ + +Compiler.prototype.namespace = function( node ) { + return this.emit( '@namespace ' + node.namespace + ';', node.position ); +}; + +/** + * Visit supports node. + */ + +Compiler.prototype.supports = function( node ) { + return this.emit( '@supports ' + node.supports, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit keyframes node. + */ + +Compiler.prototype.keyframes = function( node ) { + return this.emit( '@' + + ( node.vendor || '' ) + + 'keyframes ' + + node.name, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.keyframes ) + + this.emit( '}' ); +}; + +/** + * Visit keyframe node. + */ + +Compiler.prototype.keyframe = function( node ) { + const decls = node.declarations; + + return this.emit( node.values.join( ',' ), node.position ) + + this.emit( '{' ) + + this.mapVisit( decls ) + + this.emit( '}' ); +}; + +/** + * Visit page node. + */ + +Compiler.prototype.page = function( node ) { + const sel = node.selectors.length ? + node.selectors.join( ', ' ) : + ''; + + return this.emit( '@page ' + sel, node.position ) + + this.emit( '{' ) + + this.mapVisit( node.declarations ) + + this.emit( '}' ); +}; + +/** + * Visit font-face node. + */ + +Compiler.prototype[ 'font-face' ] = function( node ) { + return this.emit( '@font-face', node.position ) + + this.emit( '{' ) + + this.mapVisit( node.declarations ) + + this.emit( '}' ); +}; + +/** + * Visit host node. + */ + +Compiler.prototype.host = function( node ) { + return this.emit( '@host', node.position ) + + this.emit( '{' ) + + this.mapVisit( node.rules ) + + this.emit( '}' ); +}; + +/** + * Visit custom-media node. + */ + +Compiler.prototype[ 'custom-media' ] = function( node ) { + return this.emit( '@custom-media ' + node.name + ' ' + node.media + ';', node.position ); +}; + +/** + * Visit rule node. + */ + +Compiler.prototype.rule = function( node ) { + const decls = node.declarations; + if ( ! decls.length ) { + return ''; + } + + return this.emit( node.selectors.join( ',' ), node.position ) + + this.emit( '{' ) + + this.mapVisit( decls ) + + this.emit( '}' ); +}; + +/** + * Visit declaration node. + */ + +Compiler.prototype.declaration = function( node ) { + return this.emit( node.property + ':' + node.value, node.position ) + this.emit( ';' ); +}; diff --git a/packages/editor/src/editor-styles/ast/stringify/identity.js b/packages/editor/src/editor-styles/ast/stringify/identity.js new file mode 100644 index 0000000000000..a26a800ca9e76 --- /dev/null +++ b/packages/editor/src/editor-styles/ast/stringify/identity.js @@ -0,0 +1,286 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * External dependencies + */ +import inherits from 'inherits'; + +/** + * Internal dependencies + */ +import Base from './compiler'; + +/** + * Expose compiler. + */ + +export default Compiler; + +/** + * Initialize a new `Compiler`. + */ + +function Compiler( options ) { + options = options || {}; + Base.call( this, options ); + this.indentation = options.indent; +} + +/** + * Inherit from `Base.prototype`. + */ + +inherits( Compiler, Base ); + +/** + * Compile `node`. + */ + +Compiler.prototype.compile = function( node ) { + return this.stylesheet( node ); +}; + +/** + * Visit stylesheet node. + */ + +Compiler.prototype.stylesheet = function( node ) { + return this.mapVisit( node.stylesheet.rules, '\n\n' ); +}; + +/** + * Visit comment node. + */ + +Compiler.prototype.comment = function( node ) { + return this.emit( this.indent() + '/*' + node.comment + '*/', node.position ); +}; + +/** + * Visit import node. + */ + +Compiler.prototype.import = function( node ) { + return this.emit( '@import ' + node.import + ';', node.position ); +}; + +/** + * Visit media node. + */ + +Compiler.prototype.media = function( node ) { + return ( + this.emit( '@media ' + node.media, node.position ) + + this.emit( + ' {\n' + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit document node. + */ + +Compiler.prototype.document = function( node ) { + const doc = '@' + ( node.vendor || '' ) + 'document ' + node.document; + + return ( + this.emit( doc, node.position ) + + this.emit( + ' ' + + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit charset node. + */ + +Compiler.prototype.charset = function( node ) { + return this.emit( '@charset ' + node.charset + ';', node.position ); +}; + +/** + * Visit namespace node. + */ + +Compiler.prototype.namespace = function( node ) { + return this.emit( '@namespace ' + node.namespace + ';', node.position ); +}; + +/** + * Visit supports node. + */ + +Compiler.prototype.supports = function( node ) { + return ( + this.emit( '@supports ' + node.supports, node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit keyframes node. + */ + +Compiler.prototype.keyframes = function( node ) { + return ( + this.emit( '@' + ( node.vendor || '' ) + 'keyframes ' + node.name, node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.keyframes, '\n' ) + + this.emit( + this.indent( -1 ) + + '}' + ) + ); +}; + +/** + * Visit keyframe node. + */ + +Compiler.prototype.keyframe = function( node ) { + const decls = node.declarations; + + return ( + this.emit( this.indent() ) + + this.emit( node.values.join( ', ' ), node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( decls, '\n' ) + + this.emit( + this.indent( -1 ) + + '\n' + + this.indent() + '}\n' + ) + ); +}; + +/** + * Visit page node. + */ + +Compiler.prototype.page = function( node ) { + const sel = node.selectors.length ? + node.selectors.join( ', ' ) + ' ' : + ''; + + return this.emit( '@page ' + sel, node.position ) + + this.emit( '{\n' ) + + this.emit( this.indent( 1 ) ) + + this.mapVisit( node.declarations, '\n' ) + + this.emit( this.indent( -1 ) ) + + this.emit( '\n}' ); +}; + +/** + * Visit font-face node. + */ + +Compiler.prototype[ 'font-face' ] = function( node ) { + return this.emit( '@font-face ', node.position ) + + this.emit( '{\n' ) + + this.emit( this.indent( 1 ) ) + + this.mapVisit( node.declarations, '\n' ) + + this.emit( this.indent( -1 ) ) + + this.emit( '\n}' ); +}; + +/** + * Visit host node. + */ + +Compiler.prototype.host = function( node ) { + return ( + this.emit( '@host', node.position ) + + this.emit( + ' {\n' + + this.indent( 1 ) + ) + + this.mapVisit( node.rules, '\n\n' ) + + this.emit( + this.indent( -1 ) + + '\n}' + ) + ); +}; + +/** + * Visit custom-media node. + */ + +Compiler.prototype[ 'custom-media' ] = function( node ) { + return this.emit( '@custom-media ' + node.name + ' ' + node.media + ';', node.position ); +}; + +/** + * Visit rule node. + */ + +Compiler.prototype.rule = function( node ) { + const indent = this.indent(); + const decls = node.declarations; + if ( ! decls.length ) { + return ''; + } + + return this.emit( node.selectors.map( function( s ) { + return indent + s; + } ).join( ',\n' ), node.position ) + + this.emit( ' {\n' ) + + this.emit( this.indent( 1 ) ) + + this.mapVisit( decls, '\n' ) + + this.emit( this.indent( -1 ) ) + + this.emit( '\n' + this.indent() + '}' ); +}; + +/** + * Visit declaration node. + */ + +Compiler.prototype.declaration = function( node ) { + return this.emit( this.indent() ) + + this.emit( node.property + ': ' + node.value, node.position ) + + this.emit( ';' ); +}; + +/** + * Increase, decrease or return current indentation. + */ + +Compiler.prototype.indent = function( level ) { + this.level = this.level || 1; + + if ( null !== level ) { + this.level += level; + return ''; + } + + return Array( this.level ).join( this.indentation || ' ' ); +}; diff --git a/packages/editor/src/editor-styles/ast/stringify/index.js b/packages/editor/src/editor-styles/ast/stringify/index.js new file mode 100644 index 0000000000000..0253ad968cc67 --- /dev/null +++ b/packages/editor/src/editor-styles/ast/stringify/index.js @@ -0,0 +1,33 @@ +// Adapted from https://github.com/reworkcss/css +// because we needed to remove source map support. + +/** + * Internal dependencies. + */ +import Compressed from './compress'; +import Identity from './identity'; + +/** + * Stringfy the given AST `node`. + * + * Options: + * + * - `compress` space-optimized output + * - `sourcemap` return an object with `.code` and `.map` + * + * @param {Object} node + * @param {Object} [options] + * @return {String} + * @api public + */ + +export default function( node, options ) { + options = options || {}; + + const compiler = options.compress ? + new Compressed( options ) : + new Identity( options ); + + const code = compiler.compile( node ); + return code; +} diff --git a/packages/editor/src/editor-styles/index.js b/packages/editor/src/editor-styles/index.js new file mode 100644 index 0000000000000..1d53fff364337 --- /dev/null +++ b/packages/editor/src/editor-styles/index.js @@ -0,0 +1,4 @@ +export { default as traverse } from './traverse'; +export { default as urlRewrite } from './transforms/url-rewrite'; +export { default as wrap } from './transforms/wrap'; +export { default as editorWidth } from './transforms/editor-width'; diff --git a/packages/editor/src/editor-styles/test/__snapshots__/traverse.js.snap b/packages/editor/src/editor-styles/test/__snapshots__/traverse.js.snap new file mode 100644 index 0000000000000..1ff3cab7d6336 --- /dev/null +++ b/packages/editor/src/editor-styles/test/__snapshots__/traverse.js.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CSS traverse Should traverse the CSS 1`] = ` +"namespace h1 { +color: red; +}" +`; diff --git a/packages/editor/src/editor-styles/test/traverse.js b/packages/editor/src/editor-styles/test/traverse.js new file mode 100644 index 0000000000000..50d6d8da95bc3 --- /dev/null +++ b/packages/editor/src/editor-styles/test/traverse.js @@ -0,0 +1,22 @@ +/** + * Internal dependencies + */ +import traverse from '../traverse'; + +describe( 'CSS traverse', () => { + it( 'Should traverse the CSS', () => { + const input = `h1 { color: red; }`; + const output = traverse( input, ( node ) => { + if ( node.type === 'rule' ) { + return { + ...node, + selectors: node.selectors.map( ( selector ) => 'namespace ' + selector ), + }; + } + + return node; + } ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/editor/src/editor-styles/transforms/editor-width.js b/packages/editor/src/editor-styles/transforms/editor-width.js new file mode 100644 index 0000000000000..a777aaab863bb --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/editor-width.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { find } from 'lodash'; + +export const getEditorWidthRules = ( width ) => { + return { + type: 'rule', + selectors: [ + 'body.gutenberg-editor-page .editor-post-title__block', + 'body.gutenberg-editor-page .editor-default-block-appender', + 'body.gutenberg-editor-page .editor-block-list__block', + ], + declarations: [ + { + type: 'declaration', + property: 'max-width', + value: width, + }, + ], + }; +}; + +const editorWidth = ( node ) => { + if ( + node.type === 'rule' && + find( node.selectors, ( selector ) => selector.trim() === '.wp-block' ) + ) { + const widthDeclaration = find( + node.declarations, + ( declaration ) => declaration.property === 'width' + ); + + if ( widthDeclaration ) { + return getEditorWidthRules( widthDeclaration.value ); + } + } + + return node; +}; + +export default editorWidth; diff --git a/packages/editor/src/editor-styles/transforms/test/__snapshots__/editor-width.js.snap b/packages/editor/src/editor-styles/transforms/test/__snapshots__/editor-width.js.snap new file mode 100644 index 0000000000000..d1260cff31677 --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/test/__snapshots__/editor-width.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Editor Width should generate the editor width styles 1`] = ` +"body.gutenberg-editor-page .editor-post-title__block, +body.gutenberg-editor-page .editor-default-block-appender, +body.gutenberg-editor-page .editor-block-list__block { +max-width: 300px; +}" +`; + +exports[`Editor Width should only replace the html declaration 1`] = ` +"h1 { +width: 300px; +}" +`; diff --git a/packages/editor/src/editor-styles/transforms/test/__snapshots__/url-rewrite.js.snap b/packages/editor/src/editor-styles/transforms/test/__snapshots__/url-rewrite.js.snap new file mode 100644 index 0000000000000..48aaf43221e7d --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/test/__snapshots__/url-rewrite.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`URL rewrite should not replace absolute paths 1`] = ` +"h1 { +background: url(/images/test.png); +}" +`; + +exports[`URL rewrite should not replace remote paths 1`] = ` +"h1 { +background: url(http://wp.org/images/test.png); +}" +`; + +exports[`URL rewrite should replace complex relative paths 1`] = ` +"h1 { +background: url(http://wp-site.local/themes/gut/images/test.png); +}" +`; + +exports[`URL rewrite should replace relative paths 1`] = ` +"h1 { +background: url(http://wp-site.local/themes/gut/css/images/test.png); +}" +`; diff --git a/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap b/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap new file mode 100644 index 0000000000000..868585dc2952a --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/test/__snapshots__/wrap.js.snap @@ -0,0 +1,43 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CSS selector wrap should ignore font-face selectors 1`] = ` +"@font-face { +font-family: myFirstFont; +src: url(sansation_light.woff); +}" +`; + +exports[`CSS selector wrap should ignore keyframes 1`] = ` +"@keyframes move_background { +from { +background-position: 0 0; +} +}" +`; + +exports[`CSS selector wrap should ignore selectors 1`] = ` +".my-namespace h1, +body { +color: red; +}" +`; + +exports[`CSS selector wrap should replace root tags 1`] = ` +".my-namespace, +.my-namespace h1 { +color: red; +}" +`; + +exports[`CSS selector wrap should wrap multiple selectors 1`] = ` +".my-namespace h1, +.my-namespace h2 { +color: red; +}" +`; + +exports[`CSS selector wrap should wrap regular selectors 1`] = ` +".my-namespace h1 { +color: red; +}" +`; diff --git a/packages/editor/src/editor-styles/transforms/test/editor-width.js b/packages/editor/src/editor-styles/transforms/test/editor-width.js new file mode 100644 index 0000000000000..5b5b20b98edac --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/test/editor-width.js @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import traverse from '../../traverse'; +import editorWidth from '../editor-width'; + +describe( 'Editor Width', () => { + it( 'should only replace the html declaration', () => { + const input = `h1 { width: 300px; }`; + const output = traverse( input, editorWidth ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should generate the editor width styles', () => { + const input = `.wp-block { width: 300px; }`; + const output = traverse( input, editorWidth ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/editor/src/editor-styles/transforms/test/url-rewrite.js b/packages/editor/src/editor-styles/transforms/test/url-rewrite.js new file mode 100644 index 0000000000000..abbbf0754187e --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/test/url-rewrite.js @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import traverse from '../../traverse'; +import rewrite from '../url-rewrite'; + +describe( 'URL rewrite', () => { + it( 'should replace relative paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should replace complex relative paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(../images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should not replace absolute paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(/images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should not replace remote paths', () => { + const callback = rewrite( 'http://wp-site.local/themes/gut/css/' ); + const input = `h1 { background: url(http://wp.org/images/test.png); }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/editor/src/editor-styles/transforms/test/wrap.js b/packages/editor/src/editor-styles/transforms/test/wrap.js new file mode 100644 index 0000000000000..ba2e02cd6b00a --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/test/wrap.js @@ -0,0 +1,64 @@ +/** + * Internal dependencies + */ +import traverse from '../../traverse'; +import wrap from '../wrap'; + +describe( 'CSS selector wrap', () => { + it( 'should wrap regular selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = `h1 { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should wrap multiple selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = `h1, h2 { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should ignore selectors', () => { + const callback = wrap( '.my-namespace', 'body' ); + const input = `h1, body { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should replace root tags', () => { + const callback = wrap( '.my-namespace' ); + const input = `body, h1 { color: red; }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should ignore keyframes', () => { + const callback = wrap( '.my-namespace' ); + const input = ` + @keyframes move_background { + from { + background-position: 0 0; + } + }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); + + it( 'should ignore font-face selectors', () => { + const callback = wrap( '.my-namespace' ); + const input = ` + @font-face { + font-family: myFirstFont; + src: url(sansation_light.woff); + }`; + const output = traverse( input, callback ); + + expect( output ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/editor/src/editor-styles/transforms/url-rewrite.js b/packages/editor/src/editor-styles/transforms/url-rewrite.js new file mode 100644 index 0000000000000..5f454bd29ef3d --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/url-rewrite.js @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +import { parse, resolve } from 'url'; + +/** + * Return `true` if the given path is http/https. + * + * @param {string} filePath path + * + * @return {boolean} is remote path. + */ +function isRemotePath( filePath ) { + return /^(?:https?:)?\/\//.test( filePath ); +} + +/** + * Return `true` if the given filePath is an absolute url. + * + * @param {string} filePath path + * + * @return {boolean} is absolute path. + */ +function isAbsolutePath( filePath ) { + return /^\/(?!\/)/.test( filePath ); +} + +/** + * Whether or not the url should be inluded. + * + * @param {Object} meta url meta info + * + * @return {boolean} is valid. + */ +function isValidURL( meta ) { + // ignore hashes or data uris + if ( meta.value.indexOf( 'data:' ) === 0 || meta.value.indexOf( '#' ) === 0 ) { + return false; + } + + if ( isAbsolutePath( meta.value ) ) { + return false; + } + + // do not handle the http/https urls if `includeRemote` is false + if ( isRemotePath( meta.value ) ) { + return false; + } + + return true; +} + +/** + * Get the absolute path of the url, relative to the basePath + * + * @param {string} str the url + * @param {string} baseURL base URL + * @param {string} absolutePath the absolute path + * + * @return {string} the full path to the file + */ +function getResourcePath( str, baseURL ) { + const pathname = parse( str ).pathname; + const filePath = resolve( baseURL, pathname ); + + return filePath; +} + +/** + * Process the single `url()` pattern + * + * @param {string} baseURL the base URL for relative URLs + * @return {Promise} the Promise + */ +function processURL( baseURL ) { + return function( meta ) { + const URL = getResourcePath( meta.value, baseURL ); + return { + ...meta, + newUrl: + 'url(' + + meta.before + + meta.quote + + URL + + meta.quote + + meta.after + + ')', + }; + }; +} + +/** + * Get all `url()`s, and return the meta info + * + * @param {string} value decl.value + * + * @return {Array} the urls + */ +function getURLs( value ) { + const reg = /url\((\s*)(['"]?)(.+?)\2(\s*)\)/g; + let match; + const URLs = []; + + while ( ( match = reg.exec( value ) ) !== null ) { + const meta = { + source: match[ 0 ], + before: match[ 1 ], + quote: match[ 2 ], + value: match[ 3 ], + after: match[ 4 ], + }; + if ( isValidURL( meta ) ) { + URLs.push( meta ); + } + } + return URLs; +} + +/** + * Replace the raw value's `url()` segment to the new value + * + * @param {string} raw the raw value + * @param {Array} URLs the URLs to replace + * + * @return {string} the new value + */ +function replaceURLs( raw, URLs ) { + URLs.forEach( ( item ) => { + raw = raw.replace( item.source, item.newUrl ); + } ); + + return raw; +} + +const rewrite = ( rootURL ) => ( node ) => { + if ( node.type === 'declaration' ) { + const updatedURLs = getURLs( node.value ).map( processURL( rootURL ) ); + return { + ...node, + value: replaceURLs( node.value, updatedURLs ), + }; + } + + return node; +}; + +export default rewrite; diff --git a/packages/editor/src/editor-styles/transforms/wrap.js b/packages/editor/src/editor-styles/transforms/wrap.js new file mode 100644 index 0000000000000..dad6df2b0c8e6 --- /dev/null +++ b/packages/editor/src/editor-styles/transforms/wrap.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { includes } from 'lodash'; + +/** + * @const string IS_ROOT_TAG Regex to check if the selector is a root tag selector. + */ +const IS_ROOT_TAG = /^(body|html).*$/; + +const wrap = ( namespace, ignore = [] ) => ( node ) => { + const updateSelector = ( selector ) => { + if ( includes( ignore, selector.trim() ) ) { + return selector; + } + + // Anything other than a root tag is always prefixed. + {if ( ! selector.match( IS_ROOT_TAG ) ) { + return namespace + ' ' + selector; + }} + + // HTML and Body elements cannot be contained within our container so lets extract their styles. + return selector.replace( /^(body|html)/, namespace ); + }; + + if ( node.type === 'rule' ) { + return { + ...node, + selectors: node.selectors.map( updateSelector ), + }; + } + + return node; +}; + +export default wrap; diff --git a/packages/editor/src/editor-styles/traverse.js b/packages/editor/src/editor-styles/traverse.js new file mode 100644 index 0000000000000..43b18251bd7df --- /dev/null +++ b/packages/editor/src/editor-styles/traverse.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { parse, stringify } from './ast'; +import traverse from 'traverse'; + +function traverseCSS( css, callback ) { + try { + const parsed = parse( css ); + + const updated = traverse.map( parsed, function( node ) { + if ( ! node ) { + return node; + } + const updatedNode = callback( node ); + return this.update( updatedNode ); + } ); + + return stringify( updated ); + } catch ( err ) { + // eslint-disable-next-line no-console + console.warn( 'Error while traversing the CSS: ' + err ); + + return null; + } +} + +export default traverseCSS; diff --git a/packages/postcss-themes/src/index.js b/packages/postcss-themes/src/index.js index 6ede8e47c5553..5018a0dd39a4e 100644 --- a/packages/postcss-themes/src/index.js +++ b/packages/postcss-themes/src/index.js @@ -1,6 +1,6 @@ const postcss = require( 'postcss' ); -module.exports = postcss.plugin( 'postcss-test-plugin', function( options ) { +module.exports = postcss.plugin( 'postcss-themes', function( options ) { return function( root ) { root.walkRules( ( rule ) => { const themeDecls = {}; diff --git a/test/unit/jest.config.json b/test/unit/jest.config.json index 5746d6c9630ed..fec5aad1e2bc8 100644 --- a/test/unit/jest.config.json +++ b/test/unit/jest.config.json @@ -22,6 +22,8 @@ ], "testPathIgnorePatterns": [ "/node_modules/", - "/test/e2e" + "/test/e2e", + "/.*/build/", + "/.*/build-module/" ] }