PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.0-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.0-beta
16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / content-guidelines-ai / lib / inject.js
jetpack / _inc / content-guidelines-ai / lib Last commit date
api.js 2 weeks ago dom.js 2 weeks ago inject.js 6 days ago tracks.js 2 weeks ago
inject.js
394 lines
1 import { select } from '@wordpress/data';
2 import { createRoot, createElement } from '@wordpress/element';
3 import BlockSuggestionActions from '../components/block-suggestion-actions';
4 import BlockSuggestionButtons from '../components/block-suggestion-buttons';
5 import EmptyStateBanner from '../components/empty-state-banner';
6 import SectionGenerateButton from '../components/section-generate-button';
7 import SuggestAllButton from '../components/suggest-all-button';
8 import SuggestionActions from '../components/suggestion-actions';
9 import SuggestionBadge from '../components/suggestion-badge';
10 import UpgradeNotice from '../components/upgrade-notice';
11 import { VALID_SECTIONS } from '../constants';
12
13 // Each injection point tracks both the DOM container and its React root.
14 // Before re-injecting, we unmount the old root to ensure proper cleanup
15 // of effects and subscriptions. We verify containers via isConnected since
16 // Gutenberg's <Navigator> removes/re-adds the main screen DOM when
17 // navigating to revision history and back.
18
19 const slots = {
20 header: { container: null, root: null },
21 'upgrade-notice': { container: null, root: null },
22 banner: { container: null, root: null },
23 };
24
25 for ( const slug of VALID_SECTIONS ) {
26 slots[ `badge-${ slug }` ] = { container: null, root: null };
27 slots[ `actions-${ slug }` ] = { container: null, root: null };
28 slots[ `button-${ slug }` ] = { container: null, root: null };
29 }
30
31 slots[ 'block-actions' ] = { container: null, root: null };
32 slots[ 'block-suggestion-buttons' ] = { container: null, root: null };
33
34 // Block name the block-modal slots were last rendered with. Lets runAll()
35 // detect when the create-mode combobox switches to a different block while
36 // the slots are still mounted.
37 let lastBlockName = null;
38
39 /**
40 * Inject a React component into the DOM, reusing or replacing the slot.
41 *
42 * @param {string} key - Slot key in the slots map.
43 * @param {Function} findParent - Returns { parent, before, className } or null.
44 * @param {Function} Component - React component to render.
45 * @param {Object} [props] - Props to pass to the component.
46 */
47 function inject( key, findParent, Component, props ) {
48 const slot = slots[ key ];
49
50 // Already injected and still in DOM — nothing to do.
51 if ( slot.container?.isConnected ) {
52 return;
53 }
54
55 // Container was removed — unmount the old root to clean up effects.
56 if ( slot.root ) {
57 slot.root.unmount();
58 slot.root = null;
59 slot.container = null;
60 }
61
62 const target = findParent();
63 if ( ! target ) {
64 return;
65 }
66
67 const { parent, before, className, tag } = target;
68 const container = document.createElement( tag || 'div' );
69 container.className = className;
70
71 if ( before ) {
72 parent.insertBefore( container, before );
73 } else {
74 parent.appendChild( container );
75 }
76
77 const root = createRoot( container );
78 root.render( createElement( Component, props ) );
79
80 slot.container = container;
81 slot.root = root;
82 }
83
84 /**
85 * Unmount a slot's React root and remove its container from the DOM.
86 *
87 * Unlike inject()'s cleanup path, the container may still be connected —
88 * used when a slot must re-render with different props (e.g. the block
89 * modal's combobox switching blocks).
90 *
91 * @param {string} key - Slot key in the slots map.
92 */
93 function unmountSlot( key ) {
94 const slot = slots[ key ];
95 if ( slot.root ) {
96 slot.root.unmount();
97 slot.root = null;
98 }
99 if ( slot.container ) {
100 slot.container.remove();
101 slot.container = null;
102 }
103 }
104
105 /**
106 * Extract the block name from the block guideline modal.
107 * In editing mode, reads the disabled TextControl and matches against block types.
108 * In creating mode, reads the ComboboxControl's selected value.
109 */
110 function getBlockNameFromModal( modal ) {
111 const blockTypes = select( 'core/blocks' ).getBlockTypes();
112
113 // Editing mode: disabled input shows block title.
114 const disabledInput = modal.querySelector( 'input[disabled]' );
115 if ( disabledInput?.value ) {
116 return blockTypes.find( b => b.title === disabledInput.value )?.name;
117 }
118
119 // Creating mode: combobox with selected value.
120 const combobox = modal.querySelector( 'input[role="combobox"]' );
121 if ( combobox?.value ) {
122 return blockTypes.find( b => b.title === combobox.value )?.name;
123 }
124
125 return null;
126 }
127
128 function runAll() {
129 // Header button — right-aligned in the wp-admin Page header, where the
130 // native header actions would render. The gutenberg page passes no
131 // `actions` to <Page>, so that slot is never created; instead we target the
132 // header-content row (flex, justify: space-between) that holds the title and
133 // append the button as its second child so space-between pushes it to the
134 // right. All header classes are hashed CSS-module names, so we locate the
135 // row structurally: the space-between flex row containing the page <h1>.
136 inject(
137 'header',
138 () => {
139 // Wait until the guidelines have loaded before mounting the button.
140 // Gutenberg renders the list only after its async fetch resolves
141 // (a spinner shows until then); mounting earlier reads the empty
142 // default store and flickers the label "Generate" -> "Improve".
143 if ( ! document.querySelector( '.guidelines__list' ) ) {
144 return null;
145 }
146
147 const region = document.querySelector( '.admin-ui-navigable-region' );
148 const heading = region?.querySelector( 'h1' );
149 let row = heading?.parentElement;
150 while ( row && row !== region ) {
151 const style = window.getComputedStyle( row );
152 if (
153 style.display === 'flex' &&
154 style.flexDirection === 'row' &&
155 style.justifyContent.includes( 'between' )
156 ) {
157 break;
158 }
159 row = row.parentElement;
160 }
161 return row && row !== region
162 ? {
163 parent: row,
164 className: 'jetpack-content-guidelines-ai__header-container',
165 }
166 : null;
167 },
168 SuggestAllButton
169 );
170
171 // Upgrade notice — shown above the guideline list when AI is unavailable.
172 inject(
173 'upgrade-notice',
174 () => {
175 const list = document.querySelector( '.guidelines__list' );
176 return list
177 ? {
178 parent: list.parentElement,
179 before: list,
180 className: 'jetpack-content-guidelines-ai__upgrade-notice-container',
181 }
182 : null;
183 },
184 UpgradeNotice
185 );
186
187 // Empty state banner.
188 inject(
189 'banner',
190 () => {
191 const list = document.querySelector( '.guidelines__list' );
192 return list
193 ? {
194 parent: list.parentElement,
195 before: list,
196 className: 'jetpack-content-guidelines-ai__banner-container',
197 }
198 : null;
199 },
200 EmptyStateBanner
201 );
202
203 // Per-section injections. Sections are matched by the stable `data-slug`
204 // attribute on each `.guidelines__list-item`. The per-section form is
205 // always present in the DOM (the CollapsibleCard keeps its content mounted
206 // while collapsed).
207 for ( const slug of VALID_SECTIONS ) {
208 // Steady-state fast path: once this section's three slots are injected
209 // and still connected, there is nothing to do — skip the DOM queries so
210 // the observer's per-frame work stays cheap. If the <Navigator> removes
211 // the screen, the containers disconnect and we fall through to re-inject.
212 if (
213 slots[ `badge-${ slug }` ].container?.isConnected &&
214 slots[ `actions-${ slug }` ].container?.isConnected &&
215 slots[ `button-${ slug }` ].container?.isConnected
216 ) {
217 continue;
218 }
219
220 const item = document.querySelector( `.guidelines__list-item[data-slug="${ slug }"]` );
221 const form = item?.querySelector( 'form' );
222 if ( ! form ) {
223 continue;
224 }
225
226 // Badge in the accordion header, to the left of the chevron.
227 //
228 // CollapsibleCard.Header renders the heading wrapping a flex trigger row
229 // whose children are the title/description block and, last, the chevron's
230 // positioner. Appending to the row puts the badge to the right of the
231 // chevron, which pushes the chevron leftward only on sections that have a
232 // badge — so chevrons no longer line up across sections. Insert the badge
233 // before the chevron instead: the chevron stays the last child (flush to
234 // the right and aligned across every section) and the badge sits just to
235 // its left.
236 inject(
237 `badge-${ slug }`,
238 () => {
239 const heading = item.querySelector( 'h1, h2, h3, h4, h5, h6' );
240 const trigger = heading?.firstElementChild ?? heading;
241 const chevron = trigger?.lastElementChild;
242 return trigger
243 ? {
244 parent: trigger,
245 before: chevron,
246 className: 'jetpack-content-guidelines-ai__badge-container',
247 tag: 'span',
248 }
249 : null;
250 },
251 SuggestionBadge,
252 { slug }
253 );
254
255 // Suggestion actions (diff + accept/dismiss) at top of form.
256 inject(
257 `actions-${ slug }`,
258 () => {
259 const vStack = form.firstElementChild;
260 return vStack
261 ? {
262 parent: vStack,
263 before: vStack.firstChild,
264 className: 'jetpack-content-guidelines-ai__actions-container',
265 }
266 : null;
267 },
268 SuggestionActions,
269 { slug }
270 );
271
272 // Per-section generate button next to the Save button (the form's
273 // primary submit button lives in an HStack with the Clear button).
274 inject(
275 `button-${ slug }`,
276 () => {
277 const saveButton = form.querySelector( 'button[type="submit"]' );
278 const hStack = saveButton?.parentElement;
279 return hStack
280 ? {
281 parent: hStack,
282 className: 'jetpack-content-guidelines-ai__section-button-container',
283 }
284 : null;
285 },
286 SectionGenerateButton,
287 { slug }
288 );
289 }
290
291 // Block guideline modal injections.
292 const blockModal = document.querySelector( '.block-guideline-modal' );
293
294 // Resolve the block name on every pass while the modal is open — even when
295 // the slots are already mounted. In create mode the user can switch the
296 // combobox to a different block after our components rendered, and
297 // blockName is bound as a prop at render time. When the resolved name
298 // changes, tear the mounted slots down so they re-inject below with the
299 // new name; otherwise a suggestion generated for the previous block would
300 // be accepted into the newly selected one. Unmounting also runs
301 // BlockSuggestionActions' cleanup, clearing the stale suggestion from the
302 // store.
303 const blockName = blockModal ? getBlockNameFromModal( blockModal ) : null;
304
305 if ( blockName !== lastBlockName ) {
306 unmountSlot( 'block-actions' );
307 unmountSlot( 'block-suggestion-buttons' );
308 lastBlockName = blockName;
309 }
310
311 // Always invoke inject so previously mounted roots get unmounted when
312 // the modal closes — findParent() returns null for "no place to inject"
313 // and inject()'s cleanup path handles the disconnected container.
314 inject(
315 'block-actions',
316 () => {
317 if ( ! blockName || ! blockModal ) {
318 return null;
319 }
320 const textareaInput = blockModal.querySelector( '.components-textarea-control__input' );
321 const field = textareaInput?.parentElement;
322 return field
323 ? {
324 parent: field,
325 before: textareaInput,
326 className: 'jetpack-content-guidelines-ai__block-actions-container',
327 }
328 : null;
329 },
330 BlockSuggestionActions,
331 { blockName, blockModal }
332 );
333
334 inject(
335 'block-suggestion-buttons',
336 () => {
337 if ( ! blockName || ! blockModal ) {
338 return null;
339 }
340 const actionsBar = blockModal.querySelector( '.block-guideline-modal__actions' );
341 const vStack = actionsBar?.parentElement;
342 return vStack
343 ? {
344 parent: vStack,
345 before: actionsBar,
346 className: 'jetpack-content-guidelines-ai__block-suggestion-buttons-container',
347 }
348 : null;
349 },
350 BlockSuggestionButtons,
351 { blockName, blockModal }
352 );
353 }
354
355 /**
356 * Start observing DOM and inject all components.
357 *
358 * We observe document.body (not a narrower container) for two reasons:
359 * 1. WordPress Modal portals render directly on document.body — the block
360 * guideline modal lives outside any Gutenberg container, so a narrower
361 * root would miss it appearing.
362 * 2. Gutenberg's Navigator can remove and re-add the main screen DOM
363 * (e.g. revision history navigation), so we can't rely on a specific
364 * container staying connected.
365 *
366 * The observer never disconnects for the same reasons. Callbacks are
367 * debounced via requestAnimationFrame so runAll() fires at most once
368 * per frame, and each inject() call is a no-op when its container is
369 * still connected.
370 */
371 let injectionStarted = false;
372
373 export function startInjection() {
374 if ( injectionStarted ) {
375 return;
376 }
377 injectionStarted = true;
378
379 runAll();
380
381 let scheduled = false;
382 const observer = new MutationObserver( () => {
383 if ( ! scheduled ) {
384 scheduled = true;
385 requestAnimationFrame( () => {
386 scheduled = false;
387 runAll();
388 } );
389 }
390 } );
391
392 observer.observe( document.body, { childList: true, subtree: true } );
393 }
394