diff --git a/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx b/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx index fe4a943d..b3642c19 100644 --- a/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/SnippetLocationInput.tsx @@ -14,10 +14,12 @@ const SCOPE_ICONS: Record = { 'single-use': 'clock', 'content': 'shortcode', 'head-content': 'editor-code', + 'body-content': 'editor-code', 'footer-content': 'editor-code', 'admin-css': 'dashboard', 'site-css': 'admin-customizer', 'site-head-js': 'media-code', + 'site-body-js': 'media-code', 'site-footer-js': 'media-code' } @@ -27,12 +29,14 @@ const SCOPE_DESCRIPTIONS: Record = { 'front-end': __('Only run on site front-end', 'code-snippets'), 'single-use': __('Only run once', 'code-snippets'), 'content': __('Where inserted in editor', 'code-snippets'), - 'head-content': __('In site section', 'code-snippets'), + 'head-content': __('In site header ( section)', 'code-snippets'), + 'body-content': __('In site content (start of )', 'code-snippets'), 'footer-content': __('In site footer (end of )', 'code-snippets'), 'site-css': __('Site front-end', 'code-snippets'), 'admin-css': __('Administration area', 'code-snippets'), - 'site-footer-js': __('In site footer (end of )', 'code-snippets'), - 'site-head-js': __('In site section', 'code-snippets') + 'site-head-js': __('In site header ( section)', 'code-snippets'), + 'site-body-js': __('In site content (start of )', 'code-snippets'), + 'site-footer-js': __('In site footer (end of )', 'code-snippets') } export const SnippetLocationInput: React.FC = () => { diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts index 711a525e..7d757d17 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -28,8 +28,8 @@ export type SnippetScope = typeof SNIPPET_TYPE_SCOPES[SnippetType][number] export const SNIPPET_TYPE_SCOPES = { php: ['global', 'admin', 'front-end', 'single-use'], - html: ['content', 'head-content', 'footer-content'], + html: ['content', 'head-content', 'body-content', 'footer-content'], css: ['admin-css', 'site-css'], - js: ['site-head-js', 'site-footer-js'], + js: ['site-head-js', 'site-body-js', 'site-footer-js'], cond: ['condition'] } diff --git a/src/php/Integration/Evaluate_Content.php b/src/php/Integration/Evaluate_Content.php index b8d92763..2cf608fd 100644 --- a/src/php/Integration/Evaluate_Content.php +++ b/src/php/Integration/Evaluate_Content.php @@ -43,6 +43,7 @@ public function __construct( DB $db ) { */ public function init() { add_action( 'wp_head', [ $this, 'load_head_content' ] ); + add_action( 'wp_body_open', [ $this, 'load_body_content' ] ); add_action( 'wp_footer', [ $this, 'load_footer_content' ] ); } @@ -63,7 +64,7 @@ private function print_content_snippets( string $scope ) { } } } else { - $scopes = [ 'head-content', 'footer-content' ]; + $scopes = [ 'head-content', 'body-content', 'footer-content' ]; if ( is_null( $this->active_snippets ) ) { $this->active_snippets = $this->db->fetch_active_snippets( $scopes ); @@ -87,6 +88,15 @@ public function load_head_content() { $this->print_content_snippets( 'head-content' ); } + /** + * Print body content snippets (requires theme to call wp_body_open). + * + * @return void + */ + public function load_body_content() { + $this->print_content_snippets( 'body-content' ); + } + /** * Print footer content snippets. * @@ -106,7 +116,7 @@ private function populate_active_snippets_from_flat_files() { $dir_name = $handler->get_dir_name(); $ext = $handler->get_file_extension(); - $scopes = [ 'head-content', 'footer-content' ]; + $scopes = [ 'head-content', 'body-content', 'footer-content' ]; $all_snippets = Snippet_Files::get_active_snippets_from_flat_files( $scopes, $dir_name ); foreach ( $all_snippets as $snippet ) { diff --git a/src/php/Model/Snippet.php b/src/php/Model/Snippet.php index 1a37b714..20c0f6f1 100644 --- a/src/php/Model/Snippet.php +++ b/src/php/Model/Snippet.php @@ -312,9 +312,9 @@ protected function get_tags_list(): string { public static function get_all_scopes(): array { return array( 'global', 'admin', 'front-end', 'single-use', - 'content', 'head-content', 'footer-content', + 'content', 'head-content', 'body-content', 'footer-content', 'admin-css', 'site-css', - 'site-head-js', 'site-footer-js', + 'site-head-js', 'site-body-js', 'site-footer-js', 'condition', ); } @@ -332,10 +332,12 @@ public static function get_scope_icons(): array { 'single-use' => 'clock', 'content' => 'shortcode', 'head-content' => 'editor-code', + 'body-content' => 'editor-code', 'footer-content' => 'editor-code', 'admin-css' => 'dashboard', 'site-css' => 'admin-customizer', 'site-head-js' => 'media-code', + 'site-body-js' => 'media-code', 'site-footer-js' => 'media-code', 'condition' => 'randomize', ); @@ -361,6 +363,8 @@ protected function get_scope_name(): string { return __( 'Content', 'code-snippets' ); case 'head-content': return __( 'Head content', 'code-snippets' ); + case 'body-content': + return __( 'Body content', 'code-snippets' ); case 'footer-content': return __( 'Footer content', 'code-snippets' ); case 'admin-css': @@ -369,6 +373,8 @@ protected function get_scope_name(): string { return __( 'Front-end styles', 'code-snippets' ); case 'site-head-js': return __( 'Head scripts', 'code-snippets' ); + case 'site-body-js': + return __( 'Body scripts', 'code-snippets' ); case 'site-footer-js': return __( 'Footer scripts', 'code-snippets' ); } diff --git a/src/php/Utils/i18n.php b/src/php/Utils/i18n.php index 42083b68..75f3d606 100644 --- a/src/php/Utils/i18n.php +++ b/src/php/Utils/i18n.php @@ -24,7 +24,11 @@ 'site-css' => __( 'Site front-end stylesheet', 'code-snippets' ), 'admin-css' => __( 'Administration area stylesheet', 'code-snippets' ), 'site-head-js' => __( 'JavaScript loaded in the site <head> section', 'code-snippets' ), - 'site-footer-js' => __( 'JavaScript loaded just before the closing </body> tag', 'code-snippets' ), + 'site-body-js' => __( 'JavaScript loaded at the start of the <body> tag', 'code-snippets' ), + 'site-footer-js' => __( 'JavaScript loaded at the end of the <body> tag', 'code-snippets' ), + 'head-content' => __( 'HTML output in the <head> section', 'code-snippets' ), + 'body-content' => __( 'HTML output at the start of the <body> tag', 'code-snippets' ), + 'footer-content' => __( 'HTML output at the end of the <body> tag', 'code-snippets' ), ); // class-content-widget.php. diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index cc2e4f46..ab99efc4 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -64,7 +64,7 @@ function clean_active_snippets_cache( string $table_name, $scopes = false ) { $scope_groups = $scopes ? [ $scopes ] : [ - [ 'head-content', 'footer-content' ], + [ 'head-content', 'body-content', 'footer-content' ], [ 'global', 'single-use', 'front-end' ], [ 'global', 'single-use', 'admin' ], ]; diff --git a/tests/e2e/code-snippets-evaluation.spec.ts b/tests/e2e/code-snippets-evaluation.spec.ts index dc7f4b63..2c29f160 100644 --- a/tests/e2e/code-snippets-evaluation.spec.ts +++ b/tests/e2e/code-snippets-evaluation.spec.ts @@ -187,6 +187,34 @@ test.describe('Code Snippets Evaluation', () => { await helper.expectElementCount('text=Hello World HTML snippet in header!', 1) }) + test('HTML snippet is evaluating correctly at body start', async () => { + await helper.createAndActivateSnippet({ + name: snippetName, + code: '

Hello World HTML snippet in body start!

', + type: 'HTML', + location: 'SITE_BODY' + }) + + await helper.navigateToFrontend() + await helper.expectTextVisible('Hello World HTML snippet in body start!') + await helper.expectElementCount('text=Hello World HTML snippet in body start!', 1) + await helper.expectTextBeforeElement('Hello World HTML snippet in body start!', SELECTORS.THEME_MAIN_WRAPPER) + }) + + test('HTML snippet is evaluating correctly at body end', async () => { + await helper.createAndActivateSnippet({ + name: snippetName, + code: '

Hello World HTML snippet in body end!

', + type: 'HTML', + location: 'SITE_FOOTER' + }) + + await helper.navigateToFrontend() + await helper.expectTextVisible('Hello World HTML snippet in body end!') + await helper.expectElementCount('text=Hello World HTML snippet in body end!', 1) + await helper.expectTextAfterElement('Hello World HTML snippet in body end!', SELECTORS.THEME_MAIN_WRAPPER) + }) + test('HTML snippet works with shortcode in editor', async ({ page }) => { const snippetId = await createHtmlSnippetForEditor(helper, page, snippetName) const pageUrl = await createPageWithShortcode(page, snippetId, snippetName) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index 7933ae7f..6b835e60 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -497,6 +497,56 @@ export class SnippetsTestHelper { await expect(this.page.locator('body')).not.toContainText(text) } + async expectTextBeforeElement(text: string, selector: string): Promise { + const precedes = await this.page.evaluate( + ({ text, selector }) => { + const node = document.evaluate( + `//p[contains(text(),"${text}")]`, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue + + const reference = document.querySelector(selector) + + if (!node || !reference) { + return null + } + + return !!(reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_PRECEDING) + }, + { text, selector } + ) + + expect(precedes).toBe(true) + } + + async expectTextAfterElement(text: string, selector: string): Promise { + const follows = await this.page.evaluate( + ({ text, selector }) => { + const node = document.evaluate( + `//p[contains(text(),"${text}")]`, + document, + null, + XPathResult.FIRST_ORDERED_NODE_TYPE, + null + ).singleNodeValue + + const reference = document.querySelector(selector) + + if (!node || !reference) { + return null + } + + return !!(reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) + }, + { text, selector } + ) + + expect(follows).toBe(true) + } + /** * Create a complete snippet with save and activate */ diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 604a5a17..3e8df7ef 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -16,7 +16,8 @@ export const SELECTORS = { DELETE_ACTION: '.row-actions button:has-text("Trash")', EXPORT_ACTION: '.row-actions button:has-text("Export")', - ADMIN_BAR: '#wpadminbar' + ADMIN_BAR: '#wpadminbar', + THEME_MAIN_WRAPPER: '.wp-site-blocks' } export const TIMEOUTS = { @@ -43,8 +44,9 @@ export const SNIPPET_TYPES = { } export const SNIPPET_LOCATIONS = { + SITE_HEADER: 'In site header ( section)', + SITE_BODY: 'In site content (start of )', SITE_FOOTER: 'In site footer (end of )', - SITE_HEADER: 'In site section', IN_EDITOR: 'Where inserted in editor', ADMIN_ONLY: 'Only run in administration area', FRONTEND_ONLY: 'Only run on site front-end',