diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml new file mode 100644 index 00000000..7103c136 --- /dev/null +++ b/.github/workflows/playwright-test.yml @@ -0,0 +1,104 @@ +name: Playwright Test Runner + +on: + workflow_call: + inputs: + test-mode: + required: true + type: string + description: 'Test mode: default or file-based-execution' + project-name: + required: true + type: string + description: 'Playwright project name to run' + +jobs: + playwright-test: + name: Playwright tests (${{ inputs.test-mode == 'default' && 'Default Mode' || 'File-based Execution' }}) + runs-on: ubuntu-22.04 + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up PHP + uses: codesnippetspro/setup-php@v2 + with: + php-version: "8.1" + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Compute dependency hash + id: deps-hash + run: | + set -euo pipefail + tmpfile=$(mktemp) + for f in src/composer.lock package-lock.json; do + if [ -f "$f" ]; then + cat "$f" >> "$tmpfile" + fi + done + if [ -s "$tmpfile" ]; then + deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) + else + deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) + fi + echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" + + - name: Get build cache + id: deps-cache + uses: actions/cache/restore@v4 + with: + path: | + node_modules + src/vendor + key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} + restore-keys: | + ${{ runner.os }}-deps- + + - name: Install workflow dependencies (wp-env, playwright) + if: steps.deps-cache.outputs.cache-hit != 'true' + run: npm run prepare-environment:ci && npm run bundle + + - name: Save vendor and node_modules cache + if: steps.deps-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + src/vendor + node_modules + key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} + + - name: Start WordPress environment + run: | + npx wp-env start + + - name: Activate code-snippets plugin + run: npx wp-env run cli wp plugin activate code-snippets + + - name: WordPress debug information + run: | + npx wp-env run cli wp core version + npx wp-env run cli wp --info + + - name: Install playwright/test + run: | + npx playwright install chromium + + - name: Run Playwright tests + run: npm run test:playwright -- --project=${{ inputs.project-name }} + + - name: Stop WordPress environment + if: always() + run: npx wp-env stop + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-test-results-${{ inputs.test-mode }} + path: test-results/ + if-no-files-found: ignore + retention-days: 2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 37cc3aa6..4d3274f8 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -24,107 +24,31 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - Playwright: - name: Playwright test on PHP 8.1 - runs-on: ubuntu-22.04 + playwright-default: if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - - name: Set up PHP - uses: codesnippetspro/setup-php@v2 - with: - php-version: "8.1" - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version-file: .node-version - cache: 'npm' - - - name: Compute dependency hash - id: deps-hash - run: | - set -euo pipefail - # concatenate existing lock files (src/composer.lock and package-lock.json) - tmpfile=$(mktemp) - for f in src/composer.lock package-lock.json; do - if [ -f "$f" ]; then - cat "$f" >> "$tmpfile" - fi - done - if [ -s "$tmpfile" ]; then - deps_hash=$(shasum -a 1 "$tmpfile" | awk '{print $1}' | cut -c1-8) - else - # no lock files found, fall back to short commit sha - deps_hash=$(echo "${GITHUB_SHA:-unknown}" | cut -c1-8) - fi - echo "deps_hash=$deps_hash" >> "$GITHUB_OUTPUT" - - - name: Get build cache - id: deps-cache - uses: actions/cache/restore@v4 - with: - path: | - node_modules - src/vendor - key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} - restore-keys: | - ${{ runner.os }}-deps- - - - name: Install workflow dependencies (wp-env, playwright) - if: steps.deps-cache.outputs.cache-hit != 'true' - run: npm run prepare-environment:ci && npm run bundle - - - name: Save vendor and node_modules cache - if: steps.deps-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v4 - with: - path: | - src/vendor - node_modules - key: ${{ runner.os }}-deps-${{ steps.deps-hash.outputs.deps_hash }} - - - name: Start WordPress environment - run: | - npx wp-env start + uses: ./.github/workflows/playwright-test.yml + with: + test-mode: 'default' + project-name: 'chromium-db-snippets' - - name: Activate code-snippets plugin - run: npx wp-env run cli wp plugin activate code-snippets - - - name: WordPress debug information - run: | - npx wp-env run cli wp core version - npx wp-env run cli wp --info - - - name: Install playwright/test - run: | - npx playwright install chromium - - - name: Run Playwright tests - run: npm run test:playwright - - - name: Stop WordPress environment - if: always() - run: npx wp-env stop - - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-test-results - path: test-results/ - if-no-files-found: ignore - retention-days: 2 + playwright-file-based-execution: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + uses: ./.github/workflows/playwright-test.yml + with: + test-mode: 'file-based-execution' + project-name: 'chromium-file-based-snippets' test-result: - needs: [Playwright] - if: always() && (needs.Playwright.result != 'skipped') + needs: [playwright-default, playwright-file-based-execution] + if: always() && (needs.playwright-default.result != 'skipped' || needs.playwright-file-based-execution.result != 'skipped') runs-on: ubuntu-22.04 - name: Playwright - Test Results + name: Playwright - Test Results Summary steps: - - name: Test status - run: echo "Test status is - ${{ needs.Playwright.result }}" - - name: Check Playwright status - if: ${{ needs.Playwright.result != 'success' && needs.Playwright.result != 'skipped' }} + - name: Test status summary + run: | + echo "Default Mode: ${{ needs.playwright-default.result }}" + echo "File-based Execution: ${{ needs.playwright-file-based-execution.result }}" + + - name: Check overall status + if: ${{ (needs.playwright-default.result != 'success' && needs.playwright-default.result != 'skipped') || (needs.playwright-file-based-execution.result != 'success' && needs.playwright-file-based-execution.result != 'skipped') }} run: exit 1 diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index 0fa97e78..1abe2c71 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -77,6 +77,13 @@ class Plugin { */ public Licensing $licensing; + /** + * Handles snippet handler registration. + * + * @var Snippet_Handler_Registry + */ + public Snippet_Handler_Registry $snippet_handler_registry; + /** * Class constructor * @@ -130,6 +137,18 @@ public function load_plugin() { // Cloud List Table shared functions. require_once $includes_path . '/cloud/list-table-shared-ops.php'; + // Snippet files. + $this->snippet_handler_registry = new Snippet_Handler_Registry( [ + 'php' => new Php_Snippet_Handler(), + 'html' => new Html_Snippet_Handler(), + ] ); + + $fs = new WordPress_File_System_Adapter(); + + $config_repo = new Snippet_Config_Repository( $fs ); + + ( new Snippet_Files( $this->snippet_handler_registry, $fs, $config_repo ) )->register_hooks(); + $this->front_end = new Front_End(); $this->cloud_api = new Cloud_API(); diff --git a/src/php/class-snippet.php b/src/php/class-snippet.php index abe11b44..bd3269ff 100644 --- a/src/php/class-snippet.php +++ b/src/php/class-snippet.php @@ -189,7 +189,7 @@ public static function get_type_from_scope( string $scope ): string { * * @return string The snippet type – will be a filename extension. */ - protected function get_type(): string { + public function get_type(): string { return self::get_type_from_scope( $this->scope ); } diff --git a/src/php/evaluation/class-evaluate-content.php b/src/php/evaluation/class-evaluate-content.php index f76df4c8..da7a0779 100644 --- a/src/php/evaluation/class-evaluate-content.php +++ b/src/php/evaluation/class-evaluate-content.php @@ -4,6 +4,9 @@ use Code_Snippets\DB; use Code_Snippets\Snippet; +use Code_Snippets\Settings; +use Code_Snippets\Snippet_Files; +use function Code_Snippets\code_snippets; /** * Class for evaluating content snippets. @@ -40,8 +43,13 @@ public function __construct( DB $db ) { * Initialise class functions. */ public function init() { - add_action( 'wp_head', [ $this, 'load_head_content' ] ); - add_action( 'wp_footer', [ $this, 'load_footer_content' ] ); + if ( Snippet_Files::is_active() ) { + add_action( 'wp_head', [ $this, 'load_head_content_from_flat_files' ] ); + add_action( 'wp_footer', [ $this, 'load_footer_content_from_flat_files' ] ); + } else { + add_action( 'wp_head', [ $this, 'load_head_content' ] ); + add_action( 'wp_footer', [ $this, 'load_footer_content' ] ); + } } /** @@ -77,4 +85,46 @@ public function load_head_content() { public function load_footer_content() { $this->print_content_snippets( 'footer-content' ); } + + public function load_head_content_from_flat_files() { + $this->load_content_snippets_from_flat_files( 'head-content' ); + } + + public function load_footer_content_from_flat_files() { + $this->load_content_snippets_from_flat_files( 'footer-content' ); + } + + private function populate_active_snippets_from_flat_files() { + $handler = code_snippets()->snippet_handler_registry->get_handler( 'html' ); + $dir_name = $handler->get_dir_name(); + $ext = $handler->get_file_extension(); + + $scopes = [ 'head-content', 'footer-content' ]; + $all_snippets = Snippet_Files::get_active_snippets_from_flat_files( $scopes, $dir_name ); + + foreach ( $all_snippets as $snippet ) { + $scope = $snippet['scope']; + + // Add file path information to the snippet for later use + $table_name = Snippet_Files::get_hashed_table_name( $snippet['table'] ); + $base_path = Snippet_Files::get_base_dir( $table_name, $dir_name ); + $snippet['file_path'] = $base_path . '/' . $snippet['id'] . '.' . $ext; + + $this->active_snippets[ $scope ][] = $snippet; + } + } + + private function load_content_snippets_from_flat_files( string $scope ) { + if ( is_null( $this->active_snippets ) ) { + $this->populate_active_snippets_from_flat_files(); + } + + if ( ! isset( $this->active_snippets[ $scope ] ) ) { + return; + } + + foreach ( $this->active_snippets[ $scope ] as $snippet ) { + require_once $snippet['file_path']; + } + } } diff --git a/src/php/evaluation/class-evaluate-functions.php b/src/php/evaluation/class-evaluate-functions.php index 0009961e..eca66a08 100644 --- a/src/php/evaluation/class-evaluate-functions.php +++ b/src/php/evaluation/class-evaluate-functions.php @@ -4,9 +4,13 @@ use Code_Snippets\DB; use Code_Snippets\REST_API\Snippets_REST_Controller; +use Code_Snippets\Settings; +use Code_Snippets\Snippet_Files; use function Code_Snippets\clean_active_snippets_cache; use function Code_Snippets\clean_snippets_cache; use function Code_Snippets\execute_snippet; +use function Code_Snippets\code_snippets; +use function Code_Snippets\execute_snippet_from_flat_file; /** * Class for evaluating functions snippets. @@ -104,6 +108,28 @@ private function quick_deactivate_snippet( int $snippet_id, string $table_name ) [ '%d' ] ); clean_snippets_cache( $table_name ); + + $network = $table_name === $this->db->ms_table; + do_action( 'code_snippets/deactivate_snippet', $snippet_id, $network ); + } + } + + private function evaluate_snippet_flat_file( array $snippet, string $file_path, ?array $edit_snippet = null ) { + $snippet_id = $snippet['id']; + $code = $snippet['code']; + $table_name = $snippet['table']; + + // If the snippet is a single-use snippet, deactivate it before execution to ensure that the process always happens. + if ( 'single-use' === $snippet['scope'] ) { + $this->quick_deactivate_snippet( $snippet_id, $table_name ); + } + + if ( ! is_null( $edit_snippet ) && $edit_snippet['id'] === $snippet_id && $edit_snippet['table'] === $table_name ) { + return; + } + + if ( apply_filters( 'code_snippets/allow_execute_snippet', true, $snippet_id, $table_name ) ) { + execute_snippet_from_flat_file( $code, $file_path, $snippet_id ); } } @@ -117,6 +143,14 @@ public function evaluate_early(): bool { return false; } + if ( Snippet_Files::is_active() ) { + return $this->evaluate_file_snippets(); + } + + return $this->evaluate_db_snippets(); + } + + public function evaluate_db_snippets(): bool { $scopes = [ 'global', 'single-use', is_admin() ? 'admin' : 'front-end' ]; $active_snippets = $this->db->fetch_active_snippets( $scopes ); $edit_snippet = $this->get_currently_editing_snippet(); @@ -139,4 +173,21 @@ public function evaluate_early(): bool { return true; } + + private function evaluate_file_snippets(): bool { + $type = 'php'; + $scopes = [ 'global', 'single-use', is_admin() ? 'admin' : 'front-end' ]; + $snippets = Snippet_Files::get_active_snippets_from_flat_files( $scopes, $type ); + $edit_snippet = $this->get_currently_editing_snippet(); + + foreach ( $snippets as $snippet ) { + $table_name = Snippet_Files::get_hashed_table_name( $snippet['table'] ); + $base_path = Snippet_Files::get_base_dir( $table_name, $type ); + $file = $base_path . '/' . $snippet['id'] . '.' . $type; + + $this->evaluate_snippet_flat_file( $snippet, $file, $edit_snippet ); + } + + return true; + } } diff --git a/src/php/flat-files/classes/class-config-repository.php b/src/php/flat-files/classes/class-config-repository.php new file mode 100644 index 00000000..07ab5452 --- /dev/null +++ b/src/php/flat-files/classes/class-config-repository.php @@ -0,0 +1,55 @@ +fs = $fs; + } + + public function load( string $base_dir ): array { + $config_file_path = trailingslashit( $base_dir ) . static::CONFIG_FILE_NAME; + + if ( is_file( $config_file_path ) ) { + if ( function_exists( 'opcache_invalidate' ) ) { + opcache_invalidate( $config_file_path, true ); + } + return require $config_file_path; + } + return []; + } + + public function save( string $base_dir, array $active_snippets ): void { + $config_file_path = trailingslashit( $base_dir ) . static::CONFIG_FILE_NAME; + + ksort( $active_snippets ); + + $file_content = "fs->put_contents( $config_file_path, $file_content, FS_CHMOD_FILE ); + + if ( is_file( $config_file_path ) ) { + if ( function_exists( 'opcache_invalidate' ) ) { + opcache_invalidate( $config_file_path, true ); + } + } + } + + public function update( string $base_dir, Snippet $snippet, ?bool $remove = false ): void { + $active_snippets = $this->load( $base_dir ); + + if ( $remove ) { + unset( $active_snippets[ $snippet->id ] ); + } else { + $active_snippets[ $snippet->id ] = $snippet->get_fields(); + } + + $this->save( $base_dir, $active_snippets ); + } +} diff --git a/src/php/flat-files/classes/class-file-system-adapter.php b/src/php/flat-files/classes/class-file-system-adapter.php new file mode 100644 index 00000000..62055253 --- /dev/null +++ b/src/php/flat-files/classes/class-file-system-adapter.php @@ -0,0 +1,47 @@ +fs = $wp_filesystem; + } + + public function put_contents( string $path, string $contents, $chmod ) { + return $this->fs->put_contents( $path, $contents, $chmod ); + } + + public function exists( string $path ): bool { + return $this->fs->exists( $path ); + } + + public function delete( $file, $recursive = false, $type = false ): bool { + return $this->fs->delete( $file, $recursive, $type ); + } + + public function is_dir( string $path ): bool { + return $this->fs->is_dir( $path ); + } + + public function mkdir( string $path, $chmod ) { + return $this->fs->mkdir( $path, $chmod ); + } + + public function rmdir( string $path, bool $recursive = false ): bool { + return $this->fs->rmdir( $path, $recursive ); + } + + public function chmod( string $path, $chmod ): bool { + return $this->fs->chmod( $path, $chmod ); + } + + public function is_writable( string $path ): bool { + return $this->fs->is_writable( $path ); + } +} diff --git a/src/php/flat-files/classes/class-snippet-files.php b/src/php/flat-files/classes/class-snippet-files.php new file mode 100644 index 00000000..a1743666 --- /dev/null +++ b/src/php/flat-files/classes/class-snippet-files.php @@ -0,0 +1,486 @@ +handler_registry = $handler_registry; + $this->fs = $fs; + $this->config_repo = $config_repo; + } + + /** + * Check if flat files are enabled by checking for the flag file. + * This avoids database calls for better performance. + * + * @return bool True if flat files are enabled, false otherwise. + */ + public static function is_active(): bool { + $flag_file_path = self::get_flag_file_path(); + return file_exists( $flag_file_path ); + } + + private static function get_flag_file_path(): string { + return self::get_base_dir() . '/' . self::ENABLED_FLAG_FILE; + } + + private function handle_enabled_file_flag( bool $enabled ): void { + $flag_file_path = self::get_flag_file_path(); + + if ( $enabled ) { + $base_dir = self::get_base_dir(); + $this->maybe_create_directory( $base_dir ); + + $this->fs->put_contents( $flag_file_path, '', FS_CHMOD_FILE ); + } else { + $this->delete_file( $flag_file_path ); + } + } + + public function register_hooks(): void { + if ( ! $this->fs->is_writable( WP_CONTENT_DIR ) ) { + return; + } + + if ( self::is_active() ) { + add_action( 'code_snippets/create_snippet', [ $this, 'handle_snippet' ], 10, 2 ); + add_action( 'code_snippets/update_snippet', [ $this, 'handle_snippet' ], 10, 2 ); + add_action( 'code_snippets/delete_snippet', [ $this, 'delete_snippet' ], 10, 2 ); + add_action( 'code_snippets/activate_snippet', [ $this, 'activate_snippet' ], 10, 1 ); + add_action( 'code_snippets/deactivate_snippet', [ $this, 'deactivate_snippet' ], 10, 2 ); + add_action( 'code_snippets/activate_snippets', [ $this, 'activate_snippets' ], 10, 2 ); + + add_action( 'updated_option', [ $this, 'sync_active_shared_network_snippets' ], 10, 3 ); + add_action( 'add_option', [ $this, 'sync_active_shared_network_snippets_add' ], 10, 2 ); + } + + add_filter( 'code_snippets_settings_fields', [ $this, 'add_settings_fields' ], 10, 1 ); + add_action( 'code_snippets/settings_updated', [ $this, 'create_all_flat_files' ], 10, 2 ); + } + + public function activate_snippets( $valid_snippets, $table ): void { + foreach ( $valid_snippets as $snippet ) { + $snippet->active = true; + $this->handle_snippet( $snippet, $table ); + } + } + + public function handle_snippet( Snippet $snippet, string $table ): void { + if ( 0 === $snippet->id ) { + return; + } + + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( $table ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + $this->maybe_create_directory( $base_dir ); + + $file_path = $this->get_snippet_file_path( $base_dir, $snippet->id, $handler->get_file_extension() ); + + $contents = $handler->wrap_code( $snippet->code ); + + $this->fs->put_contents( $file_path, $contents, FS_CHMOD_FILE ); + + $this->config_repo->update( $base_dir, $snippet ); + } + + public function delete_snippet( Snippet $snippet, bool $network ): void { + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( $network ) ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + + $file_path = $this->get_snippet_file_path( $base_dir, $snippet->id, $handler->get_file_extension() ); + $this->delete_file( $file_path ); + + $this->config_repo->update( $base_dir, $snippet, true ); + } + + public function activate_snippet( Snippet $snippet ): void { + $snippet = get_snippet( $snippet->id, $snippet->network ); + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( $snippet->network ) ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + + $this->maybe_create_directory( $base_dir ); + + $file_path = $this->get_snippet_file_path( $base_dir, $snippet->id, $handler->get_file_extension() ); + + $contents = $handler->wrap_code( $snippet->code ); + + $this->fs->put_contents( $file_path, $contents, FS_CHMOD_FILE ); + + $this->config_repo->update( $base_dir, $snippet ); + } + + public function deactivate_snippet( int $snippet_id, bool $network ): void { + $snippet = get_snippet( $snippet_id, $network ); + $handler = $this->handler_registry->get_handler( $snippet->type ); + + if ( ! $handler ) { + return; + } + + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( $network ) ); + $base_dir = self::get_base_dir( $table, $handler->get_dir_name() ); + + $this->config_repo->update( $base_dir, $snippet ); + } + + public static function get_base_dir( string $table = '', string $snippet_type = '' ): string { + $base_dir = WP_CONTENT_DIR . '/code-snippets'; + + if ( ! empty( $table ) ) { + $base_dir .= '/' . $table; + } + + if ( ! empty( $snippet_type ) ) { + $base_dir .= '/' . $snippet_type; + } + + return $base_dir; + } + + public static function get_base_url( string $table = '', string $snippet_type = '' ): string { + $base_url = WP_CONTENT_URL . '/code-snippets'; + + if ( ! empty( $table ) ) { + $base_url .= '/' . $table; + } + + if ( ! empty( $snippet_type ) ) { + $base_url .= '/' . $snippet_type; + } + + return $base_url; + } + + private function maybe_create_directory( string $dir ): void { + if ( ! $this->fs->is_dir( $dir ) ) { + $result = wp_mkdir_p( $dir ); + + if ( $result ) { + $this->fs->chmod( $dir, FS_CHMOD_DIR ); + } + } + } + + private function get_snippet_file_path( string $base_dir, int $snippet_id, string $ext ): string { + return trailingslashit( $base_dir ) . $snippet_id . '.' . $ext; + } + + private function delete_file( string $file_path ): void { + if ( $this->fs->exists( $file_path ) ) { + $this->fs->delete( $file_path ); + } + } + + public function sync_active_shared_network_snippets( $option, $old_value, $value ): void { + if ( 'active_shared_network_snippets' !== $option ) { + return; + } + + $this->create_active_shared_network_snippets_file( $value ); + } + + public function sync_active_shared_network_snippets_add( $option, $value ): void { + if ( 'active_shared_network_snippets' !== $option ) { + return; + } + + $this->create_active_shared_network_snippets_file( $value ); + } + + private function create_active_shared_network_snippets_file( $value ): void { + $table = self::get_hashed_table_name( code_snippets()->db->get_table_name( false ) ); + $base_dir = self::get_base_dir( $table ); + + $this->maybe_create_directory( $base_dir ); + + $file_path = trailingslashit( $base_dir ) . 'active-shared-network-snippets.php'; + $file_content = "fs->put_contents( $file_path, $file_content, FS_CHMOD_FILE ); + } + + public static function get_hashed_table_name( string $table ): string { + return wp_hash( $table ); + } + + public static function get_active_snippets_from_flat_files( + array $scopes = [], + $snippet_type = 'php' + ): array { + $active_snippets = []; + $db = code_snippets()->db; + + $table = self::get_hashed_table_name( $db->get_table_name() ); + $snippets = self::load_active_snippets_from_file( + $table, + $snippet_type, + $scopes + ); + + if ( $snippets ) { + foreach ( $snippets as $snippet ) { + $active_snippets[] = [ + 'id' => intval( $snippet['id'] ), + 'code' => $snippet['code'], + 'scope' => $snippet['scope'], + 'table' => $db->table, + 'network' => false, + 'priority' => intval( $snippet['priority'] ), + 'condition_id' => intval( $snippet['condition_id'] ), + ]; + } + } + + if ( is_multisite() ) { + $ms_table = self::get_hashed_table_name( $db->get_table_name( true ) ); + + $root_base_dir = self::get_base_dir( $table ); + $active_shared_ids_file_path = $root_base_dir . '/active-shared-network-snippets.php'; + $active_shared_ids = is_file( $active_shared_ids_file_path ) + ? require $active_shared_ids_file_path + : []; + + $ms_snippets = self::load_active_snippets_from_file( + $ms_table, + $snippet_type, + $scopes, + $active_shared_ids + ); + + if ( $ms_snippets ) { + $active_shared_ids = is_array( $active_shared_ids ) + ? array_map( 'intval', $active_shared_ids ) + : []; + + foreach ( $ms_snippets as $snippet ) { + $id = intval( $snippet['id'] ); + + if ( ! $snippet['active'] && ! in_array( $id, $active_shared_ids, true ) ) { + continue; + } + + $active_snippets[] = [ + 'id' => $id, + 'code' => $snippet['code'], + 'scope' => $snippet['scope'], + 'table' => $db->ms_table, + 'network' => true, + 'priority' => intval( $snippet['priority'] ), + 'condition_id' => intval( $snippet['condition_id'] ), + ]; + } + + self::sort_active_snippets( $active_snippets, $db ); + } + } + + return $active_snippets; + } + + private static function sort_active_snippets( array &$active_snippets, $db ): void { + $comparisons = [ + function ( array $a, array $b ) { + return $a['priority'] <=> $b['priority']; + }, + function ( array $a, array $b ) use ( $db ) { + $a_table = $a['table'] === $db->ms_table ? 0 : 1; + $b_table = $b['table'] === $db->ms_table ? 0 : 1; + return $a_table <=> $b_table; + }, + function ( array $a, array $b ) { + return $a['id'] <=> $b['id']; + }, + ]; + + usort( + $active_snippets, + static function ( $a, $b ) use ( $comparisons ) { + foreach ( $comparisons as $comparison ) { + $result = $comparison( $a, $b ); + if ( 0 !== $result ) { + return $result; + } + } + + return 0; + } + ); + } + + private static function load_active_snippets_from_file( + string $table, + string $snippet_type, + array $scopes, + ?array $active_shared_ids = null + ): array { + $snippets = []; + $db = code_snippets()->db; + + $base_dir = self::get_base_dir( $table, $snippet_type ); + $snippets_file_path = $base_dir . '/index.php'; + + if ( ! is_file( $snippets_file_path ) ) { + return $snippets; + } + + $cache_key = sprintf( + 'active_snippets_%s_%s', + sanitize_key( join( '_', $scopes ) ), + self::get_hashed_table_name( $db->table ) === $table ? $db->table : $db->ms_table + ); + + $cached_snippets = wp_cache_get( $cache_key, CACHE_GROUP ); + + if ( is_array( $cached_snippets ) ) { + return $cached_snippets; + } + + $file_snippets = require $snippets_file_path; + + $filtered_snippets = array_filter( + $file_snippets, + function ( $snippet ) use ( $scopes, $active_shared_ids ) { + $is_active = $snippet['active']; + + if ( null !== $active_shared_ids ) { + $is_active = $is_active || in_array( + intval( $snippet['id'] ), + $active_shared_ids, + true + ); + } + + return ( $is_active || 'condition' === $snippet['scope'] ) && in_array( $snippet['scope'], $scopes, true ); + } + ); + + wp_cache_set( $cache_key, $filtered_snippets, CACHE_GROUP ); + + return $filtered_snippets; + } + + public function add_settings_fields( array $fields ): array { + $fields['general']['enable_flat_files'] = [ + 'name' => __( 'Enable file-based execution', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Snippets will be executed directly from files instead of the database.', 'code-snippets' ) . ' ' . sprintf( + '%s', + esc_url( 'https://codesnippets.pro/doc/file-based-execution/' ), + __( 'Learn more.', 'code-snippets' ) + ), + ]; + + return $fields; + } + + public function create_all_flat_files( array $settings, array $input ): void { + if ( ! isset( $settings['general']['enable_flat_files'] ) ) { + return; + } + + $this->handle_enabled_file_flag( $settings['general']['enable_flat_files'] ); + + if ( ! $settings['general']['enable_flat_files'] ) { + return; + } + + $this->create_snippet_flat_files(); + $this->create_active_shared_network_snippets_config_file(); + } + + private function create_snippet_flat_files(): void { + $db = code_snippets()->db; + + $scopes = Snippet::get_all_scopes(); + + $data = $db->fetch_active_snippets( $scopes ); + + foreach ( $data as $snippet ) { + $snippet_obj = get_snippet( $snippet['id'], $db->ms_table === $snippet['table'] ); + $this->handle_snippet( $snippet_obj, $snippet['table'] ); + } + + if ( is_multisite() ) { + $current_blog_id = get_current_blog_id(); + + $sites = get_sites( [ 'fields' => 'ids' ] ); + foreach ( $sites as $site_id ) { + switch_to_blog( $site_id ); + $db->set_table_vars(); + + $site_data = $db->fetch_active_snippets( $scopes ); + foreach ( $site_data as $table_name => $active_snippets ) { + foreach ( $active_snippets as $snippet ) { + $snippet_obj = get_snippet( $snippet['id'], false ); + $this->handle_snippet( $snippet_obj, $table_name ); + } + } + + restore_current_blog(); + } + + $db->set_table_vars(); + } + } + + private function create_active_shared_network_snippets_config_file(): void { + if ( is_multisite() ) { + $current_blog_id = get_current_blog_id(); + $sites = get_sites( [ 'fields' => 'ids' ] ); + $db = code_snippets()->db; + + foreach ( $sites as $site_id ) { + switch_to_blog( $site_id ); + $db->set_table_vars(); + + $active_shared_network_snippets = get_option( 'active_shared_network_snippets' ); + if ( false !== $active_shared_network_snippets ) { + $this->create_active_shared_network_snippets_file( $active_shared_network_snippets ); + } + + restore_current_blog(); + } + + $db->set_table_vars(); + } else { + $active_shared_network_snippets = get_option( 'active_shared_network_snippets' ); + if ( false !== $active_shared_network_snippets ) { + $this->create_active_shared_network_snippets_file( $active_shared_network_snippets ); + } + } + } +} diff --git a/src/php/flat-files/handlers/html-snippet-handler.php b/src/php/flat-files/handlers/html-snippet-handler.php new file mode 100644 index 00000000..d7a4446a --- /dev/null +++ b/src/php/flat-files/handlers/html-snippet-handler.php @@ -0,0 +1,17 @@ +\n\n" . $code; + } +} diff --git a/src/php/flat-files/handlers/php-snippet-handler.php b/src/php/flat-files/handlers/php-snippet-handler.php new file mode 100644 index 00000000..5d306392 --- /dev/null +++ b/src/php/flat-files/handlers/php-snippet-handler.php @@ -0,0 +1,17 @@ + $handler ) { + $this->register_handler( $type, $handler ); + } + } + + /** + * Registers a handler for a snippet type. + * + * @param string $type + * @param Snippet_Type_Handler_Interface $handler + * @return void + */ + public function register_handler( string $type, Snippet_Type_Handler_Interface $handler ): void { + $this->handlers[ $type ] = $handler; + } + + /** + * Gets the handler for a snippet type. + * + * @param string $type + * + * @return Snippet_Type_Handler_Interface|null + */ + public function get_handler( string $type ): ?Snippet_Type_Handler_Interface { + if ( ! isset( $this->handlers[ $type ] ) ) { + return null; + } + + return $this->handlers[ $type ]; + } +} diff --git a/src/php/front-end/class-front-end.php b/src/php/front-end/class-front-end.php index 74ca529a..72003164 100644 --- a/src/php/front-end/class-front-end.php +++ b/src/php/front-end/class-front-end.php @@ -227,6 +227,20 @@ protected function convert_boolean_attribute_flags( array $atts, array $boolean_ return $atts; } + /** + * Build the file path for a snippet's flat file. + * + * @param string $table_name Table name for the snippet. + * @param Snippet $snippet Snippet object. + * + * @return string Full file path for the snippet. + */ + private function build_snippet_flat_file_path( string $table_name, Snippet $snippet ): string { + $handler = code_snippets()->snippet_handler_registry->get_handler( $snippet->get_type() ); + + return Snippet_Files::get_base_dir( $table_name, $handler->get_dir_name() ) . '/' . $snippet->id . '.' . $handler->get_file_extension(); + } + /** * Evaluate the code from a content shortcode. * @@ -240,6 +254,20 @@ protected function evaluate_shortcode_content( Snippet $snippet, array $atts ): return $snippet->code; } + if ( ! Snippet_Files::is_active() ) { + return $this->evaluate_shortcode_from_db( $snippet, $atts ); + } + + $network = DB::validate_network_param( $snippet->network ); + $table_name = Snippet_Files::get_hashed_table_name( code_snippets()->db->get_table_name( $network ) ); + $filepath = $this->build_snippet_flat_file_path( $table_name, $snippet ); + + return file_exists( $filepath ) + ? $this->evaluate_shortcode_from_flat_file( $filepath, $atts ) + : $this->evaluate_shortcode_from_db( $snippet, $atts ); + } + + private function evaluate_shortcode_from_db( Snippet $snippet, array $atts ): string { /** * Avoiding extract is typically recommended, however in this situation we want to make it easy for snippet * authors to use custom attributes. @@ -254,6 +282,46 @@ protected function evaluate_shortcode_content( Snippet $snippet, array $atts ): return ob_get_clean(); } + private function evaluate_shortcode_from_flat_file( $filepath, array $atts ): string { + ob_start(); + + ( function( $atts ) use ( $filepath ) { + /** + * Avoiding extract is typically recommended, however in this situation we want to make it easy for snippet + * authors to use custom attributes. + * + * @phpcs:disable WordPress.PHP.DontExtract.extract_extract + */ + extract( $atts ); + require_once $filepath; + } )( $atts ); + + return ob_get_clean(); + } + + private function get_snippet( int $id, bool $network, string $snippet_type ): Snippet { + if ( ! Snippet_Files::is_active() ) { + return get_snippet( $id, $network ); + } + + $validated_network = DB::validate_network_param( $network ); + $table_name = Snippet_Files::get_hashed_table_name( code_snippets()->db->get_table_name( $validated_network ) ); + $handler = code_snippets()->snippet_handler_registry->get_handler( $snippet_type ); + $config_filepath = Snippet_Files::get_base_dir( $table_name, $handler->get_dir_name() ) . '/index.php'; + + if ( file_exists( $config_filepath ) ) { + $config = require_once $config_filepath; + $snippet_data = $config[ $id ] ?? null; + + if ( $snippet_data ) { + $snippet = new Snippet( $snippet_data ); + return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network ); + } + } + + return get_snippet( $id, $network ); + } + /** * Render the value of a content shortcode * @@ -284,7 +352,7 @@ public function render_content_shortcode( array $atts ): string { return $this->invalid_id_warning( $id ); } - $snippet = get_snippet( $id, (bool) $atts['network'] ); + $snippet = $this->get_snippet( $id, (bool) $atts['network'], 'html' ); // Render the source code if this is not a shortcode snippet. if ( 'content' !== $snippet->scope ) { @@ -426,7 +494,7 @@ public function render_source_shortcode( array $atts ): string { return $this->invalid_id_warning( $id ); } - $snippet = get_snippet( $id, (bool) $atts['network'] ); + $snippet = $this->get_snippet( $id, (bool) $atts['network'], 'html' ); return $this->render_snippet_source( $snippet, $atts ); } diff --git a/src/php/settings/settings.php b/src/php/settings/settings.php index cf94f8b9..e3565b46 100644 --- a/src/php/settings/settings.php +++ b/src/php/settings/settings.php @@ -330,6 +330,8 @@ function sanitize_settings( array $input ): array { __( 'Settings saved.', 'code-snippets' ), 'updated' ); + + do_action( 'code_snippets/settings_updated', $settings, $input ); } return $settings; diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 10581eb3..d23ffe36 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -315,7 +315,7 @@ function activate_snippet( int $id, ?bool $network = null ) { } update_shared_network_snippets( [ $snippet ] ); - do_action( 'code_snippets/activate_snippet', $snippet ); + do_action( 'code_snippets/activate_snippet', $snippet, $network ); clean_snippets_cache( $table_name ); return $snippet; } @@ -393,7 +393,7 @@ function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet { $network = DB::validate_network_param( $network ); $table = code_snippets()->db->get_table_name( $network ); - // Set the snippet to active. + // Set the snippet to inactive. $result = $wpdb->update( $table, array( 'active' => '0' ), @@ -434,6 +434,8 @@ function delete_snippet( int $id, ?bool $network = null ): bool { $network = DB::validate_network_param( $network ); $table = code_snippets()->db->get_table_name( $network ); + $snippet = get_snippet( $id, $network ); + $result = $wpdb->delete( $table, array( 'id' => $id ), @@ -441,7 +443,7 @@ function delete_snippet( int $id, ?bool $network = null ): bool { ); if ( $result ) { - do_action( 'code_snippets/delete_snippet', $id, $network ); + do_action( 'code_snippets/delete_snippet', $snippet, $network ); clean_snippets_cache( $table ); code_snippets()->cloud_api->delete_snippet_from_transient_data( $id ); } @@ -672,3 +674,17 @@ function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = do_action( 'code_snippets/update_snippet', $snippet->id, $table ); clean_snippets_cache( $table ); } + +function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force = false ) { + if ( ! is_file( $file ) ) { + return execute_snippet( $code, $id, $force ); + } + + if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) { + return false; + } + + require_once $file; + + do_action( 'code_snippets/after_execute_snippet_from_flat_file', $file, $id ); +} diff --git a/src/php/uninstall.php b/src/php/uninstall.php index 4da58cfe..5afaefb6 100644 --- a/src/php/uninstall.php +++ b/src/php/uninstall.php @@ -18,7 +18,7 @@ function complete_uninstall_enabled(): bool { $unified = false; if ( is_multisite() ) { - $menu_perms = get_site_option( 'menu_items', array() ); + $menu_perms = get_site_option( 'menu_items', [] ); $unified = empty( $menu_perms['snippets_settings'] ); } @@ -72,6 +72,25 @@ function uninstall_multisite() { delete_site_option( 'recently_activated_snippets' ); } +function delete_flat_files_directory() { + $flat_files_dir = WP_CONTENT_DIR . '/code-snippets'; + + if ( ! is_dir( $flat_files_dir ) ) { + return; + } + + if ( ! function_exists( 'request_filesystem_credentials' ) ) { + require_once ABSPATH . 'wp-admin/includes/file.php'; + } + + global $wp_filesystem; + WP_Filesystem(); + + if ( $wp_filesystem && $wp_filesystem->is_dir( $flat_files_dir ) ) { + $wp_filesystem->delete( $flat_files_dir, true ); + } +} + /** * Uninstall the Code Snippets plugin. * @@ -85,5 +104,7 @@ function uninstall_plugin() { } else { uninstall_current_site(); } + + delete_flat_files_directory(); } } diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 2f3426a2..de1204b2 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -19,7 +19,7 @@ test.describe('Code Snippets Admin', () => { test('Can add a new snippet', async () => { await helper.createSnippet({ name: TEST_SNIPPET_NAME, - code: 'echo "Hello World!";' + code: "add_filter('show_admin_bar', '__return_false');" }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index c83b6515..07ecedf5 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -13,7 +13,7 @@ test.describe('Code Snippets List Page Actions', () => { await helper.createAndActivateSnippet({ name: TEST_SNIPPET_NAME, - code: 'echo "Test snippet for list actions";' + code: "add_filter('show_admin_bar', '__return_false');" }) await helper.navigateToSnippetsAdmin() }) diff --git a/tests/e2e/flat-files.setup.ts b/tests/e2e/flat-files.setup.ts new file mode 100644 index 00000000..3a9307c4 --- /dev/null +++ b/tests/e2e/flat-files.setup.ts @@ -0,0 +1,25 @@ +import { expect, test as setup } from '@playwright/test' + +setup('enable flat files', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=snippets-settings') + await page.waitForSelector('#wpbody-content') + + await page.waitForSelector('form') + + const flatFilesCheckbox = page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]') + await expect(flatFilesCheckbox).toBeVisible() + + const isChecked = await flatFilesCheckbox.isChecked() + if (!isChecked) { + await flatFilesCheckbox.check() + } + + await page.click('input[type="submit"][name="submit"]') + + await page.waitForSelector('.notice-success', { timeout: 10000 }) + await expect(page.locator('.notice-success')).toContainText('Settings saved') + + await page.reload() + await page.waitForSelector('input[name="code_snippets_settings[general][enable_flat_files]"]') + await expect(page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]')).toBeChecked() +}) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 696f73de..cde090e2 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -29,16 +29,37 @@ export default defineConfig({ projects: [ { name: 'setup', - testMatch: /.*\.setup\.ts/ + testMatch: /auth\.setup\.ts/ }, { - name: 'chromium', + name: 'flat-files-setup', + testMatch: /flat-files\.setup\.ts/, use: { ...devices['Desktop Chrome'], storageState: join(__dirname, '../e2e/.auth/user.json') }, dependencies: ['setup'] + }, + + { + name: 'chromium-db-snippets', + use: { + ...devices['Desktop Chrome'], + storageState: join(__dirname, '../e2e/.auth/user.json') + }, + dependencies: ['setup'], + testIgnore: /.*\.setup\.ts/ + }, + + { + name: 'chromium-file-based-snippets', + use: { + ...devices['Desktop Chrome'], + storageState: join(__dirname, '../e2e/.auth/user.json') + }, + dependencies: ['setup', 'flat-files-setup'], + testIgnore: /.*\.setup\.ts/ } ],