> */ public static function get_theme_registry(): array { return array( 'atom-one' => array( 'label' => 'Atom One', 'dark_css' => 'atom-one-dark', 'light_css' => 'atom-one-light', 'dark_bg' => '#282c34', 'dark_toolbar' => '#21252b', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), 'github' => array( 'label' => 'GitHub', 'dark_css' => 'github-dark', 'light_css' => 'github', 'dark_bg' => '#24292e', 'dark_toolbar' => '#1f2428', 'light_bg' => '#fff', 'light_toolbar' => '#f6f8fa', ), 'monokai' => array( 'label' => 'Monokai', 'dark_css' => 'monokai', 'light_css' => 'atom-one-light', 'dark_bg' => '#272822', 'dark_toolbar' => '#1e1f1c', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), 'nord' => array( 'label' => 'Nord', 'dark_css' => 'nord', 'light_css' => 'atom-one-light', 'dark_bg' => '#2e3440', 'dark_toolbar' => '#272c36', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), 'dracula' => array( 'label' => 'Dracula', 'dark_css' => 'dracula', 'light_css' => 'atom-one-light', 'dark_bg' => '#282a36', 'dark_toolbar' => '#21222c', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), 'tokyo-night' => array( 'label' => 'Tokyo Night', 'dark_css' => 'tokyo-night-dark', 'light_css' => 'tokyo-night-light', 'dark_bg' => '#1a1b26', 'dark_toolbar' => '#16161e', 'light_bg' => '#d5d6db', 'light_toolbar' => '#c8c9ce', ), 'vs2015' => array( 'label' => 'VS 2015 / VS Code', 'dark_css' => 'vs2015', 'light_css' => 'vs', 'dark_bg' => '#1e1e1e', 'dark_toolbar' => '#181818', 'light_bg' => '#fff', 'light_toolbar' => '#f3f3f3', ), 'stackoverflow' => array( 'label' => 'Stack Overflow', 'dark_css' => 'stackoverflow-dark', 'light_css' => 'stackoverflow-light', 'dark_bg' => '#1c1b1b', 'dark_toolbar' => '#151414', 'light_bg' => '#f6f6f6', 'light_toolbar' => '#e8e8e8', ), 'night-owl' => array( 'label' => 'Night Owl', 'dark_css' => 'night-owl', 'light_css' => 'atom-one-light', 'dark_bg' => '#011627', 'dark_toolbar' => '#001122', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), 'gruvbox' => array( 'label' => 'Gruvbox', 'dark_css' => 'gruvbox-dark-hard', 'light_css' => 'gruvbox-light-hard', 'dark_bg' => '#1d2021', 'dark_toolbar' => '#171819', 'light_bg' => '#f9f5d7', 'light_toolbar' => '#ece8c8', ), 'solarized' => array( 'label' => 'Solarized', 'dark_css' => 'solarized-dark', 'light_css' => 'solarized-light', 'dark_bg' => '#002b36', 'dark_toolbar' => '#002530', 'light_bg' => '#fdf6e3', 'light_toolbar' => '#eee8d5', ), 'panda' => array( 'label' => 'Panda', 'dark_css' => 'panda-syntax-dark', 'light_css' => 'panda-syntax-light', 'dark_bg' => '#292a2b', 'dark_toolbar' => '#222324', 'light_bg' => '#e6e6e6', 'light_toolbar' => '#d9d9d9', ), 'tomorrow' => array( 'label' => 'Tomorrow Night', 'dark_css' => 'tomorrow-night-bright', 'light_css' => 'atom-one-light', 'dark_bg' => '#000', 'dark_toolbar' => '#0a0a0a', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), 'shades-of-purple' => array( 'label' => 'Shades of Purple', 'dark_css' => 'shades-of-purple', 'light_css' => 'atom-one-light', 'dark_bg' => '#2d2b55', 'dark_toolbar' => '#252347', 'light_bg' => '#fafafa', 'light_toolbar' => '#e8eaed', ), ); } /** * Registers the block type and all its scripts and stylesheets. * * @since 1.0.0 * @return void */ public static function register_block() { // Serve hljs and its theme CSS from the plugin's own /assets/ directory so // they are not blocked by a strict CSP that would otherwise require an external CDN allowlist. $assets = plugins_url( 'assets/', CSDT_PLUGIN_FILE ); wp_register_script( 'csdt-hljs-core', $assets . 'highlight.min.js', array(), self::HLJS_VERSION, true ); // highlight.js's browser bundle is the "common" build: 34 grammars, which does // NOT include Dockerfile even though the block's language picker offers it, a // Dockerfile block rendered with no colours at all and logged an "unknown // language" warning. This is upstream's own grammar for that version, loaded as // a separate file so the vendor bundle stays byte-identical to the published one. // It self-registers against window.hljs, so it must load after the core bundle. wp_register_script( 'csdt-hljs-lang-dockerfile', $assets . 'hljs-lang-dockerfile.min.js', array( 'csdt-hljs-core' ), self::HLJS_VERSION, true ); // Mermaid draws a code block whose language is `mermaid` as a diagram. 5.5 MB, so it is // registered here and enqueued only by render_block() when a page actually has one; every // other page pays nothing. Bundled rather than fetched from a CDN, for the same reason as // highlight.js above. Not a dependency of the front-end script: that script waits for it. wp_register_script( 'csdt-mermaid', $assets . 'mermaid.min.js', array(), self::MERMAID_VERSION, true ); // Chart.js draws a code block whose language is `chart` as a line, bar or pie chart. The // same arrangement as Mermaid: registered here, enqueued only by render_block() for a // chart block. cs-chart-block.js reads the author's text and styles the chart. wp_register_script( 'csdt-chartjs', $assets . 'chartjs.min.js', array(), self::CHARTJS_VERSION, true ); wp_register_script( 'csdt-chart-block', $assets . 'cs-chart-block.js', array( 'csdt-chartjs' ), self::VERSION, true ); // Vega draws a code block whose language is `vega` or `vega-lite` from a JSON // specification. Four scripts, enqueued only by render_block() for such a block: // Vega itself, its expression interpreter (so no chart needs eval, which a strict // Content Security Policy forbids), Vega-Lite (compiles the shorter grammar to Vega), // and cs-vega-block.js, which reads the block and applies the theme. wp_register_script( 'csdt-vega', $assets . 'vega.min.js', array(), self::VEGA_VERSION, true ); wp_register_script( 'csdt-vega-interpreter', $assets . 'vega-interpreter.js', array( 'csdt-vega' ), self::VEGA_INTERPRETER_VERSION, true ); wp_register_script( 'csdt-vega-lite', $assets . 'vega-lite.min.js', array( 'csdt-vega' ), self::VEGA_LITE_VERSION, true ); wp_register_script( 'csdt-vega-block', $assets . 'cs-vega-block.js', array( 'csdt-vega', 'csdt-vega-interpreter', 'csdt-vega-lite' ), self::VERSION, true ); // Register both theme stylesheets from the selected pair $pair_slug = get_option( 'csdt_devtools_code_theme_pair', 'atom-one' ); $registry = self::get_theme_registry(); $pair = isset( $registry[ $pair_slug ] ) ? $registry[ $pair_slug ] : $registry['atom-one']; wp_register_style( 'csdt-hljs-theme-dark', $assets . 'hljs-' . $pair['dark_css'] . '.min.css', array(), self::HLJS_VERSION ); wp_register_style( 'csdt-hljs-theme-light', $assets . 'hljs-' . $pair['light_css'] . '.min.css', array(), self::HLJS_VERSION ); wp_register_style( 'csdt-code-block-frontend', plugins_url( 'assets/cs-code-block.css', CSDT_PLUGIN_FILE ), array( 'csdt-hljs-theme-dark', 'csdt-hljs-theme-light' ), self::VERSION ); // Builds the PNG that Copy hands over for a drawn block. Small, and a dependency of // the frontend script so it is always there when a diagram or chart is. wp_register_script( 'csdt-code-image', plugins_url( 'assets/cs-code-image.js', CSDT_PLUGIN_FILE ), array(), self::VERSION, true ); wp_register_script( 'csdt-code-block-frontend', plugins_url( 'assets/cs-code-block.js', CSDT_PLUGIN_FILE ), array( 'csdt-hljs-core', 'csdt-hljs-lang-dockerfile', 'csdt-code-image' ), self::VERSION, true ); wp_register_style( 'csdt-code-block-editor', plugins_url( 'assets/cs-code-block-editor.css', CSDT_PLUGIN_FILE ), array(), self::VERSION ); wp_register_script( 'csdt-code-block-editor-script', plugins_url( 'blocks/code/editor.js', CSDT_PLUGIN_FILE ), array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n', 'wp-data', 'wp-hooks' ), self::VERSION, true ); register_block_type( dirname( CSDT_PLUGIN_FILE ) . '/blocks/code', array( 'render_callback' => array( __CLASS__, 'render_block' ), 'editor_script' => 'csdt-code-block-editor-script', ) ); } /** * Enqueues the paste-time fence/bracket recovery interceptor and localizes * the AJAX endpoint + nonce it uses to report failures via Telegram. * * @since 1.10.265 * @return void */ public static function enqueue_paste_guard_script() { wp_enqueue_script( 'csdt-code-block-paste-guard', plugins_url( 'blocks/code/paste-guard.js', CSDT_PLUGIN_FILE ), array( 'wp-blocks', 'wp-data', 'wp-dom-ready' ), self::VERSION, true ); $data = array( 'ajax_url' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'csdt_devtools_paste_guard' ), ); wp_add_inline_script( 'csdt-code-block-paste-guard', 'window.csdtPasteGuardData=' . wp_json_encode( $data ) . ';', 'before' ); } /** * AJAX handler: reports that the client-side paste-time fence/bracket * recovery interceptor (blocks/code/paste-guard.js) either failed outright, * or that the older reactive merge fallback (cs-convert.js) had to run, * meaning the interceptor's detection missed a real case. Sends a Telegram * alert so the detection heuristic can be tightened, throttled to at most * one alert per layer every 30 minutes. * * @since 1.10.265 * @return void */ public static function ajax_paste_recovery_failed(): void { check_ajax_referer( 'csdt_devtools_paste_guard', 'nonce' ); // Capability floor: this only fires from the block editor, so require the // editing capability, otherwise any logged-in user could drive error_log // writes / Telegram beacons (log-flood / alert-spam). if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( 'Unauthorized', 403 ); } try { $layer = sanitize_text_field( wp_unslash( $_POST['layer'] ?? 'interceptor' ) ); // 'interceptor' | 'merge-fallback' | 'diagnostic' $error_msg = sanitize_text_field( wp_unslash( $_POST['error_msg'] ?? '' ) ); $snippet = sanitize_textarea_field( wp_unslash( $_POST['snippet'] ?? '' ) ); $post_id = absint( wp_unslash( $_POST['post_id'] ?? 0 ) ); if ( ! $error_msg && ! in_array( $layer, array( 'merge-fallback', 'diagnostic' ), true ) ) { wp_send_json_error( 'No error message', 400 ); return; } self::log( '[CSDT] Paste-guard ' . $layer . ': ' . $error_msg . ( $snippet ? ( ' | snippet: ' . $snippet ) : '' ) ); // Diagnostic beacons are for debugging the interceptor itself, // always logged above (unthrottled, so every real paste attempt // during an active investigation is visible), never alerted. if ( 'diagnostic' === $layer ) { wp_send_json_success( 'logged' ); return; } if ( ! class_exists( 'CSDT_Telegram' ) || ! CSDT_Telegram::is_configured() ) { wp_send_json_success( 'logged' ); return; } if ( CSDT_Options::get( 'csdt_notify_telegram_enabled' ) !== '1' ) { wp_send_json_success( 'logged' ); return; } // Throttle per layer so a burst of failures from one bad paste // sends exactly one alert, and the two layers never share a window. Option-backed // (CSDT_Alert_Window): a transient window reset on every deploy's cache flush. if ( ! CSDT_Alert_Window::claim( 'paste_guard_' . $layer, 30 * MINUTE_IN_SECONDS ) ) { wp_send_json_success( 'throttled' ); return; } $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ?: home_url(); $level = 'merge-fallback' === $layer ? 'warning' : 'error'; $title = 'merge-fallback' === $layer ? 'Paste recovered only by the reactive INI-merge safety net, content may still be corrupted, interceptor missed this case' : 'Paste-time fence/bracket recovery interceptor failed'; $msg = sprintf( "%s\nSite: %s\nPost: #%d\nURL: %s\nError: %s\nClipboard snippet:\n%s", $title, $site, $post_id, $post_id ? esc_url_raw( (string) get_edit_post_link( $post_id, 'raw' ) ) : '', $error_msg ?: '(n/a, merge fallback fired without a JS error)', mb_substr( $snippet, 0, 500 ) ); CSDT_Alert_Events::send( CSDT_Alert_Events::GENERAL, $msg, 'CloudScale Cyber Devtools', $level ); wp_send_json_success( 'sent' ); } catch ( \Throwable $e ) { CSDT_Ajax::fail( $e, __CLASS__ . '::' . __FUNCTION__ ); } } /** * Enqueues the block editor auto-convert script and attaches the toast inline style. * * @since 1.5.0 * @return void */ public static function enqueue_convert_script() { wp_enqueue_script( 'csdt-code-block-convert', plugins_url( 'assets/cs-convert.js', CSDT_PLUGIN_FILE ), // Depends on csdt-code-block-paste-guard so window.csdtPasteGuardData // (read by cs-convert.js's reportMergeFallback()) is guaranteed to be // printed before this script runs, regardless of enqueue call order. array( 'wp-blocks', 'wp-data', 'csdt-code-block-paste-guard' ), self::VERSION, true ); // The toast's rules live in assets/cs-code-block-editor.css, the stylesheet this // editor already loads. They used to be a PHP string passed to wp_add_inline_style(); // WordPress.org reads that as inline CSS, which it is. } /** * Enqueues the Replace Post with Clipboard button script in the block editor. * * @since 1.10.265 * @return void */ public static function enqueue_replace_post_script() { wp_enqueue_script( 'csdt-replace-post', plugins_url( 'assets/cs-replace-post.js', CSDT_PLUGIN_FILE ), // Depends on csdt-code-block-paste-guard so window.__csPasteGuard // (the shared fence-detection helpers) is guaranteed to be defined // before this script runs, regardless of enqueue call order. array( 'wp-blocks', 'wp-data', 'wp-dom-ready', 'csdt-code-block-paste-guard' ), self::VERSION, true ); wp_enqueue_style( 'csdt-replace-post', plugins_url( 'assets/cs-replace-post.css', CSDT_PLUGIN_FILE ), array( 'wp-edit-post' ), self::VERSION ); } /** * Renders a code block on the frontend. * * @since 1.0.0 * @param array $attributes Block attributes. * @param string $block_content Existing block content (unused). * @return string HTML output. */ public static function render_block( $attributes, $_block_content = '' ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- block render callback; second arg required by register_block_type arity self::maybe_enqueue_frontend(); ++self::$instance_count; $id = 'cs-code-' . self::$instance_count; $code = isset( $attributes['content'] ) ? $attributes['content'] : ''; $lang = isset( $attributes['language'] ) ? $attributes['language'] : ''; if ( self::is_mermaid( $lang ) ) { wp_enqueue_script( 'csdt-mermaid' ); } if ( self::is_chart( $lang ) ) { wp_enqueue_script( 'csdt-chart-block' ); } if ( self::is_vega( $lang ) ) { wp_enqueue_script( 'csdt-vega-block' ); } $title = isset( $attributes['title'] ) ? $attributes['title'] : ''; $theme = isset( $attributes['theme'] ) ? $attributes['theme'] : ''; $gh_view_url = ''; $gh_label = 'GitHub'; if ( ! empty( $attributes['githubUrl'] ) && ! empty( $attributes['githubShowLink'] ) ) { $provider = CSDT_Git_Providers::detect( (string) $attributes['githubUrl'] ); $parts = $provider ? $provider->parse( (string) $attributes['githubUrl'] ) : null; if ( $provider && $parts ) { // Whole-file link (no line anchor), the stored block doesn't carry line numbers. $gh_view_url = $provider->view_url( $parts, 0, 0 ); $gh_label = $provider->label(); } } return self::build_html( $id, $code, $lang, $title, $theme, $gh_view_url, $gh_label ); } /** * Whether a block's language asks for a Mermaid diagram rather than highlighted code. * * @since 1.10.947 * @param string $lang Block language attribute. * @return bool */ public static function is_mermaid( $lang ) { return 'mermaid' === strtolower( trim( (string) $lang ) ); } /** * Whether a block's language asks for a chart drawn by Chart.js rather than highlighted code. * * @param string $lang Block language attribute. * @return bool */ public static function is_chart( $lang ) { return in_array( strtolower( trim( (string) $lang ) ), self::CHART_LANGS, true ); } /** * Whether a block's language asks for a chart drawn by Vega (a Vega or Vega-Lite * specification in JSON) rather than highlighted code. * * @param string $lang Block language attribute. * @return bool */ public static function is_vega( $lang ) { return in_array( strtolower( trim( (string) $lang ) ), self::VEGA_LANGS, true ); } /** * Returns the inline SVG mark for a source provider. GitHub keeps its octocat; * other providers (GitLab, future) use a neutral git-branch glyph so we never * render a wrong-brand logo. Markup is a fixed literal, safe to echo directly. * * @since 1.10.265 * @param string $label Provider label ("GitHub", "GitLab", …). * @return string Inline SVG markup. */ private static function source_icon_svg( $label ) { if ( 'GitHub' === $label ) { return ''; } return ''; } /** * Builds the full HTML markup for a code block. * * @since 1.0.0 * @param string $id Unique HTML element ID. * @param string $code Code content to display. * @param string $lang Language identifier for highlight.js, or empty for auto-detect. * @param string $title Optional filename or title label. * @param string $theme Per-block colour-theme override slug, or empty for site default. * @param string $gh_view_url Source "view file" URL, or empty when not linked. * @param string $gh_label Provider label for link text ("GitHub", "GitLab"). * @return string HTML markup. */ private static function build_html( $id, $code, $lang, $title, $theme, $gh_view_url = '', $gh_label = 'GitHub' ) { $lang_class = $lang ? 'language-' . esc_attr( $lang ) : ''; // Opt-in, default OFF per WordPress.org Guideline 10 (front-end credit // links must not ship enabled by default). $cloudscale_link = CSDT_Login::credit_opted_in() ? '⚡ Powered by CloudScale' : ''; $title_html = ''; if ( $title ) { $title_html = '
' . $code_html . ''
. '|
$#i', '', trim( $content ) ); $content = str_replace( array( '