Repository navigation
New block: Copy Page, for the Markdown rendering of a page - #780
Conversation
WordPress.org serves a Markdown rendering of any page via ?output_format=md or an Accept: text/markdown header. This block gives readers a "Copy page" button that fetches that rendering and writes it to the clipboard, plus a "View as Markdown" link to open it. Both use the icons from @wordpress/icons (copySmall, check) and a Markdown glyph, with the same small-text link treatment as the surrounding sidebar. The clipboard write hands the fetch promise to ClipboardItem where supported, so Safari keeps the write tied to the click, and falls back to writeText. Sidebar Container changes to host it: - Replace the text arrow in "Back to top" with the arrowUp icon so its label lines up with the new rows. The translatable string becomes "Back to top". - Hide the copy block below the floating breakpoint, as "Back to top" is. - Move the divider to whichever footer item comes first. Add a shared is_markdown_request() helper, and omit both the copy block and "Back to top" from the Markdown rendering, where they have no meaning.
obenland
left a comment
There was a problem hiding this comment.
🤖 Automated review generated with Claude Code. Findings are inline.
| * @return string The URL. | ||
| */ | ||
| function get_markdown_url() { | ||
| $url = add_query_arg( 'output_format', 'md' ); |
There was a problem hiding this comment.
add_query_arg() without a URL builds the link from the raw REQUEST_URI. For a request like https://developer.wordpress.org//evil.example/x, that yields //evil.example/x?output_format=md, a protocol-relative URL to another host, which esc_url() keeps.
If WordPress renders a template with this block for that request instead of redirecting, both the View as Markdown link and the fetch() point off-site. The text/markdown content-type check doesn't help, because the other host can send that header. Whatever it serves then lands on the reader's clipboard, very possibly on its way into an LLM.
Forcing the URL to be root-relative fixes it, keeps the query string, and works on subdirectory installs. The backslash is in the trim list because browsers treat /\ like //:
| $url = add_query_arg( 'output_format', 'md' ); | |
| $url = '/' . ltrim( add_query_arg( 'output_format', 'md' ), '/\\' ); |
If the <link rel="alternate" type="text/markdown"> tag is built the same way, it probably needs the same fix.
🤖 Automated review comment, generated with Claude Code.
There was a problem hiding this comment.
Good catch, fixed in 7902211 with the suggested root-relative form (backslash included in the trim list). The <link rel="alternate" type="text/markdown"> tag isn't built in this repo, so nothing to change here for it.
| function Edit( { attributes, name } ) { | ||
| return ( | ||
| <div { ...useBlockProps() }> | ||
| <ServerSideRender block={ name } attributes={ attributes } skipBlockSupportAttributes /> |
There was a problem hiding this comment.
Minor, since the block is template-only: inside ServerSideRender, render.php runs during the /wp/v2/block-renderer REST request, so get_markdown_url() returns the REST endpoint plus output_format=md. In the Site Editor preview, View as Markdown points at the JSON endpoint and Copy page fails the content-type check. Maybe return a placeholder (such as home_url( '/?output_format=md' )) when wp_is_serving_rest_request() is true.
🤖 Automated review comment, generated with Claude Code.
There was a problem hiding this comment.
Fixed in 7902211: when wp_is_serving_rest_request() is true the URL is home_url( '/?output_format=md' ). Verified against /wp/v2/block-renderer/wporg/copy-page?context=edit, which now renders that href instead of the REST route.
| try { | ||
| const item = new window.ClipboardItem( { | ||
| 'text/plain': textPromise.then( ( text ) => new Blob( [ text ], { type: 'text/plain' } ) ), | ||
| } ); |
There was a problem hiding this comment.
If the ClipboardItem constructor throws, the textPromise.then( … ) promise created for it is left without a handler. When the fetch then fails (404, or a non-Markdown response), it rejects and logs "Uncaught (in promise)" next to the error that is handled. Marking it as handled fixes that, because write() or the writeText fallback still reports the failure:
const blobPromise = textPromise.then( ( text ) => new Blob( [ text ], { type: 'text/plain' } ) );
blobPromise.catch( () => {} ); // Rejection surfaces via write() or the writeText fallback.
const item = new window.ClipboardItem( { 'text/plain': blobPromise } );(See the comment on the writeToClipboard() docblock: the catch branch may not be reachable at all, in which case dropping the try/catch fixes this too.)
🤖 Automated review comment, generated with Claude Code.
There was a problem hiding this comment.
Fixed in 7902211: the blob promise is created first and marked as handled with a no-op catch, then passed to ClipboardItem. Tested by routing the Markdown request to a 404: the block shows "Copy failed" and the console only has the resource 404, no unhandled rejection.
| // Initial state to pass to Interactivity API. | ||
| $init_state = array( | ||
| 'url' => $markdown_url, | ||
| 'status' => 'idle', |
There was a problem hiding this comment.
status is written in both PHP and JS but never read. Meanwhile isLoading, isCopied, isError, labelText and ariaLabel are all derived from it, and setStatus() has to keep all six in sync.
The comment in setStatus() explains why they live in context: so the server can render the initial values. The Interactivity API supports server-side derived state for that. wp_interactivity_state() accepts closures that read wp_interactivity_get_context(), and getters with the same names go in the store's state. Context then only holds url, status and label, and setStatus() becomes context.status = status.
🤖 Automated review comment, generated with Claude Code.
There was a problem hiding this comment.
Refactored in 7902211 as suggested. Context now holds only url, status and label. isLoading, isCopied, isError, labelText and ariaLabel are derived state: closures passed to wp_interactivity_state() in render.php, with matching getters in the store's state in view.js. The markup binds to state.* and the action just sets context.status.
| <div | ||
| <?php echo get_block_wrapper_attributes(); // phpcs:ignore ?> | ||
| data-wp-interactive="wporg/copy-page" | ||
| data-wp-context="<?php echo esc_attr( $encoded_state ); ?>" |
There was a problem hiding this comment.
Nit (optional): wp_interactivity_data_wp_context( $init_state ) prints the whole data-wp-context attribute and replaces the wp_json_encode() + esc_attr() pair. The modal block already uses it.
🤖 Automated review comment, generated with Claude Code.
There was a problem hiding this comment.
Done in 7902211, using wp_interactivity_data_wp_context() like the modal block.
| * before 133) throw synchronously, so fall back to `writeText` in that case. | ||
| * |
There was a problem hiding this comment.
As far as I can tell, this misstates the browser support. Chromium has accepted Promise<Blob> values in ClipboardItem for a long time. What 133 added was plain string / Promise<string> values. Browsers without ClipboardItem skip this branch through the if, so it isn't clear any current browser reaches the catch. Either fix the comment so the fallback isn't removed (or kept) for the wrong reason, or drop the try/catch, which also takes care of the unhandled rejection noted below.
🤖 Automated review comment, generated with Claude Code.
There was a problem hiding this comment.
You're right about the version. Per MDN's compat data, Chromium 76–97 accepted only a Blob (a promise throws from the constructor), 98 added Promise<Blob>, and 133 added strings. I kept the fallback for that older range and corrected the comment in 7902211, and the unhandled rejection is dealt with separately as noted above.
- Force the Markdown URL to be root-relative so a protocol-relative request URI can't point the link and fetch at another host. - Return a home-page placeholder URL when rendered through the REST block-renderer endpoint (editor preview). - Derive the loading/copied/error flags and labels from `status`, with server-side derived state in render.php and matching getters in view.js, so context only holds url, status and labels. - Use wp_interactivity_data_wp_context() to print the context. - Mark the ClipboardItem blob promise as handled so a failed fetch doesn't also log an unhandled rejection, and correct the browser-support note: Chromium 76–97 accepted only a Blob, 98 added promises, 133 added strings.
What
WordPress.org serves a Markdown rendering of any page via
?output_format=mdor anAccept: text/markdownheader (Markdown now available on WordPress.org). This adds awporg/copy-pageblock that gives readers a Copy page button, which fetches that rendering and puts it on the clipboard, and a View as Markdown link to open it. It's meant for the article sidebar on developer.wordpress.org (theme PR to follow), but is a plain template block any site can place.Mobile is unchanged: the block is hidden below the sidebar's floating breakpoint, like Back to top.
The block
inserter: false), server rendered, Interactivity API view script (likefavorite-buttonandmodal). All bound values live in context so the server renders the complete markup.add_query_arg( 'output_format', 'md' )on the current request, the same way the site's<link rel="alternate" type="text/markdown">tag is built, so it also works on archives. Filterable viawporg_copy_page_markdown_url.Accept: text/markdownand refuses anything that doesn't come back astext/markdown, so an HTML page can never be copied by mistake.ClipboardItemwhere supported, which keeps Safari's user-gesture window open across the request, and falls back towriteText(including on Chromium before 133, which throws on promise values).aria-busy), copied (check icon, three seconds), error ("Copy failed", with the view link as the fallback). State changes are announced withwp.a11y.speak.copySmall,checkandarrowUpfrom@wordpress/icons, plus a hand-drawn Markdown glyph in the same 1.5px stroke style.hasViewLinkattribute (defaulttrue) toggles the link.Sidebar Container changes
These ship to every site using
wporg/sidebar-container(DevHub, documentation, make, 5ftf), so flagging them explicitly:arrowUpSVG in the same icon-plus-gap structure as the new rows, so the three labels line up. The translatable string changes from↑ Back to toptoBack to top; existing translations of the old string will need redoing.A shared
Helpers\is_markdown_request()(filterwporg_is_markdown_request) mirrors the checks html-to-md uses. Both blocks return nothing on Markdown requests, so the converted document contains no "Copy page", "View as Markdown" or "Back to top".Usage
Testing
Tested on a local wporg-developer wp-env with html-to-md active (it's the same plugin that powers the Markdown output, and it emits the same alternate link tag). Checked in Chromium with Playwright: pointer cursor, copy/copied/reset cycle, clipboard contents, Markdown output free of the three links, hidden below 1300px, no console errors. A manual check in Safari would be welcome.
composer run lint,npm run lint:css,npm run lint:jsand Prettier pass.This PR was generated by Claude Code on my behalf.