alt text. '/\[([^\]]*)\]\([^)]*\)/', // Links -> label text. '/`([^`]*)`/', // Inline code -> contents. '/(\*\*|__|~~|\*|_)(.+?)\1/', // Bold/italic/strikethrough -> contents. ); $replacements = array( '$1', '$1', '$1', '$2' ); $result = preg_replace( $patterns, $replacements, $text ); if ( ! is_string( $result ) ) { $result = $text; } return sanitize_text_field( trim( $result ) ); } /** * Extract the post title from the first H1 in the Markdown, and remove that * H1 line from the content so it does not duplicate the theme-rendered title. * * Falls back to $fallback_title when no H1 is present or the extracted text * is empty after sanitization. * * @since 1.2.0 * * @param string $content Raw Markdown. * @param string $fallback_title Fallback title if no H1 is found. * @return array{title: string, content: string} { * Extracted title (plain text) and the Markdown with the first H1 removed. * * @type string $title Post title. * @type string $content Markdown with the first H1 line removed. * } */ function robotstxt_docmd_extract_h1_title( string $content, string $fallback_title ): array { if ( preg_match( '/^#[ \t]+(.+)$/m', $content, $matches ) ) { $title = robotstxt_docmd_plain_text_from_inline_markdown( trim( $matches[1] ) ); if ( '' === $title ) { $title = $fallback_title; } $replaced = preg_replace( '/^#[ \t]+.*$/m', '', $content, 1 ); if ( is_string( $replaced ) ) { $content = $replaced; } } else { $title = $fallback_title; } return array( 'title' => $title, 'content' => $content, ); } /** * Resolve a (possibly relative) Markdown link target to a canonical repository * path, relative to the file currently being synced. * * Handles "./file.md", "../file.md", "sub/file.md", and absolute "/file.md" * forms. Returns null for empty targets. * * @since 1.2.0 * * @param string $current_file_path Repository path of the file being synced. * @param string $href Raw link target. * @return string|null Canonical repository path, or null if it cannot be resolved. */ function robotstxt_docmd_resolve_repo_path( string $current_file_path, string $href ): ?string { // Strip any fragment or query string before resolving. $path = preg_replace( '/[?#].*$/', '', $href ); if ( ! is_string( $path ) || '' === $path ) { return null; } if ( 0 === strpos( $path, '/' ) ) { // Absolute repository path. $base_segments = array(); $rel_segments = explode( '/', ltrim( $path, '/' ) ); } else { // Relative to the current file's directory. $dir = dirname( $current_file_path ); $base_segments = '.' === $dir ? array() : explode( '/', $dir ); $rel_segments = explode( '/', $path ); } $result = $base_segments; foreach ( $rel_segments as $segment ) { if ( '' === $segment || '.' === $segment ) { continue; } if ( '..' === $segment ) { array_pop( $result ); continue; } $result[] = $segment; } $normalized = implode( '/', $result ); if ( '' === $normalized ) { return null; } return $normalized; } /** * Build a lookup of canonical repository file paths to WordPress permalinks, * scoped to the given repository and branch. Only mappings with an existing * target post are included. * * @since 1.2.0 * * @param string $owner Repository owner. * @param string $repo Repository name. * @param string $branch Branch name. * @return array Map of repository path => permalink. */ function robotstxt_docmd_build_link_map( string $owner, string $repo, string $branch ): array { $mappings = robotstxt_docmd_get_all_mappings(); $map = array(); foreach ( $mappings as $mapping ) { if ( $mapping['repo_owner'] !== $owner || $mapping['repo_name'] !== $repo || $mapping['branch'] !== $branch ) { continue; } if ( empty( $mapping['target_post_id'] ) ) { continue; } $permalink = get_permalink( (int) $mapping['target_post_id'] ); if ( is_string( $permalink ) && '' !== $permalink ) { $map[ $mapping['file_path'] ] = $permalink; } } return $map; } /** * Get the imported-image asset map for a synced post. * * @since 1.2.0 * * @param int $post_id Target post ID. * @return array Map of repository image path => attachment ID. */ function robotstxt_docmd_get_asset_map( int $post_id ): array { $raw = get_post_meta( $post_id, '_robotstxt_docmd_assets', true ); if ( ! is_array( $raw ) ) { return array(); } $map = array(); foreach ( $raw as $path => $attachment_id ) { if ( is_string( $path ) && ( is_int( $attachment_id ) || ( is_string( $attachment_id ) && ctype_digit( $attachment_id ) ) ) ) { $map[ $path ] = (int) $attachment_id; } } return $map; } /** * Persist the imported-image asset map for a synced post. * * @since 1.2.0 * * @param int $post_id Target post ID. * @param array $map Map of repository image path => attachment ID. * @phpstan-param array $map * @return void */ function robotstxt_docmd_save_asset_map( int $post_id, array $map ): void { update_post_meta( $post_id, '_robotstxt_docmd_assets', $map ); } /** * Return the list of image extensions allowed for sideloading. * * SVG is intentionally excluded to avoid stored XSS via embedded scripts. * * @since 1.2.0 * * @return list */ function robotstxt_docmd_allowed_image_extensions(): array { return array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ); } /** * Download an image from the repository and import it into the Media Library. * * @since 1.2.0 * * @param string $repo_path Canonical repository path of the image. * @param int $target_post_id Post to attach the image to. * @param string $owner Repository owner. * @param string $repo Repository name. * @param string $branch Branch name. * @param string $token Encrypted GitHub token. * @return int|WP_Error Attachment ID on success, or WP_Error. */ function robotstxt_docmd_sideload_repo_image( string $repo_path, int $target_post_id, string $owner, string $repo, string $branch, string $token ): int|WP_Error { $ext = strtolower( (string) pathinfo( $repo_path, PATHINFO_EXTENSION ) ); $allowed = robotstxt_docmd_allowed_image_extensions(); if ( ! in_array( $ext, $allowed, true ) ) { return new WP_Error( 'unsupported_image_type', sprintf( /* translators: %s: file extension */ __( 'Unsupported image type .%s. Allowed: jpg, jpeg, png, gif, webp.', 'robotstxt-documentation-markdown' ), $ext ) ); } // Fetch image bytes from GitHub. $bytes = robotstxt_docmd_get_file_content( $owner, $repo, $repo_path, $branch, $token ); if ( is_wp_error( $bytes ) ) { return $bytes; } // Size guard (10 MB). if ( strlen( $bytes ) > 10 * 1024 * 1024 ) { return new WP_Error( 'image_too_large', __( 'Repository image exceeds the 10 MB import limit.', 'robotstxt-documentation-markdown' ) ); } if ( ! function_exists( 'media_handle_sideload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } $filename = sanitize_file_name( basename( $repo_path ) ); $tmp_name = wp_tempnam( $filename ); if ( '' === $tmp_name ) { return new WP_Error( 'temp_failed', __( 'Could not create a temporary file for image import.', 'robotstxt-documentation-markdown' ) ); } global $wp_filesystem; if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { require_once ABSPATH . 'wp-admin/includes/file.php'; WP_Filesystem(); } if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) { wp_delete_file( $tmp_name ); return new WP_Error( 'fs_failed', __( 'Could not initialize the WordPress filesystem.', 'robotstxt-documentation-markdown' ) ); } if ( ! $wp_filesystem->put_contents( $tmp_name, $bytes, FS_CHMOD_FILE ) ) { wp_delete_file( $tmp_name ); return new WP_Error( 'write_failed', __( 'Could not write image to the temporary file.', 'robotstxt-documentation-markdown' ) ); } $attach_id = media_handle_sideload( array( 'name' => $filename, 'tmp_name' => $tmp_name, ), $target_post_id ); if ( is_wp_error( $attach_id ) ) { wp_delete_file( $tmp_name ); return $attach_id; } return (int) $attach_id; } /** * Post-process synced HTML: rewrite internal links to mapped permalinks and * sideload repository images into the Media Library. * * Internal-link and image references are parsed with DOMDocument (no regex on * HTML). Images already present in $asset_map are reused. Image import errors * are non-fatal: the original src is kept and the sync continues. * * @since 1.2.0 * * @param string $html Converted HTML content. * @param array $mapping Mapping being synced. * @param int $target_post_id Target post ID (for image attachment). * @param string $token Encrypted GitHub token (for image fetch). * @param array $asset_map Existing imported-image map (path => attach ID). * @phpstan-param MappingData $mapping * @phpstan-param array $asset_map * @return array{html: string, assets: array} Processed HTML and the updated asset map. */ function robotstxt_docmd_process_synced_html( string $html, array $mapping, int $target_post_id, string $token, array $asset_map ): array { $dom = new DOMDocument(); libxml_use_internal_errors( true ); $dom->loadHTML( '' . $html . '' ); libxml_clear_errors(); $link_map = robotstxt_docmd_build_link_map( $mapping['repo_owner'], $mapping['repo_name'], $mapping['branch'] ); // Rewrite internal anchor hrefs. foreach ( $dom->getElementsByTagName( 'a' ) as $link ) { $href = $link->getAttribute( 'href' ); if ( '' === $href ) { continue; } // Skip absolute, protocol-relative, mailto/tel, and anchor-only links. if ( preg_match( '~^(https?:|mailto:|tel:|//|#)~', $href ) ) { continue; } $resolved = robotstxt_docmd_resolve_repo_path( $mapping['file_path'], $href ); if ( null === $resolved ) { continue; } if ( isset( $link_map[ $resolved ] ) ) { $link->setAttribute( 'href', $link_map[ $resolved ] ); } } // Sideload or reuse repository images. foreach ( $dom->getElementsByTagName( 'img' ) as $image ) { $src = $image->getAttribute( 'src' ); if ( '' === $src ) { continue; } // Skip absolute, protocol-relative, and data URIs. if ( preg_match( '~^(https?:|//|data:)~', $src ) ) { continue; } $resolved = robotstxt_docmd_resolve_repo_path( $mapping['file_path'], $src ); if ( null === $resolved ) { continue; } // Reuse an already-imported attachment. if ( isset( $asset_map[ $resolved ] ) ) { $url = wp_get_attachment_url( (int) $asset_map[ $resolved ] ); if ( is_string( $url ) && '' !== $url ) { $image->setAttribute( 'src', $url ); } continue; } // Import the image from the repository. $attach_id = robotstxt_docmd_sideload_repo_image( $resolved, $target_post_id, $mapping['repo_owner'], $mapping['repo_name'], $mapping['branch'], $token ); if ( is_wp_error( $attach_id ) ) { // Keep the original src; do not block the sync. continue; } $asset_map[ $resolved ] = $attach_id; $url = wp_get_attachment_url( $attach_id ); if ( is_string( $url ) && '' !== $url ) { $image->setAttribute( 'src', $url ); } } // Extract the processed body HTML. $body = $dom->getElementsByTagName( 'body' )->item( 0 ); if ( ! $body instanceof DOMElement ) { return array( 'html' => $html, 'assets' => $asset_map, ); } $output = ''; foreach ( $body->childNodes as $child ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- DOMDocument API property. $saved = $dom->saveHTML( $child ); if ( is_string( $saved ) ) { $output .= $saved; } } if ( '' === $output ) { $output = $html; } return array( 'html' => $output, 'assets' => $asset_map, ); }