This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,61 @@
import apiFetch from '@wordpress/api-fetch';
import { API_PATH, VALID_SECTIONS } from '../constants';
/**
* Generate or improve guidelines for the given sections/blocks.
*
* Translates between the internal format used by our components and the
* API's categories-based format:
*
* API request: { categories: { site: {}, copy: { guidelines: "..." }, blocks: { "core/paragraph": {} } } }
* API response: { site: { guidelines: "..." }, blocks: { "core/paragraph": { guidelines: "..." } } }
*
* @param {string[]} slugs - Section slugs or block names to generate.
* @param {Object.<string, string>} [existingContent] - Existing content keyed by slug.
* @return {Promise<Object>} Response with `suggestions` keyed by slug.
*/
export async function suggestGuidelines( slugs, existingContent = {} ) {
// Build categories object for the API.
// Standard sections go as top-level keys, block names go under `blocks`.
const categories = {};
const blockEntries = {};
for ( const slug of slugs ) {
const existing = existingContent[ slug ];
const entry = existing ? { guidelines: existing } : {};
if ( VALID_SECTIONS.includes( slug ) ) {
categories[ slug ] = entry;
} else {
blockEntries[ slug ] = entry;
}
}
if ( Object.keys( blockEntries ).length > 0 ) {
categories.blocks = blockEntries;
}
const response = await apiFetch( {
path: API_PATH,
method: 'POST',
data: { categories },
} );
// Normalize API response to { suggestions: { slug: text } }.
const suggestions = {};
for ( const slug of slugs ) {
if ( VALID_SECTIONS.includes( slug ) ) {
const guidelines = response?.[ slug ]?.guidelines;
if ( guidelines ) {
suggestions[ slug ] = guidelines;
}
} else {
const guidelines = response?.blocks?.[ slug ]?.guidelines;
if ( guidelines ) {
suggestions[ slug ] = guidelines;
}
}
}
return { suggestions };
}
@@ -0,0 +1,31 @@
/**
* Programmatically set a React-controlled textarea's value.
* Uses the native setter so React's synthetic onChange fires.
*
* @param {HTMLTextAreaElement} textarea - The textarea element.
* @param {string} value - The new value.
*/
export function setTextareaValue( textarea, value ) {
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set;
setter.call( textarea, value );
textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) );
}
/**
* Accept a block suggestion: write text to the modal textarea and clear the store.
*
* @param {HTMLElement} blockModal - The block guideline modal element.
* @param {string} blockName - Block name key in the store.
* @param {string} suggestion - Suggestion text to write.
* @param {Function} clearSuggestion - Store action to clear the suggestion.
*/
export function acceptBlockSuggestion( blockModal, blockName, suggestion, clearSuggestion ) {
const textarea = blockModal?.querySelector( '.components-textarea-control__input' );
if ( textarea ) {
setTextareaValue( textarea, suggestion );
}
clearSuggestion( blockName );
}
@@ -0,0 +1,382 @@
import { select } from '@wordpress/data';
import { createRoot, createElement } from '@wordpress/element';
import BlockSuggestionActions from '../components/block-suggestion-actions';
import BlockSuggestionButtons from '../components/block-suggestion-buttons';
import EmptyStateBanner from '../components/empty-state-banner';
import SectionGenerateButton from '../components/section-generate-button';
import SuggestAllButton from '../components/suggest-all-button';
import SuggestionActions from '../components/suggestion-actions';
import SuggestionBadge from '../components/suggestion-badge';
import UpgradeNotice from '../components/upgrade-notice';
import { VALID_SECTIONS } from '../constants';
// Each injection point tracks both the DOM container and its React root.
// Before re-injecting, we unmount the old root to ensure proper cleanup
// of effects and subscriptions. We verify containers via isConnected since
// Gutenberg's <Navigator> removes/re-adds the main screen DOM when
// navigating to revision history and back.
const slots = {
header: { container: null, root: null },
'upgrade-notice': { container: null, root: null },
banner: { container: null, root: null },
};
for ( const slug of VALID_SECTIONS ) {
slots[ `badge-${ slug }` ] = { container: null, root: null };
slots[ `actions-${ slug }` ] = { container: null, root: null };
slots[ `button-${ slug }` ] = { container: null, root: null };
}
slots[ 'block-actions' ] = { container: null, root: null };
slots[ 'block-suggestion-buttons' ] = { container: null, root: null };
// Block name the block-modal slots were last rendered with. Lets runAll()
// detect when the create-mode combobox switches to a different block while
// the slots are still mounted.
let lastBlockName = null;
/**
* Inject a React component into the DOM, reusing or replacing the slot.
*
* @param {string} key - Slot key in the slots map.
* @param {Function} findParent - Returns { parent, before, className } or null.
* @param {Function} Component - React component to render.
* @param {Object} [props] - Props to pass to the component.
*/
function inject( key, findParent, Component, props ) {
const slot = slots[ key ];
// Already injected and still in DOM — nothing to do.
if ( slot.container?.isConnected ) {
return;
}
// Container was removed — unmount the old root to clean up effects.
if ( slot.root ) {
slot.root.unmount();
slot.root = null;
slot.container = null;
}
const target = findParent();
if ( ! target ) {
return;
}
const { parent, before, className, tag } = target;
const container = document.createElement( tag || 'div' );
container.className = className;
if ( before ) {
parent.insertBefore( container, before );
} else {
parent.appendChild( container );
}
const root = createRoot( container );
root.render( createElement( Component, props ) );
slot.container = container;
slot.root = root;
}
/**
* Unmount a slot's React root and remove its container from the DOM.
*
* Unlike inject()'s cleanup path, the container may still be connected —
* used when a slot must re-render with different props (e.g. the block
* modal's combobox switching blocks).
*
* @param {string} key - Slot key in the slots map.
*/
function unmountSlot( key ) {
const slot = slots[ key ];
if ( slot.root ) {
slot.root.unmount();
slot.root = null;
}
if ( slot.container ) {
slot.container.remove();
slot.container = null;
}
}
/**
* Extract the block name from the block guideline modal.
* In editing mode, reads the disabled TextControl and matches against block types.
* In creating mode, reads the ComboboxControl's selected value.
*/
function getBlockNameFromModal( modal ) {
const blockTypes = select( 'core/blocks' ).getBlockTypes();
// Editing mode: disabled input shows block title.
const disabledInput = modal.querySelector( 'input[disabled]' );
if ( disabledInput?.value ) {
return blockTypes.find( b => b.title === disabledInput.value )?.name;
}
// Creating mode: combobox with selected value.
const combobox = modal.querySelector( 'input[role="combobox"]' );
if ( combobox?.value ) {
return blockTypes.find( b => b.title === combobox.value )?.name;
}
return null;
}
function runAll() {
// Header button — right-aligned in the wp-admin Page header, where the
// native header actions would render. The gutenberg page passes no
// `actions` to <Page>, so that slot is never created; instead we target the
// header-content row (flex, justify: space-between) that holds the title and
// append the button as its second child so space-between pushes it to the
// right. All header classes are hashed CSS-module names, so we locate the
// row structurally: the space-between flex row containing the page <h1>.
inject(
'header',
() => {
// Wait until the guidelines have loaded before mounting the button.
// Gutenberg renders the list only after its async fetch resolves
// (a spinner shows until then); mounting earlier reads the empty
// default store and flickers the label "Generate" -> "Improve".
if ( ! document.querySelector( '.guidelines__list' ) ) {
return null;
}
const region = document.querySelector( '.admin-ui-navigable-region' );
const heading = region?.querySelector( 'h1' );
let row = heading?.parentElement;
while ( row && row !== region ) {
const style = window.getComputedStyle( row );
if (
style.display === 'flex' &&
style.flexDirection === 'row' &&
style.justifyContent.includes( 'between' )
) {
break;
}
row = row.parentElement;
}
return row && row !== region
? {
parent: row,
className: 'jetpack-content-guidelines-ai__header-container',
}
: null;
},
SuggestAllButton
);
// Upgrade notice — shown above the guideline list when AI is unavailable.
inject(
'upgrade-notice',
() => {
const list = document.querySelector( '.guidelines__list' );
return list
? {
parent: list.parentElement,
before: list,
className: 'jetpack-content-guidelines-ai__upgrade-notice-container',
}
: null;
},
UpgradeNotice
);
// Empty state banner.
inject(
'banner',
() => {
const list = document.querySelector( '.guidelines__list' );
return list
? {
parent: list.parentElement,
before: list,
className: 'jetpack-content-guidelines-ai__banner-container',
}
: null;
},
EmptyStateBanner
);
// Per-section injections. Sections are matched by the stable `data-slug`
// attribute on each `.guidelines__list-item`. The per-section form is
// always present in the DOM (the CollapsibleCard keeps its content mounted
// while collapsed).
for ( const slug of VALID_SECTIONS ) {
// Steady-state fast path: once this section's three slots are injected
// and still connected, there is nothing to do — skip the DOM queries so
// the observer's per-frame work stays cheap. If the <Navigator> removes
// the screen, the containers disconnect and we fall through to re-inject.
if (
slots[ `badge-${ slug }` ].container?.isConnected &&
slots[ `actions-${ slug }` ].container?.isConnected &&
slots[ `button-${ slug }` ].container?.isConnected
) {
continue;
}
const item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
const form = item?.querySelector( 'form' );
if ( ! form ) {
continue;
}
// Badge in the accordion header (the section's heading element).
inject(
`badge-${ slug }`,
() => {
const heading = item.querySelector( 'h1, h2, h3, h4, h5, h6' );
const titleStack = heading?.firstElementChild ?? heading;
return titleStack
? {
parent: titleStack,
className: 'jetpack-content-guidelines-ai__badge-container',
tag: 'span',
}
: null;
},
SuggestionBadge,
{ slug }
);
// Suggestion actions (diff + accept/dismiss) at top of form.
inject(
`actions-${ slug }`,
() => {
const vStack = form.firstElementChild;
return vStack
? {
parent: vStack,
before: vStack.firstChild,
className: 'jetpack-content-guidelines-ai__actions-container',
}
: null;
},
SuggestionActions,
{ slug }
);
// Per-section generate button next to the Save button (the form's
// primary submit button lives in an HStack with the Clear button).
inject(
`button-${ slug }`,
() => {
const saveButton = form.querySelector( 'button[type="submit"]' );
const hStack = saveButton?.parentElement;
return hStack
? {
parent: hStack,
className: 'jetpack-content-guidelines-ai__section-button-container',
}
: null;
},
SectionGenerateButton,
{ slug }
);
}
// Block guideline modal injections.
const blockModal = document.querySelector( '.block-guideline-modal' );
// Resolve the block name on every pass while the modal is open — even when
// the slots are already mounted. In create mode the user can switch the
// combobox to a different block after our components rendered, and
// blockName is bound as a prop at render time. When the resolved name
// changes, tear the mounted slots down so they re-inject below with the
// new name; otherwise a suggestion generated for the previous block would
// be accepted into the newly selected one. Unmounting also runs
// BlockSuggestionActions' cleanup, clearing the stale suggestion from the
// store.
const blockName = blockModal ? getBlockNameFromModal( blockModal ) : null;
if ( blockName !== lastBlockName ) {
unmountSlot( 'block-actions' );
unmountSlot( 'block-suggestion-buttons' );
lastBlockName = blockName;
}
// Always invoke inject so previously mounted roots get unmounted when
// the modal closes — findParent() returns null for "no place to inject"
// and inject()'s cleanup path handles the disconnected container.
inject(
'block-actions',
() => {
if ( ! blockName || ! blockModal ) {
return null;
}
const textareaInput = blockModal.querySelector( '.components-textarea-control__input' );
const field = textareaInput?.parentElement;
return field
? {
parent: field,
before: textareaInput,
className: 'jetpack-content-guidelines-ai__block-actions-container',
}
: null;
},
BlockSuggestionActions,
{ blockName, blockModal }
);
inject(
'block-suggestion-buttons',
() => {
if ( ! blockName || ! blockModal ) {
return null;
}
const actionsBar = blockModal.querySelector( '.block-guideline-modal__actions' );
const vStack = actionsBar?.parentElement;
return vStack
? {
parent: vStack,
before: actionsBar,
className: 'jetpack-content-guidelines-ai__block-suggestion-buttons-container',
}
: null;
},
BlockSuggestionButtons,
{ blockName, blockModal }
);
}
/**
* Start observing DOM and inject all components.
*
* We observe document.body (not a narrower container) for two reasons:
* 1. WordPress Modal portals render directly on document.body — the block
* guideline modal lives outside any Gutenberg container, so a narrower
* root would miss it appearing.
* 2. Gutenberg's Navigator can remove and re-add the main screen DOM
* (e.g. revision history navigation), so we can't rely on a specific
* container staying connected.
*
* The observer never disconnects for the same reasons. Callbacks are
* debounced via requestAnimationFrame so runAll() fires at most once
* per frame, and each inject() call is a no-op when its container is
* still connected.
*/
let injectionStarted = false;
export function startInjection() {
if ( injectionStarted ) {
return;
}
injectionStarted = true;
runAll();
let scheduled = false;
const observer = new MutationObserver( () => {
if ( ! scheduled ) {
scheduled = true;
requestAnimationFrame( () => {
scheduled = false;
runAll();
} );
}
} );
observer.observe( document.body, { childList: true, subtree: true } );
}
@@ -0,0 +1,22 @@
import analytics from '@automattic/jetpack-analytics';
/**
* Record a content guidelines Tracks event.
*
* @param {string} eventName - Event name suffix (appended to `jetpack_ai_guidelines_`).
* @param {Object} properties - Event properties.
*/
export function recordGuidelinesEvent( eventName, properties = {} ) {
analytics.tracks.recordEvent( `jetpack_ai_guidelines_${ eventName }`, properties );
}
/**
* Record a generic Jetpack AI Tracks event.
* Use for shared events like `jetpack_ai_upgrade_button`.
*
* @param {string} eventName - Full event name.
* @param {Object} properties - Event properties.
*/
export function recordAiEvent( eventName, properties = {} ) {
analytics.tracks.recordEvent( eventName, properties );
}