Last active
March 30, 2024 22:30
-
-
Save adamziel/6d2c8aad6133b0453b494d8445a11834 to your computer and use it in GitHub Desktop.
Playground docs contribution plugin
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/** | |
* A copy of the WP_Interactivity_API_Directives_Processor class | |
* from the Gutenberg plugin. | |
* | |
* @package WordPress | |
* @subpackage Interactivity API | |
* @since 6.5.0 | |
*/ | |
if ( ! class_exists( 'Playground_Post_Export_Processor' ) ) { | |
/** | |
* Class used to iterate over the tags of an HTML string and help process the | |
* directive attributes. | |
* | |
* @since 6.5.0 | |
* | |
* @access private | |
*/ | |
final class Playground_Post_Export_Processor extends WP_HTML_Tag_Processor { | |
/** | |
* List of tags whose closer tag is not visited by the WP_HTML_Tag_Processor. | |
* | |
* @since 6.5.0 | |
* @var string[] | |
*/ | |
const TAGS_THAT_DONT_VISIT_CLOSER_TAG = array( | |
'SCRIPT', | |
'IFRAME', | |
'NOEMBED', | |
'NOFRAMES', | |
'STYLE', | |
'TEXTAREA', | |
'TITLE', | |
'XMP', | |
); | |
/** | |
* Returns the content between two balanced template tags. | |
* | |
* It positions the cursor in the closer tag of the balanced template tag, | |
* if it exists. | |
* | |
* @since 6.5.0 | |
* | |
* @access private | |
* | |
* @return string|null The content between the current opener template tag and its matching closer tag or null if it | |
* doesn't find the matching closing tag or the current tag is not a template opener tag. | |
*/ | |
public function get_content_between_balanced_template_tags() { | |
$positions = $this->get_after_opener_tag_and_before_closer_tag_positions(); | |
if ( ! $positions ) { | |
return null; | |
} | |
return substr( $this->html, $positions['after_opener_tag'], $positions['before_closer_tag'] - $positions['after_opener_tag'] ); | |
} | |
public function remove_balanced_tag() | |
{ | |
$positions = $this->get_after_opener_tag_and_before_closer_tag_positions(); | |
if ( ! $positions ) { | |
return null; | |
} | |
$this->lexical_updates[] = new WP_HTML_Text_Replacement( | |
$positions['before_opener_tag'], | |
$positions['after_closer_tag'], | |
'' | |
); | |
return true; | |
} | |
/** | |
* Sets the content between two balanced tags. | |
* | |
* @since 6.5.0 | |
* | |
* @access private | |
* | |
* @param string $new_content The string to replace the content between the matching tags. | |
* @return bool Whether the content was successfully replaced. | |
*/ | |
public function set_content_between_balanced_tags( string $new_content ): bool { | |
$positions = $this->get_after_opener_tag_and_before_closer_tag_positions( true ); | |
if ( ! $positions ) { | |
return false; | |
} | |
list( $after_opener_tag, $before_closer_tag ) = $positions; | |
$this->lexical_updates[] = new Gutenberg_HTML_Text_Replacement_6_5( | |
$after_opener_tag, | |
$before_closer_tag - $after_opener_tag, | |
esc_html( $new_content ) | |
); | |
return true; | |
} | |
/** | |
* Gets the positions right after the opener tag and right before the closer | |
* tag in a balanced tag. | |
* | |
* By default, it positions the cursor in the closer tag of the balanced tag. | |
* If $rewind is true, it seeks back to the opener tag. | |
* | |
* @since 6.5.0 | |
* | |
* @access private | |
* | |
* @param bool $rewind Optional. Whether to seek back to the opener tag after finding the positions. Defaults to false. | |
* @return array|null Start and end byte position, or null when no balanced tag bookmarks. | |
*/ | |
private function get_after_opener_tag_and_before_closer_tag_positions( bool $rewind = false ) { | |
// Flushes any changes. | |
$this->get_updated_html(); | |
$bookmarks = $this->get_balanced_tag_bookmarks(); | |
if ( ! $bookmarks ) { | |
return null; | |
} | |
list( $opener_tag, $closer_tag ) = $bookmarks; | |
$positions = array( | |
'before_opener_tag' => $this->bookmarks[$opener_tag]->start, | |
'after_opener_tag' => $this->bookmarks[$opener_tag]->end + 1, | |
'before_closer_tag' => $this->bookmarks[$closer_tag]->start, | |
'after_closer_tag' => $this->bookmarks[$closer_tag]->end + 1, | |
); | |
if ( $rewind ) { | |
$this->seek( $opener_tag ); | |
} | |
$this->release_bookmark( $opener_tag ); | |
$this->release_bookmark( $closer_tag ); | |
return $positions; | |
} | |
/** | |
* Returns a pair of bookmarks for the current opener tag and the matching | |
* closer tag. | |
* | |
* It positions the cursor in the closer tag of the balanced tag, if it | |
* exists. | |
* | |
* @since 6.5.0 | |
* | |
* @return array|null A pair of bookmarks, or null if there's no matching closing tag. | |
*/ | |
private function get_balanced_tag_bookmarks() { | |
static $i = 0; | |
$opener_tag = 'opener_tag_of_balanced_tag_' . ++$i; | |
$this->set_bookmark( $opener_tag ); | |
if ( ! $this->next_balanced_tag_closer_tag() ) { | |
$this->release_bookmark( $opener_tag ); | |
return null; | |
} | |
$closer_tag = 'closer_tag_of_balanced_tag_' . ++$i; | |
$this->set_bookmark( $closer_tag ); | |
return array( $opener_tag, $closer_tag ); | |
} | |
/** | |
* Finds the matching closing tag for an opening tag. | |
* | |
* When called while the processor is on an open tag, it traverses the HTML | |
* until it finds the matching closer tag, respecting any in-between content, | |
* including nested tags of the same name. Returns false when called on a | |
* closer tag, a tag that doesn't have a closer tag (void), a tag that | |
* doesn't visit the closer tag, or if no matching closing tag was found. | |
* | |
* @since 6.5.0 | |
* | |
* @access private | |
* | |
* @return bool Whether a matching closing tag was found. | |
*/ | |
public function next_balanced_tag_closer_tag(): bool { | |
$depth = 0; | |
$tag_name = $this->get_tag(); | |
if ( ! $this->has_and_visits_its_closer_tag() ) { | |
return false; | |
} | |
while ( $this->next_tag( | |
array( | |
'tag_name' => $tag_name, | |
'tag_closers' => 'visit', | |
) | |
) ) { | |
if ( ! $this->is_tag_closer() ) { | |
++$depth; | |
continue; | |
} | |
if ( 0 === $depth ) { | |
return true; | |
} | |
--$depth; | |
} | |
return false; | |
} | |
/** | |
* Checks whether the current tag has and will visit its matching closer tag. | |
* | |
* @since 6.5.0 | |
* | |
* @access private | |
* | |
* @return bool Whether the current tag has a closer tag. | |
*/ | |
public function has_and_visits_its_closer_tag(): bool { | |
$tag_name = $this->get_tag(); | |
return null !== $tag_name && ( | |
// @TODO: Backport the 6.5 method | |
// ! WP_HTML_Tag_Processor::is_void( $tag_name ) && | |
! in_array( $tag_name, self::TAGS_THAT_DONT_VISIT_CLOSER_TAG, true ) | |
); | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/* | |
Plugin Name: Documentation Pages | |
Plugin URI: https://w.org/playground | |
Description: Manage HTML documentation pages using WordPress. | |
Version: 0.1 | |
Author: WordPress Contributors | |
License: GPL2 | |
*/ | |
if (!defined('HTML_PAGES_PATH')) { | |
define('HTML_PAGES_PATH', __DIR__ . '/html-pages'); | |
} | |
require_once __DIR__ . '/playground-post-export-processor.php'; | |
add_action('init', function () { | |
// Register custom post type for doc_page | |
$args = array( | |
'public' => true, | |
'show_in_rest' => true, // Enable block editor | |
'menu_position' => 5, | |
'hierarchical' => true, | |
'show_in_nav_menus' => true, | |
'show_in_menu' => true, | |
'show_ui' => true, | |
'publicly_queryable' => true, | |
'exclude_from_search' => false, | |
'has_archive' => true, | |
'query_var' => true, | |
'supports' => array('title', 'editor', 'custom-fields', 'page-attributes'), | |
'labels' => array( | |
'name' => 'Doc Pages', | |
'singular_name' => 'Doc Page', | |
'menu_name' => 'Doc Pages', | |
'add_new' => 'Add New', | |
'add_new_item' => 'Add New', | |
), | |
); | |
register_post_type('doc_page', $args); | |
initialize_docs_plugin(); | |
}); | |
function initialize_docs_plugin() { | |
if(get_option('docs_populated')) { | |
// Prevent collisions between the initial create_db_doc_pages_from_html_files call | |
// process and the save_post_doc_page hook. | |
return; | |
} | |
if(!file_exists(HTML_PAGES_PATH)) { | |
return; | |
} | |
delete_db_doc_pages(HTML_PAGES_PATH); | |
create_db_doc_pages_from_html_files(HTML_PAGES_PATH); | |
update_option('docs_populated', true); | |
} | |
add_action('admin_menu', function () { | |
// Remove distracting options from the admin menu | |
remove_menu_page('edit.php'); | |
remove_menu_page('edit.php?post_type=page'); | |
remove_menu_page('edit-comments.php'); | |
remove_menu_page('users.php'); | |
// Add a submenu under "Docs pages" menu | |
add_submenu_page( | |
'edit.php?post_type=doc_page', | |
'Download ZIP', | |
'Download ZIP', | |
'manage_options', | |
'download_docs', | |
function () { } | |
); | |
// Add a submenu under "Docs pages" menu | |
add_submenu_page( | |
'edit.php?post_type=doc_page', | |
'Reload doc pages from disk', | |
'Reload doc pages from disk', | |
'manage_options', | |
'recreate_db_doc_pages_from_disk', | |
function () { } | |
); | |
}); | |
add_action('admin_init', function () { | |
if (isset($_GET['page']) && $_GET['page'] === 'download_docs') { | |
return download_docs_callback(); | |
} | |
if (isset($_GET['page']) && $_GET['page'] === 'recreate_db_doc_pages_from_disk') { | |
update_option('docs_populated', false); | |
delete_db_doc_pages(HTML_PAGES_PATH); | |
create_db_doc_pages_from_html_files(HTML_PAGES_PATH); | |
update_option('docs_populated', true); | |
// Display admin notice | |
add_action('admin_notices', function () { | |
echo '<div class="notice notice-success is-dismissible"><p>Doc pages were recreated successfully.</p></div>'; | |
}); | |
} | |
}); | |
function download_docs_callback() { | |
// Create a zip file of the HTML_PAGES_PATH directory | |
$zipFile = __DIR__ . '/docs.zip'; | |
$zip = new ZipArchive(); | |
if ($zip->open($zipFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true) { | |
$files = new RecursiveIteratorIterator( | |
new RecursiveDirectoryIterator(HTML_PAGES_PATH), | |
RecursiveIteratorIterator::LEAVES_ONLY | |
); | |
foreach ($files as $name => $file) { | |
if (!$file->isDir()) { | |
$filePath = $file->getRealPath(); | |
$relativePath = substr($filePath, strlen(HTML_PAGES_PATH) + 1); | |
$zip->addFile($filePath, $relativePath); | |
} | |
} | |
$zip->close(); | |
// Download the zip file | |
header('Content-Type: application/zip'); | |
header('Content-Disposition: attachment; filename="docs.zip"'); | |
header('Content-Length: ' . filesize($zipFile)); | |
readfile($zipFile); | |
// Delete the zip file | |
unlink($zipFile); | |
} else { | |
echo 'Failed to create zip file'; | |
} | |
exit; | |
} | |
/** | |
* Recreate the entire file structure when any post is saved. | |
* | |
* Why recreate? | |
* | |
* It's easier to recreate the entire file structure than to keep track of | |
* which files have been added, deleted, renamed and moved under | |
* another parent, or changed via a direct SQL query. | |
*/ | |
add_action('save_post_doc_page', function ($post_id) { | |
// Prevent collisions between the initial create_db_doc_pages_from_html_files call | |
// process and the save_post_doc_page hook. | |
if (!get_option('docs_populated')) { | |
return; | |
} | |
// Don't delete the files upfront. Save the new files to a temporary | |
// directory and then replace the existing directory with the new one | |
// to prevent data loss on error. | |
$tmpPath = HTML_PAGES_PATH . '.tmp'; | |
if (file_exists($tmpPath)) { | |
docs_plugin_deltree($tmpPath); | |
} | |
save_db_doc_pages_as_html($tmpPath); | |
docs_plugin_deltree(HTML_PAGES_PATH); | |
rename($tmpPath, HTML_PAGES_PATH); | |
}); | |
function create_db_doc_pages_from_html_files($dir, $parent_id = 0) { | |
$indexFilePath = $dir . '/index.html'; | |
if(file_exists($indexFilePath)) { | |
$parent_id = create_db_doc_page_from_html_file(new SplFileInfo($indexFilePath), $parent_id); | |
} | |
foreach (scandir($dir) as $file) { | |
if ($file === '.' || $file === '..' || $file === 'index.html') { | |
continue; | |
} | |
$filePath = $dir . '/' . $file; | |
if (is_dir($filePath)) { | |
create_db_doc_pages_from_html_files($filePath, $parent_id); | |
} else if (pathinfo($file, PATHINFO_EXTENSION) === 'html') { | |
create_db_doc_page_from_html_file(new SplFileInfo($filePath), $parent_id); | |
} | |
} | |
} | |
function create_db_doc_page_from_html_file(SplFileInfo $file, $parent_id = 0) { | |
$content = file_get_contents($file->getRealPath()); | |
$p = new Playground_Post_Export_Processor($content); | |
$p->next_tag(); | |
if($p->get_tag() === 'H1') { | |
$p->set_bookmark('start'); | |
$title = $p->get_content_between_balanced_template_tags(); | |
$p->seek('start'); | |
$p->remove_balanced_tag(); | |
// Removing the tag doesn't affect the whitespace that follows, so | |
// we need to trim the content or else we'll start accumulating leading | |
// newlines. | |
$content = trim($p->get_updated_html()); | |
} else { | |
$title = $file->getBasename('.html'); | |
} | |
// Insert the content as a WordPress doc_page | |
return wp_insert_post(array( | |
'post_title' => $title, | |
'post_content' => $content, | |
'post_status' => 'publish', | |
'post_author' => get_current_user_id(), | |
'post_type' => 'doc_page', | |
'post_parent' => $parent_id, | |
)); | |
} | |
function delete_db_doc_pages() { | |
$args = array( | |
'post_type' => 'doc_page', | |
'posts_per_page' => -1, | |
'post_status' => 'any', | |
); | |
$pages = new WP_Query($args); | |
if ($pages->have_posts()) { | |
while ($pages->have_posts()) { | |
$pages->the_post(); | |
wp_delete_post(get_the_ID(), true); | |
} | |
} | |
wp_reset_postdata(); | |
} | |
function save_db_doc_pages_as_html($path, $parent_id = 0) { | |
if (!file_exists($path)) { | |
mkdir($path, 0777, true); | |
} | |
$args = array( | |
'post_type' => 'doc_page', | |
'posts_per_page' => -1, | |
'post_parent' => $parent_id, | |
'post_status' => 'publish', | |
); | |
$pages = new WP_Query($args); | |
if ($pages->have_posts()) { | |
while ($pages->have_posts()) { | |
$pages->the_post(); | |
$page_id = get_the_ID(); | |
$title = sanitize_title(get_the_title()); | |
$content = '<h1>' . esc_html(get_the_title()) . "</h1>\n\n" . get_the_content(); | |
$child_pages = get_pages(array('child_of' => $page_id, 'post_type' => 'doc_page')); | |
if (!file_exists($path)) { | |
mkdir($path, 0777, true); | |
} | |
if (!empty($child_pages)) { | |
$new_parent = $path . '/' . $title; | |
if (!file_exists($new_parent)) { | |
mkdir($new_parent, 0777, true); | |
} | |
file_put_contents($new_parent . '/index.html', $content); | |
save_db_doc_pages_as_html($new_parent, $page_id); | |
} else { | |
file_put_contents($path . '/' . $title . '.html', $content); | |
} | |
} | |
} | |
wp_reset_postdata(); | |
} | |
function docs_plugin_deltree($path) { | |
if (!file_exists($path)) { | |
return; | |
} | |
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path), RecursiveIteratorIterator::CHILD_FIRST); | |
foreach ($iterator as $file) { | |
/** @var SplFileInfo $file */ | |
if ($file->isDir()) { | |
rmdir($file->getRealPath()); | |
} else if($file->isFile()) { | |
unlink($file->getRealPath()); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment