diff --git a/build/reaction-links/block.json b/build/reaction-links/block.json
new file mode 100644
index 00000000..f247323a
--- /dev/null
+++ b/build/reaction-links/block.json
@@ -0,0 +1,14 @@
+{
+ "name": "webmention/reaction-links",
+ "title": "Reaction Links: Microformats2 reaction classes for links",
+ "category": "widgets",
+ "icon": "format-status",
+ "keywords": [
+ "reaction",
+ "microformats",
+ "indieweb",
+ "webmention"
+ ],
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css"
+}
\ No newline at end of file
diff --git a/build/reaction-links/index-rtl.css b/build/reaction-links/index-rtl.css
new file mode 100644
index 00000000..3ab3ce18
--- /dev/null
+++ b/build/reaction-links/index-rtl.css
@@ -0,0 +1 @@
+.webmention-reaction-setting.block-editor-link-control__setting{border-top:1px solid #ddd;flex-wrap:nowrap;margin-top:0;padding-bottom:16px;padding-top:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__label{flex-shrink:0;padding-left:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__select{flex:1;max-width:180px}
diff --git a/build/reaction-links/index.asset.php b/build/reaction-links/index.asset.php
new file mode 100644
index 00000000..76d449f9
--- /dev/null
+++ b/build/reaction-links/index.asset.php
@@ -0,0 +1 @@
+ array('wp-data', 'wp-dom-ready', 'wp-i18n'), 'version' => '7cb8bf9f7714645e1a13');
diff --git a/build/reaction-links/index.css b/build/reaction-links/index.css
new file mode 100644
index 00000000..c82da9b6
--- /dev/null
+++ b/build/reaction-links/index.css
@@ -0,0 +1 @@
+.webmention-reaction-setting.block-editor-link-control__setting{border-top:1px solid #ddd;flex-wrap:nowrap;margin-top:0;padding-bottom:16px;padding-top:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__label{flex-shrink:0;padding-right:16px}.webmention-reaction-setting.block-editor-link-control__setting .webmention-reaction-setting__select{flex:1;max-width:180px}
diff --git a/build/reaction-links/index.js b/build/reaction-links/index.js
new file mode 100644
index 00000000..7a680010
--- /dev/null
+++ b/build/reaction-links/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";var e={n:t=>{var n=t&&t.__esModule?()=>t.default:()=>t;return e.d(n,{a:n}),n},d:(t,n)=>{for(var r in n)e.o(n,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:n[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t)};const t=window.wp.domReady;var n=e.n(t);const r=window.wp.data,o=window.wp.i18n,i=[{value:"",label:(0,o.__)("None","webmention")},{value:"u-in-reply-to",label:(0,o.__)("Reply","webmention")},{value:"u-like-of",label:(0,o.__)("Like","webmention")},{value:"u-repost-of",label:(0,o.__)("Repost","webmention")},{value:"u-bookmark-of",label:(0,o.__)("Bookmark","webmention")},{value:"u-tag-of",label:(0,o.__)("Tag","webmention")}],l=i.map(e=>e.value).filter(e=>e);let c=null,u=null;function a(){const e=function(){const e=(0,r.select)("core/block-editor").getSelectedBlock();if(!e)return null;const{clientId:t,attributes:n}=e,o=(0,r.select)("core/block-editor"),i="function"==typeof o.getSelectionStart,l=i?o.getSelectionStart():null;let u=l?.attributeKey;if(l?.clientId&&l.clientId!==t)return null;if(u&&(c={clientId:t,attributeKey:u}),u||c?.clientId===t&&(u=c.attributeKey),!u){if(i)return null;if(!Object.prototype.hasOwnProperty.call(n,"content"))return null;u="content"}const a=function(e){let t=e;return"object"==typeof t&&t?.toHTMLString&&(t=t.toHTMLString()),"string"==typeof t?t:null}(n[u]);return a?{clientId:t,attributeKey:u,content:a}:null}();if(!e)return null;const t=function(e){const t=(new window.DOMParser).parseFromString(`
${e}
`,"text/html").body.firstChild;return t?{container:t,anchors:Array.from(t.querySelectorAll("a"))}:null}(e.content);if(!t)return null;const n=function(){const e=document.querySelector(".block-editor-link-control__search-input input");if(e&&e.value)return e.value;const t=document.querySelector(".block-editor-link-control__search-item-info a");if(t){const e=t.getAttribute("href");if(e)return e.trim()}return null}();let o=function(e){const t=document.querySelector(`[data-block="${e}"]`),n=t?.ownerDocument?.defaultView||document.defaultView,r=n?.getSelection?n.getSelection():null;if(!r||0===r.rangeCount)return null;let o=r.anchorNode;if(!o||t&&!t.contains(o))return null;if(3===o.nodeType&&(o=o.parentElement),!o||1!==o.nodeType||"function"!=typeof o.closest)return null;const i=o.closest("a");if(!i)return null;const l=i.closest('[contenteditable="true"]');if(!l)return null;const c=Array.from(l.querySelectorAll("a")).indexOf(i);return c<0?null:{index:c,href:i.getAttribute("href")?.trim()||""}}(e.clientId);o?u={clientId:e.clientId,attributeKey:e.attributeKey,...o}:u?.clientId===e.clientId&&u?.attributeKey===e.attributeKey&&(o=u);const i=function(e,t,n){if(t&&t.index(e.getAttribute("href")||"").trim()===t.href);if(n>=0)return n}return n?e.findIndex(e=>(e.getAttribute("href")||"").trim()===n.trim()):-1}(t.anchors,o,n);return i<0?null:{...e,...t,targetAnchorIndex:i}}function s(){const e=document.querySelector(".block-editor-link-control__settings");if(!e)return;const t=a(),n=e.querySelector(".webmention-reaction-setting");if(!t)return void(n&&n.remove());const c=`${t.clientId}:${t.attributeKey}:${t.targetAnchorIndex}`,u=function(e){const t=e.anchors[e.targetAnchorIndex];return t?function(e){if(!e)return"";const t=e.trim().split(/\s+/);for(const e of l)if(t.includes(e))return e;return""}(t.getAttribute("class")):""}(t);if(n){if(n.dataset.target===c){const e=n.querySelector("select");return void(e&&e.value!==u&&(e.value=u))}n.remove()}const s=function(e,t){const n=document.createElement("div");n.className="block-editor-link-control__setting webmention-reaction-setting",n.dataset.target=e;const c=document.createElement("label");c.className="webmention-reaction-setting__label",c.setAttribute("for","webmention-reaction-select"),c.textContent=(0,o.__)("Reaction","webmention"),n.appendChild(c);const u=document.createElement("select");return u.id="webmention-reaction-select",u.className="webmention-reaction-setting__select components-select-control__input",i.forEach(e=>{const n=document.createElement("option");n.value=e.value,n.textContent=e.label,n.selected=e.value===t,u.appendChild(n)}),u.addEventListener("change",e=>{!function(e){const t=a();if(!t)return;const n=t.anchors[t.targetAnchorIndex];if(!n)return;const o=n.getAttribute("class")||"";l.forEach(e=>{n.classList.remove(e)}),e&&n.classList.add(e),0===n.classList.length&&n.removeAttribute("class"),o!==(n.getAttribute("class")||"")&&(0,r.dispatch)("core/block-editor").updateBlockAttributes(t.clientId,{[t.attributeKey]:t.container.innerHTML})}(e.target.value)}),n.appendChild(u),n}(c,u);e.appendChild(s)}function d(e,t){let n;return function(...r){clearTimeout(n),n=setTimeout(()=>e.apply(this,r),t)}}n()(()=>{const e=d(s,50),t=d(a,50),n=new window.MutationObserver(()=>{e()});document.addEventListener("selectionchange",t),n.observe(document.body,{childList:!0,subtree:!0}),window.addEventListener("beforeunload",()=>{n.disconnect(),document.removeEventListener("selectionchange",t)})})})();
\ No newline at end of file
diff --git a/build/rsvp/block.json b/build/rsvp/block.json
new file mode 100644
index 00000000..668bd179
--- /dev/null
+++ b/build/rsvp/block.json
@@ -0,0 +1,15 @@
+{
+ "name": "webmention/rsvp",
+ "title": "RSVP: Microformats2 RSVP format for event responses",
+ "category": "widgets",
+ "icon": "calendar-alt",
+ "keywords": [
+ "rsvp",
+ "microformats",
+ "indieweb",
+ "webmention",
+ "event"
+ ],
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css"
+}
\ No newline at end of file
diff --git a/build/rsvp/index-rtl.css b/build/rsvp/index-rtl.css
new file mode 100644
index 00000000..a0111293
--- /dev/null
+++ b/build/rsvp/index-rtl.css
@@ -0,0 +1 @@
+.webmention-rsvp-popover .webmention-rsvp-popover__buttons{display:flex;gap:4px;padding:8px}data.p-rsvp{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;text-decoration-thickness:2px;text-underline-offset:2px}
diff --git a/build/rsvp/index.asset.php b/build/rsvp/index.asset.php
new file mode 100644
index 00000000..c3d11329
--- /dev/null
+++ b/build/rsvp/index.asset.php
@@ -0,0 +1 @@
+ array('react-jsx-runtime', 'wp-block-editor', 'wp-components', 'wp-element', 'wp-i18n', 'wp-primitives', 'wp-rich-text'), 'version' => 'a767f7b502cf0dee8b77');
diff --git a/build/rsvp/index.css b/build/rsvp/index.css
new file mode 100644
index 00000000..a0111293
--- /dev/null
+++ b/build/rsvp/index.css
@@ -0,0 +1 @@
+.webmention-rsvp-popover .webmention-rsvp-popover__buttons{display:flex;gap:4px;padding:8px}data.p-rsvp{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;text-decoration-thickness:2px;text-underline-offset:2px}
diff --git a/build/rsvp/index.js b/build/rsvp/index.js
new file mode 100644
index 00000000..0b5d8096
--- /dev/null
+++ b/build/rsvp/index.js
@@ -0,0 +1 @@
+(()=>{"use strict";const e=window.wp.richText,t=window.wp.blockEditor,l=window.wp.element,o=window.wp.components,a=window.wp.i18n,n=window.wp.primitives,i=window.ReactJSXRuntime;var s=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{d:"M16.5 7.5 10 13.9l-2.5-2.4-1 1 3.5 3.6 7.5-7.6z"})}),w=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{d:"m13.06 12 6.47-6.47-1.06-1.06L12 10.94 5.53 4.47 4.47 5.53 10.94 12l-6.47 6.47 1.06 1.06L12 13.06l6.47 6.47 1.06-1.06L13.06 12Z"})}),r=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{d:"M12 4a8 8 0 1 1 .001 16.001A8 8 0 0 1 12 4Zm0 1.5a6.5 6.5 0 1 0-.001 13.001A6.5 6.5 0 0 0 12 5.5Zm.75 11h-1.5V15h1.5v1.5Zm-.445-9.234a3 3 0 0 1 .445 5.89V14h-1.5v-1.25c0-.57.452-.958.917-1.01A1.5 1.5 0 0 0 12 8.75a1.5 1.5 0 0 0-1.5 1.5H9a3 3 0 0 1 3.305-2.984Z"})}),v=(0,i.jsx)(n.SVG,{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 24 24",children:(0,i.jsx)(n.Path,{fillRule:"evenodd",d:"M9.706 8.646a.25.25 0 01-.188.137l-4.626.672a.25.25 0 00-.139.427l3.348 3.262a.25.25 0 01.072.222l-.79 4.607a.25.25 0 00.362.264l4.138-2.176a.25.25 0 01.233 0l4.137 2.175a.25.25 0 00.363-.263l-.79-4.607a.25.25 0 01.072-.222l3.347-3.262a.25.25 0 00-.139-.427l-4.626-.672a.25.25 0 01-.188-.137l-2.069-4.192a.25.25 0 00-.448 0L9.706 8.646zM12 7.39l-.948 1.921a1.75 1.75 0 01-1.317.957l-2.12.308 1.534 1.495c.412.402.6.982.503 1.55l-.362 2.11 1.896-.997a1.75 1.75 0 011.629 0l1.895.997-.362-2.11a1.75 1.75 0 01.504-1.55l1.533-1.495-2.12-.308a1.75 1.75 0 01-1.317-.957L12 7.39z",clipRule:"evenodd"})}),c=(0,i.jsx)(n.SVG,{viewBox:"0 0 24 24",xmlns:"http://www.w3.org/2000/svg",children:(0,i.jsx)(n.Path,{d:"M12 4c-4.4 0-8 3.6-8 8s3.6 8 8 8 8-3.6 8-8-3.6-8-8-8Zm3.8 10.7-1.1 1.1-2.7-2.7-2.7 2.7-1.1-1.1 2.7-2.7-2.7-2.7 1.1-1.1 2.7 2.7 2.7-2.7 1.1 1.1-2.7 2.7 2.7 2.7Z"})});const m="webmention/rsvp",u=[{value:"yes",label:(0,a.__)("Yes","webmention"),icon:s},{value:"no",label:(0,a.__)("No","webmention"),icon:w},{value:"maybe",label:(0,a.__)("Maybe","webmention"),icon:r},{value:"interested",label:(0,a.__)("Interested","webmention"),icon:v}];(0,e.registerFormatType)(m,{title:"RSVP",tagName:"data",className:"p-rsvp",attributes:{value:"value"},edit:({isActive:n,value:s,onChange:w})=>{const[r,v]=(0,l.useState)(!1),d=function(t){const l=(0,e.getActiveFormat)(t,m);return l?.attributes?.value?l.attributes.value:l?.unregisteredAttributes?.value?l.unregisteredAttributes.value:""}(s),p=u.find(e=>e.value===d),b=p?`RSVP: ${p.label}`:"RSVP";return(0,i.jsxs)(i.Fragment,{children:[(0,i.jsx)(t.RichTextToolbarButton,{icon:"calendar-alt",title:b,onClick:()=>v(!r),isActive:n}),r&&(0,i.jsx)(o.Popover,{className:"webmention-rsvp-popover",position:"bottom center",onClose:()=>v(!1),children:(0,i.jsxs)("div",{className:"webmention-rsvp-popover__buttons",children:[u.map(t=>(0,i.jsx)(o.Button,{icon:t.icon,label:t.label,showTooltip:!0,isPressed:d===t.value,onClick:()=>{w((0,e.applyFormat)(s,{type:m,attributes:{value:t.value}})),v(!1)}},t.value)),n&&(0,i.jsx)(o.Button,{icon:c,label:(0,a.__)("Remove","webmention"),showTooltip:!0,onClick:()=>{w((0,e.removeFormat)(s,m)),v(!1)}})]})})]})}})})();
\ No newline at end of file
diff --git a/includes/class-block.php b/includes/class-block.php
index 08630e8e..269fe1c7 100644
--- a/includes/class-block.php
+++ b/includes/class-block.php
@@ -9,6 +9,9 @@ class Block {
public static function init() {
// Add editor plugin.
\add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue_editor_assets' ) );
+
+ // Add RSVP styles inside editor iframe.
+ \add_action( 'enqueue_block_assets', array( self::class, 'enqueue_block_assets' ) );
}
/**
@@ -21,8 +24,92 @@ public static function enqueue_editor_assets() {
if ( ! $current_screen || ! in_array( $current_screen->post_type, $ap_post_types, true ) ) {
return;
}
+
+ // Enqueue the main editor plugin.
$asset_data = include WEBMENTION_PLUGIN_DIR . 'build/editor-plugin/plugin.asset.php';
$plugin_url = plugins_url( 'build/editor-plugin/plugin.js', WEBMENTION_PLUGIN_FILE );
wp_enqueue_script( 'webmention-block-editor', $plugin_url, $asset_data['dependencies'], $asset_data['version'], true );
+
+ // Enqueue the reaction links extension.
+ self::enqueue_reaction_links_assets();
+
+ // Enqueue the RSVP format.
+ self::enqueue_rsvp_assets();
+ }
+
+ /**
+ * Enqueue the reaction links extension assets.
+ */
+ public static function enqueue_reaction_links_assets() {
+ $asset_file = WEBMENTION_PLUGIN_DIR . 'build/reaction-links/index.asset.php';
+
+ if ( ! file_exists( $asset_file ) ) {
+ return;
+ }
+
+ $asset_data = include $asset_file;
+
+ wp_enqueue_script(
+ 'webmention-reaction-links',
+ plugins_url( 'build/reaction-links/index.js', WEBMENTION_PLUGIN_FILE ),
+ $asset_data['dependencies'],
+ $asset_data['version'],
+ true
+ );
+
+ wp_enqueue_style(
+ 'webmention-reaction-links',
+ plugins_url( 'build/reaction-links/index.css', WEBMENTION_PLUGIN_FILE ),
+ array(),
+ $asset_data['version']
+ );
+ }
+
+ /**
+ * Enqueue the RSVP format assets.
+ */
+ public static function enqueue_rsvp_assets() {
+ $asset_file = WEBMENTION_PLUGIN_DIR . 'build/rsvp/index.asset.php';
+
+ if ( ! file_exists( $asset_file ) ) {
+ return;
+ }
+
+ $asset_data = include $asset_file;
+
+ wp_enqueue_script(
+ 'webmention-rsvp',
+ plugins_url( 'build/rsvp/index.js', WEBMENTION_PLUGIN_FILE ),
+ $asset_data['dependencies'],
+ $asset_data['version'],
+ true
+ );
+ }
+
+ /**
+ * Enqueue block assets for editor content area (inside iframe).
+ *
+ * RSVP styles need to load inside the editor iframe to style
+ * the data.p-rsvp elements in the content.
+ */
+ public static function enqueue_block_assets() {
+ if ( ! is_admin() ) {
+ return;
+ }
+
+ $asset_file = WEBMENTION_PLUGIN_DIR . 'build/rsvp/index.asset.php';
+
+ if ( ! file_exists( $asset_file ) ) {
+ return;
+ }
+
+ $asset_data = include $asset_file;
+
+ wp_enqueue_style(
+ 'webmention-rsvp-editor',
+ plugins_url( 'build/rsvp/index.css', WEBMENTION_PLUGIN_FILE ),
+ array(),
+ $asset_data['version']
+ );
}
}
diff --git a/src/reaction-links/block.json b/src/reaction-links/block.json
new file mode 100644
index 00000000..de2faf02
--- /dev/null
+++ b/src/reaction-links/block.json
@@ -0,0 +1,9 @@
+{
+ "name": "webmention/reaction-links",
+ "title": "Reaction Links: Microformats2 reaction classes for links",
+ "category": "widgets",
+ "icon": "format-status",
+ "keywords": [ "reaction", "microformats", "indieweb", "webmention" ],
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css"
+}
diff --git a/src/reaction-links/editor.scss b/src/reaction-links/editor.scss
new file mode 100644
index 00000000..aa6b94c8
--- /dev/null
+++ b/src/reaction-links/editor.scss
@@ -0,0 +1,23 @@
+/**
+ * Microformats Reaction Links - Editor Styles
+ *
+ * Matches WordPress block-editor-link-control__setting design
+ */
+
+.webmention-reaction-setting.block-editor-link-control__setting {
+ flex-wrap: nowrap;
+ padding-top: 16px;
+ padding-bottom: 16px;
+ border-top: 1px solid #ddd;
+ margin-top: 0;
+
+ .webmention-reaction-setting__label {
+ flex-shrink: 0;
+ padding-right: 16px;
+ }
+
+ .webmention-reaction-setting__select {
+ flex: 1;
+ max-width: 180px;
+ }
+}
diff --git a/src/reaction-links/index.js b/src/reaction-links/index.js
new file mode 100644
index 00000000..c8b815fd
--- /dev/null
+++ b/src/reaction-links/index.js
@@ -0,0 +1,544 @@
+/**
+ * Microformats Reaction Links Extension
+ *
+ * Extends the WordPress block editor link popover to add microformats2
+ * reaction classes (u-in-reply-to, u-like-of, etc.) directly to anchor elements.
+ */
+import domReady from '@wordpress/dom-ready';
+import { select, dispatch } from '@wordpress/data';
+import { __ } from '@wordpress/i18n';
+
+import './editor.scss';
+
+/**
+ * Microformats reaction types
+ */
+const REACTION_TYPES = [
+ {
+ value: '',
+ label: __( 'None', 'webmention' ),
+ },
+ {
+ value: 'u-in-reply-to',
+ label: __( 'Reply', 'webmention' ),
+ },
+ {
+ value: 'u-like-of',
+ label: __( 'Like', 'webmention' ),
+ },
+ {
+ value: 'u-repost-of',
+ label: __( 'Repost', 'webmention' ),
+ },
+ {
+ value: 'u-bookmark-of',
+ label: __( 'Bookmark', 'webmention' ),
+ },
+ {
+ value: 'u-tag-of',
+ label: __( 'Tag', 'webmention' ),
+ },
+];
+
+const REACTION_CLASSES = REACTION_TYPES.map( ( t ) => t.value ).filter(
+ ( v ) => v
+);
+
+/**
+ * Debounce delay in milliseconds for MutationObserver callbacks.
+ */
+const DEBOUNCE_DELAY = 50;
+let lastRichTextSelection = null;
+let lastAnchorSelection = null;
+
+/**
+ * Get the currently selected block
+ */
+function getSelectedBlock() {
+ return select( 'core/block-editor' ).getSelectedBlock();
+}
+
+/**
+ * Parse reaction class from a class string
+ *
+ * @param {string} classStr Class attribute string.
+ * @return {string} Parsed reaction class.
+ */
+function parseReactionClass( classStr ) {
+ if ( ! classStr ) {
+ return '';
+ }
+
+ const classTokens = classStr.trim().split( /\s+/ );
+
+ for ( const cls of REACTION_CLASSES ) {
+ if ( classTokens.includes( cls ) ) {
+ return cls;
+ }
+ }
+ return '';
+}
+
+/**
+ * Get the URL being edited from the link popover
+ */
+function getCurrentLinkUrl() {
+ const urlInput = document.querySelector(
+ '.block-editor-link-control__search-input input'
+ );
+ if ( urlInput && urlInput.value ) {
+ return urlInput.value;
+ }
+
+ const urlDisplayLink = document.querySelector(
+ '.block-editor-link-control__search-item-info a'
+ );
+ if ( urlDisplayLink ) {
+ const href = urlDisplayLink.getAttribute( 'href' );
+ if ( href ) {
+ return href.trim();
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Normalize rich text values to an HTML string.
+ *
+ * @param {*} value Raw attribute value.
+ * @return {?string} HTML string when supported, otherwise null.
+ */
+function getHtmlString( value ) {
+ let content = value;
+
+ if ( typeof content === 'object' && content?.toHTMLString ) {
+ content = content.toHTMLString();
+ }
+
+ return typeof content === 'string' ? content : null;
+}
+
+/**
+ * Get the selected rich text attribute and its HTML content.
+ */
+function getEditableRichTextContext() {
+ const block = getSelectedBlock();
+ if ( ! block ) {
+ return null;
+ }
+
+ const { clientId, attributes } = block;
+ const blockEditorStore = select( 'core/block-editor' );
+ const hasSelectionApi =
+ typeof blockEditorStore.getSelectionStart === 'function';
+ const selectionStart = hasSelectionApi
+ ? blockEditorStore.getSelectionStart()
+ : null;
+ let attributeKey = selectionStart?.attributeKey;
+
+ if ( selectionStart?.clientId && selectionStart.clientId !== clientId ) {
+ return null;
+ }
+
+ if ( attributeKey ) {
+ lastRichTextSelection = {
+ clientId,
+ attributeKey,
+ };
+ }
+
+ if ( ! attributeKey ) {
+ if ( lastRichTextSelection?.clientId === clientId ) {
+ attributeKey = lastRichTextSelection.attributeKey;
+ }
+ }
+
+ if ( ! attributeKey ) {
+ // If the selection API is available but there is no rich-text attribute,
+ // this block shape is not supported by this extension.
+ if ( hasSelectionApi ) {
+ return null;
+ }
+
+ if ( ! Object.prototype.hasOwnProperty.call( attributes, 'content' ) ) {
+ return null;
+ }
+
+ attributeKey = 'content';
+ }
+
+ const content = getHtmlString( attributes[ attributeKey ] );
+ if ( ! content ) {
+ return null;
+ }
+
+ return {
+ clientId,
+ attributeKey,
+ content,
+ };
+}
+
+/**
+ * Parse anchors from an HTML string.
+ *
+ * @param {string} content HTML content from a rich-text attribute.
+ * @return {?Object} Parsed container and anchor list.
+ */
+function parseContentAnchors( content ) {
+ const parser = new window.DOMParser();
+ const doc = parser.parseFromString(
+ `${ content }
`,
+ 'text/html'
+ );
+ const container = doc.body.firstChild;
+
+ if ( ! container ) {
+ return null;
+ }
+
+ return {
+ container,
+ anchors: Array.from( container.querySelectorAll( 'a' ) ),
+ };
+}
+
+/**
+ * Get the currently selected anchor from the editor and its index.
+ *
+ * @param {string} clientId Current block client ID.
+ * @return {?Object} Selected anchor position and href.
+ */
+function getSelectedAnchorInEditor( clientId ) {
+ const selectedBlockEl = document.querySelector(
+ `[data-block="${ clientId }"]`
+ );
+ const view =
+ selectedBlockEl?.ownerDocument?.defaultView || document.defaultView;
+ const selection = view?.getSelection ? view.getSelection() : null;
+
+ if ( ! selection || selection.rangeCount === 0 ) {
+ return null;
+ }
+
+ let node = selection.anchorNode;
+
+ if ( ! node || ( selectedBlockEl && ! selectedBlockEl.contains( node ) ) ) {
+ return null;
+ }
+
+ if ( node.nodeType === 3 ) {
+ node = node.parentElement;
+ }
+
+ if ( ! node || node.nodeType !== 1 || typeof node.closest !== 'function' ) {
+ return null;
+ }
+
+ const anchor = node.closest( 'a' );
+ if ( ! anchor ) {
+ return null;
+ }
+
+ const editableRoot = anchor.closest( '[contenteditable="true"]' );
+ if ( ! editableRoot ) {
+ return null;
+ }
+
+ const anchorIndex = Array.from(
+ editableRoot.querySelectorAll( 'a' )
+ ).indexOf( anchor );
+ if ( anchorIndex < 0 ) {
+ return null;
+ }
+
+ return {
+ index: anchorIndex,
+ href: anchor.getAttribute( 'href' )?.trim() || '',
+ };
+}
+
+/**
+ * Resolve the single target anchor index in parsed content.
+ *
+ * @param {Array} anchors Parsed anchor elements from block attributes.
+ * @param {?Object} selectedAnchor Selected anchor position from editor DOM.
+ * @param {?string} targetUrl URL currently shown in the link popover.
+ * @return {number} Anchor index, or -1 when not resolvable.
+ */
+function getTargetAnchorIndex( anchors, selectedAnchor, targetUrl ) {
+ if ( selectedAnchor && selectedAnchor.index < anchors.length ) {
+ const indexedAnchorHref = (
+ anchors[ selectedAnchor.index ].getAttribute( 'href' ) || ''
+ ).trim();
+ if (
+ ! selectedAnchor.href ||
+ indexedAnchorHref === selectedAnchor.href
+ ) {
+ return selectedAnchor.index;
+ }
+ }
+
+ if ( selectedAnchor?.href ) {
+ const selectedHrefIndex = anchors.findIndex(
+ ( anchor ) =>
+ ( anchor.getAttribute( 'href' ) || '' ).trim() ===
+ selectedAnchor.href
+ );
+ if ( selectedHrefIndex >= 0 ) {
+ return selectedHrefIndex;
+ }
+ }
+
+ if ( targetUrl ) {
+ return anchors.findIndex(
+ ( anchor ) =>
+ ( anchor.getAttribute( 'href' ) || '' ).trim() ===
+ targetUrl.trim()
+ );
+ }
+
+ return -1;
+}
+
+/**
+ * Build a full reaction editing context for the selected link.
+ */
+function getReactionContext() {
+ const richTextContext = getEditableRichTextContext();
+ if ( ! richTextContext ) {
+ return null;
+ }
+
+ const parsed = parseContentAnchors( richTextContext.content );
+ if ( ! parsed ) {
+ return null;
+ }
+
+ const targetUrl = getCurrentLinkUrl();
+ let selectedAnchor = getSelectedAnchorInEditor( richTextContext.clientId );
+
+ if ( selectedAnchor ) {
+ lastAnchorSelection = {
+ clientId: richTextContext.clientId,
+ attributeKey: richTextContext.attributeKey,
+ ...selectedAnchor,
+ };
+ } else if (
+ lastAnchorSelection?.clientId === richTextContext.clientId &&
+ lastAnchorSelection?.attributeKey === richTextContext.attributeKey
+ ) {
+ selectedAnchor = lastAnchorSelection;
+ }
+
+ const targetAnchorIndex = getTargetAnchorIndex(
+ parsed.anchors,
+ selectedAnchor,
+ targetUrl
+ );
+
+ if ( targetAnchorIndex < 0 ) {
+ return null;
+ }
+
+ return {
+ ...richTextContext,
+ ...parsed,
+ targetAnchorIndex,
+ };
+}
+
+/**
+ * Get current reaction from the selected anchor in block content.
+ *
+ * @param {Object} reactionContext Reaction context for the selected link.
+ * @return {string} Active reaction class.
+ */
+function getCurrentReactionFromBlock( reactionContext ) {
+ const targetAnchor =
+ reactionContext.anchors[ reactionContext.targetAnchorIndex ];
+ if ( ! targetAnchor ) {
+ return '';
+ }
+
+ return parseReactionClass( targetAnchor.getAttribute( 'class' ) );
+}
+
+/**
+ * Apply reaction class to the anchor in block content
+ *
+ * @param {string} reaction Reaction class to apply.
+ */
+function applyReaction( reaction ) {
+ const reactionContext = getReactionContext();
+ if ( ! reactionContext ) {
+ return;
+ }
+
+ const targetAnchor =
+ reactionContext.anchors[ reactionContext.targetAnchorIndex ];
+ if ( ! targetAnchor ) {
+ return;
+ }
+
+ const previousClassAttribute = targetAnchor.getAttribute( 'class' ) || '';
+
+ // Remove existing reaction classes
+ REACTION_CLASSES.forEach( ( cls ) => {
+ targetAnchor.classList.remove( cls );
+ } );
+
+ // Add new reaction class
+ if ( reaction ) {
+ targetAnchor.classList.add( reaction );
+ }
+
+ // Clean up empty class attribute
+ if ( targetAnchor.classList.length === 0 ) {
+ targetAnchor.removeAttribute( 'class' );
+ }
+
+ const updatedClassAttribute = targetAnchor.getAttribute( 'class' ) || '';
+ const modified = previousClassAttribute !== updatedClassAttribute;
+
+ if ( modified ) {
+ dispatch( 'core/block-editor' ).updateBlockAttributes(
+ reactionContext.clientId,
+ {
+ [ reactionContext.attributeKey ]:
+ reactionContext.container.innerHTML,
+ }
+ );
+ }
+}
+
+/**
+ * Create the reaction dropdown
+ *
+ * @param {string} targetKey Unique key for selected anchor context.
+ * @param {string} currentReaction Current reaction class.
+ * @return {HTMLElement} Dropdown container element.
+ */
+function createReactionDropdown( targetKey, currentReaction ) {
+ const container = document.createElement( 'div' );
+ container.className =
+ 'block-editor-link-control__setting webmention-reaction-setting';
+ container.dataset.target = targetKey;
+
+ const label = document.createElement( 'label' );
+ label.className = 'webmention-reaction-setting__label';
+ label.setAttribute( 'for', 'webmention-reaction-select' );
+ label.textContent = __( 'Reaction', 'webmention' );
+ container.appendChild( label );
+
+ const selectEl = document.createElement( 'select' );
+ selectEl.id = 'webmention-reaction-select';
+ selectEl.className =
+ 'webmention-reaction-setting__select components-select-control__input';
+
+ REACTION_TYPES.forEach( ( type ) => {
+ const option = document.createElement( 'option' );
+ option.value = type.value;
+ option.textContent = type.label;
+ option.selected = type.value === currentReaction;
+ selectEl.appendChild( option );
+ } );
+
+ selectEl.addEventListener( 'change', ( e ) => {
+ applyReaction( e.target.value );
+ } );
+
+ container.appendChild( selectEl );
+ return container;
+}
+
+/**
+ * Inject the reaction dropdown into the link popover
+ */
+function injectReactionDropdown() {
+ const settingsDrawer = document.querySelector(
+ '.block-editor-link-control__settings'
+ );
+
+ if ( ! settingsDrawer ) {
+ return;
+ }
+
+ const reactionContext = getReactionContext();
+ const existingDropdown = settingsDrawer.querySelector(
+ '.webmention-reaction-setting'
+ );
+ if ( ! reactionContext ) {
+ if ( existingDropdown ) {
+ existingDropdown.remove();
+ }
+ return;
+ }
+
+ const targetKey = `${ reactionContext.clientId }:${ reactionContext.attributeKey }:${ reactionContext.targetAnchorIndex }`;
+ const currentReaction = getCurrentReactionFromBlock( reactionContext );
+
+ if ( existingDropdown ) {
+ // Recreate the dropdown if another link or attribute is selected.
+ if ( existingDropdown.dataset.target !== targetKey ) {
+ existingDropdown.remove();
+ } else {
+ // Selection is the same, just update the selected value if needed.
+ const selectEl = existingDropdown.querySelector( 'select' );
+ if ( selectEl && selectEl.value !== currentReaction ) {
+ selectEl.value = currentReaction;
+ }
+ return;
+ }
+ }
+
+ const dropdown = createReactionDropdown( targetKey, currentReaction );
+ settingsDrawer.appendChild( dropdown );
+}
+
+/**
+ * Debounce helper
+ *
+ * @param {Function} func Function to debounce.
+ * @param {number} wait Debounce delay in milliseconds.
+ * @return {Function} Debounced function.
+ */
+function debounce( func, wait ) {
+ let timeout;
+ return function ( ...args ) {
+ clearTimeout( timeout );
+ timeout = setTimeout( () => func.apply( this, args ), wait );
+ };
+}
+
+/**
+ * Initialize
+ */
+domReady( () => {
+ const debouncedInject = debounce( injectReactionDropdown, DEBOUNCE_DELAY );
+ const debouncedSelectionCache = debounce(
+ getReactionContext,
+ DEBOUNCE_DELAY
+ );
+
+ const observer = new window.MutationObserver( () => {
+ debouncedInject();
+ } );
+
+ document.addEventListener( 'selectionchange', debouncedSelectionCache );
+
+ observer.observe( document.body, {
+ childList: true,
+ subtree: true,
+ } );
+
+ // Cleanup on page unload
+ window.addEventListener( 'beforeunload', () => {
+ observer.disconnect();
+ document.removeEventListener(
+ 'selectionchange',
+ debouncedSelectionCache
+ );
+ } );
+} );
diff --git a/src/rsvp/block.json b/src/rsvp/block.json
new file mode 100644
index 00000000..761a2792
--- /dev/null
+++ b/src/rsvp/block.json
@@ -0,0 +1,9 @@
+{
+ "name": "webmention/rsvp",
+ "title": "RSVP: Microformats2 RSVP format for event responses",
+ "category": "widgets",
+ "icon": "calendar-alt",
+ "keywords": [ "rsvp", "microformats", "indieweb", "webmention", "event" ],
+ "editorScript": "file:./index.js",
+ "editorStyle": "file:./index.css"
+}
diff --git a/src/rsvp/editor.scss b/src/rsvp/editor.scss
new file mode 100644
index 00000000..85930815
--- /dev/null
+++ b/src/rsvp/editor.scss
@@ -0,0 +1,18 @@
+/**
+ * RSVP Format - Editor Styles
+ */
+
+.webmention-rsvp-popover {
+ .webmention-rsvp-popover__buttons {
+ display: flex;
+ padding: 8px;
+ gap: 4px;
+ }
+}
+
+/* Style RSVP text in the editor */
+data.p-rsvp {
+ text-decoration: underline dotted;
+ text-decoration-thickness: 2px;
+ text-underline-offset: 2px;
+}
diff --git a/src/rsvp/index.js b/src/rsvp/index.js
new file mode 100644
index 00000000..b422cd79
--- /dev/null
+++ b/src/rsvp/index.js
@@ -0,0 +1,131 @@
+/**
+ * RSVP Rich Text Format
+ *
+ * Adds text markup
+ * for microformats2 RSVP responses to events.
+ */
+import { registerFormatType, applyFormat, removeFormat, getActiveFormat } from '@wordpress/rich-text';
+import { RichTextToolbarButton } from '@wordpress/block-editor';
+import { useState } from '@wordpress/element';
+import { Popover, Button } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { check, close, help, starEmpty, cancelCircleFilled } from '@wordpress/icons';
+
+import './editor.scss';
+
+const FORMAT_NAME = 'webmention/rsvp';
+
+const RSVP_VALUES = [
+ {
+ value: 'yes',
+ label: __( 'Yes', 'webmention' ),
+ icon: check,
+ },
+ {
+ value: 'no',
+ label: __( 'No', 'webmention' ),
+ icon: close,
+ },
+ {
+ value: 'maybe',
+ label: __( 'Maybe', 'webmention' ),
+ icon: help,
+ },
+ {
+ value: 'interested',
+ label: __( 'Interested', 'webmention' ),
+ icon: starEmpty,
+ },
+];
+
+/**
+ * Get current RSVP value from format
+ */
+function getCurrentRsvpValue( value ) {
+ const format = getActiveFormat( value, FORMAT_NAME );
+ if ( format?.attributes?.value ) {
+ return format.attributes.value;
+ }
+ if ( format?.unregisteredAttributes?.value ) {
+ return format.unregisteredAttributes.value;
+ }
+ return '';
+}
+
+/**
+ * RSVP Format Edit Component
+ */
+const RsvpFormatEdit = ( { isActive, value, onChange } ) => {
+ const [ isOpen, setIsOpen ] = useState( false );
+ const currentValue = getCurrentRsvpValue( value );
+
+ const currentItem = RSVP_VALUES.find( ( v ) => v.value === currentValue );
+ const buttonTitle = currentItem
+ ? `RSVP: ${ currentItem.label }`
+ : 'RSVP';
+
+ return (
+ <>
+ setIsOpen( ! isOpen ) }
+ isActive={ isActive }
+ />
+ { isOpen && (
+ setIsOpen( false ) }
+ >
+
+ { RSVP_VALUES.map( ( rsvp ) => (
+
+
+ ) }
+ >
+ );
+};
+
+/**
+ * Register the RSVP format type
+ */
+registerFormatType( FORMAT_NAME, {
+ title: 'RSVP',
+ tagName: 'data',
+ className: 'p-rsvp',
+ attributes: {
+ value: 'value',
+ },
+ edit: RsvpFormatEdit,
+} );