422 lines
12 KiB
PHP
422 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Synced-content processing: H1 title extraction, internal-link translation,
|
|
* and repository image sideloading.
|
|
*
|
|
* @package RobotsTxt\DocumentationMarkdown
|
|
* @since 1.2.0
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Reduce inline Markdown formatting in a single line to plain text.
|
|
*
|
|
* Used to derive a clean post title from an H1 that may contain links,
|
|
* emphasis, inline code, or images.
|
|
*
|
|
* @since 1.2.0
|
|
*
|
|
* @param string $text Inline Markdown text (single line).
|
|
* @return string Sanitized plain text.
|
|
*/
|
|
function robotstxt_docmd_plain_text_from_inline_markdown( string $text ): string {
|
|
$patterns = array(
|
|
'/!\[([^\]]*)\]\([^)]*\)/', // Images -> 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<string, string> 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<string, int> 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<string, int> $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<string>
|
|
*/
|
|
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<string, int> $asset_map
|
|
* @return array{html: string, assets: array<string, int>} 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(
|
|
'<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>' . $html . '</body></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,
|
|
);
|
|
}
|