v1.2.0
This commit is contained in:
parent
693e5ae2e6
commit
7d3fc1969d
52 changed files with 1776 additions and 295 deletions
422
robotstxt-documentation-markdown-content.php
Normal file
422
robotstxt-documentation-markdown-content.php
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
<?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,
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue