robotstxt-documentation-mar.../robotstxt-documentation-markdown-content.php
2026-08-08 08:53:08 +00:00

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,
);
}