Skip to content

Commit 6eb2a30

Browse files
authored
Merge pull request #23205 from Yoast/feature/content-planner
Feature/content planner
2 parents 777600d + 4a96292 commit 6eb2a30

148 files changed

Lines changed: 10215 additions & 197 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/webpack/paths.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ const getEntries = ( sourceDirectory = "./packages/js/src" ) => ( {
5353
workouts: `${ sourceDirectory }/workouts.js`,
5454
"frontend-inspector-resources": `${ sourceDirectory }/frontend-inspector-resources.js`,
5555
"ai-generator": `${ sourceDirectory }/ai-generator/initialize.js`,
56+
"ai-content-planner": `${ sourceDirectory }/ai-content-planner/initialize.js`,
5657
"ai-consent": `${ sourceDirectory }/ai-consent/initialize.js`,
5758
plans: `${ sourceDirectory }/plans/initialize.js`,
5859
} );

inc/class-wpseo-meta.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ class WPSEO_Meta {
9999
* Currently only used by add-on plugins.
100100
*/
101101
public static $meta_fields = [
102-
'general' => [
102+
'general' => [
103103
'focuskw' => [
104104
'type' => 'hidden',
105105
'title' => '',
@@ -131,7 +131,7 @@ class WPSEO_Meta {
131131
'default_value' => 'false',
132132
],
133133
],
134-
'advanced' => [
134+
'advanced' => [
135135
'meta-robots-noindex' => [
136136
'type' => 'hidden',
137137
'default_value' => '0', // = post-type default.
@@ -171,8 +171,8 @@ class WPSEO_Meta {
171171
'default_value' => '',
172172
],
173173
],
174-
'social' => [],
175-
'schema' => [
174+
'social' => [],
175+
'schema' => [
176176
'schema_page_type' => [
177177
'type' => 'hidden',
178178
'options' => Schema_Types::PAGE_TYPES,
@@ -184,7 +184,7 @@ class WPSEO_Meta {
184184
],
185185
],
186186
/* Fields we should validate & save, but not show on any form. */
187-
'non_form' => [
187+
'non_form' => [
188188
'linkdex' => [
189189
'type' => null,
190190
'default_value' => '0',

packages/js/images/yoast.svg

Lines changed: 4 additions & 0 deletions
Loading

packages/js/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@wordpress/api-fetch": "^7.8.2",
1818
"@wordpress/block-editor": "^14.3.16",
1919
"@wordpress/blocks": "^13.8.6",
20+
"@wordpress/wordcount": "^4.27.0",
2021
"@wordpress/components": "^28.8.12",
2122
"@wordpress/compose": "^7.8.4",
2223
"@wordpress/data": "^10.8.4",
Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,28 @@
1-
import { useDispatch, useSelect } from "@wordpress/data";
2-
import { useCallback } from "@wordpress/element";
3-
import { AiConsent } from "../../shared-admin/components";
1+
import PropTypes from "prop-types";
2+
import { AiGrantConsent } from "../../shared-admin/components";
43
import { STORE_NAME_AI_CONSENT } from "../constants";
54

5+
const LINKS = {
6+
termsOfService: "https://yoa.st/ai-fix-assessments-terms-of-service",
7+
privacyPolicy: "https://yoa.st/ai-fix-assessments-privacy-policy",
8+
learnMore: "https://yoa.st/ai-fix-assessments-consent-learn-more",
9+
};
10+
611
/**
712
* The modal content for granting consent to use the AI features.
813
*
914
* @param {function} onStartGenerating Callback to start generating content after consent is given.
1015
*
1116
* @returns {JSX.Element} The element.
1217
*/
13-
export const GrantConsent = ( { onStartGenerating } ) => {
14-
const { termsOfServiceLink, privacyPolicyLink, learnMoreLink, imageLink, endpoint } = useSelect( select => {
15-
const storeSelect = select( STORE_NAME_AI_CONSENT );
16-
return {
17-
termsOfServiceLink: storeSelect.selectLink( "https://yoa.st/ai-fix-assessments-terms-of-service" ),
18-
privacyPolicyLink: storeSelect.selectLink( "https://yoa.st/ai-fix-assessments-privacy-policy" ),
19-
learnMoreLink: storeSelect.selectLink( "https://yoa.st/ai-fix-assessments-consent-learn-more" ),
20-
imageLink: storeSelect.selectImageLink( "ai-consent.png" ),
21-
endpoint: storeSelect.selectAiGeneratorConsentEndpoint(),
22-
};
23-
}, [] );
24-
25-
const { storeAiGeneratorConsent } = useDispatch( STORE_NAME_AI_CONSENT );
26-
const handleGiveConsent = useCallback( () => {
27-
storeAiGeneratorConsent( true, endpoint );
28-
onStartGenerating();
29-
}, [ storeAiGeneratorConsent, onStartGenerating ] );
30-
31-
return (
32-
<AiConsent
33-
imageLink={ imageLink }
34-
onGiveConsent={ handleGiveConsent }
35-
learnMoreLink={ learnMoreLink }
36-
termsOfServiceLink={ termsOfServiceLink }
37-
privacyPolicyLink={ privacyPolicyLink }
38-
/>
39-
);
18+
export const GrantConsent = ( { onStartGenerating } ) => (
19+
<AiGrantConsent
20+
storeName={ STORE_NAME_AI_CONSENT }
21+
onConsentGranted={ onStartGenerating }
22+
linkStoreName={ STORE_NAME_AI_CONSENT }
23+
links={ LINKS }
24+
/>
25+
);
26+
GrantConsent.propTypes = {
27+
onStartGenerating: PropTypes.func.isRequired,
4028
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://schemas.wp.org/trunk/block.json",
3+
"apiVersion": 3,
4+
"name": "yoast/content-planner-banner",
5+
"title": "Yoast Content Planner Banner",
6+
"category": "yoast-ai-blocks",
7+
"supports": {
8+
"inserter": false,
9+
"html": false,
10+
"reusable": false,
11+
"multiple": false,
12+
"lock": false
13+
},
14+
"textdomain": "wordpress-seo"
15+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { registerBlockType } from "@wordpress/blocks";
2+
import { useBlockProps } from "@wordpress/block-editor";
3+
import { useSelect, useDispatch } from "@wordpress/data";
4+
import { useCallback, useEffect, useRef } from "@wordpress/element";
5+
import block from "../block.json";
6+
import { InlineBanner } from "../components/inline-banner";
7+
import { CONTENT_PLANNER_STORE, FEATURE_MODAL_STATUS } from "../constants";
8+
import { STORE_NAME_EDITOR, STORE_NAME_AI } from "../../ai-generator/constants";
9+
import { useFetchContentSuggestions } from "../hooks/use-fetch-content-suggestions";
10+
11+
const INJECTED_STYLE_ID = "yoast-seo-tailwind-css";
12+
13+
/**
14+
* The edit component for the Content Planner Banner block.
15+
*
16+
* Renders the inline banner in the editor. When the user dismisses it,
17+
* the block is removed from the editor entirely.
18+
*
19+
* @param {Object} props The block props.
20+
* @param {string} props.clientId The block's client ID.
21+
* @returns {JSX.Element} The block edit component.
22+
*/
23+
const Edit = ( { clientId } ) => {
24+
const blockProps = useBlockProps();
25+
const ref = useRef( null );
26+
const { isPremium, hasConsent } = useSelect( select => {
27+
return {
28+
isPremium: select( STORE_NAME_EDITOR ).getIsPremium(),
29+
hasConsent: select( STORE_NAME_AI ).selectHasAiGeneratorConsent(),
30+
};
31+
}, [] );
32+
const { removeBlock } = useDispatch( "core/block-editor" );
33+
const { setFeatureModalStatus } = useDispatch( CONTENT_PLANNER_STORE );
34+
const fetchContentSuggestions = useFetchContentSuggestions();
35+
36+
const handleDismiss = useCallback( () => {
37+
removeBlock( clientId );
38+
}, [ removeBlock, clientId ] );
39+
40+
const handleClick = useCallback( () => {
41+
if ( hasConsent ) {
42+
fetchContentSuggestions();
43+
} else {
44+
setFeatureModalStatus( FEATURE_MODAL_STATUS.consent );
45+
}
46+
}, [ hasConsent, fetchContentSuggestions, setFeatureModalStatus ] );
47+
48+
useEffect( () => {
49+
// Inject the Tailwind stylesheet into the editor iframe if needed.
50+
const ownerDoc = ref.current?.ownerDocument ?? document;
51+
if ( ownerDoc === window.document || ownerDoc.getElementById( INJECTED_STYLE_ID ) ) {
52+
return;
53+
}
54+
const mainLink = window.document.getElementById( INJECTED_STYLE_ID );
55+
if ( ! mainLink ) {
56+
return;
57+
}
58+
const link = ownerDoc.createElement( "link" );
59+
link.id = INJECTED_STYLE_ID;
60+
link.rel = "stylesheet";
61+
link.href = mainLink.href;
62+
ownerDoc.head.appendChild( link );
63+
}, [] );
64+
65+
return (
66+
<div { ...blockProps } ref={ ref }>
67+
<InlineBanner
68+
isPremium={ isPremium }
69+
onDismiss={ handleDismiss }
70+
onClick={ handleClick }
71+
/>
72+
</div>
73+
);
74+
};
75+
76+
/**
77+
* Registers the Content Planner Banner block.
78+
*
79+
* Deferred behind a function so registration only happens when the Content
80+
* Planner feature initializes, not at module import time.
81+
*
82+
* @returns {void}
83+
*/
84+
export function registerBannerBlock() {
85+
registerBlockType( block, {
86+
edit: Edit,
87+
save: () => null,
88+
} );
89+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { createBlock, registerBlockType } from "@wordpress/blocks";
2+
import { useBlockProps } from "@wordpress/block-editor";
3+
import { useEffect, useRef } from "@wordpress/element";
4+
import { __ } from "@wordpress/i18n";
5+
import { ContentSuggestionBlock } from "../components/content-suggestion-block";
6+
7+
registerBlockType( "yoast-seo/content-suggestion", {
8+
apiVersion: 3,
9+
title: __( "Content Suggestion", "wordpress-seo" ),
10+
category: "text",
11+
supports: { inserter: false },
12+
transforms: {
13+
to: [
14+
{
15+
type: "block",
16+
blocks: [ "core/list" ],
17+
transform: ( { suggestions } ) =>
18+
createBlock(
19+
"core/list",
20+
{},
21+
suggestions.map( ( suggestion ) => createBlock( "core/list-item", { content: suggestion } ) )
22+
),
23+
},
24+
],
25+
},
26+
attributes: {
27+
title: { type: "string", "default": "" },
28+
suggestions: { type: "array", items: { type: "string" }, "default": [] },
29+
},
30+
edit: ( { attributes } ) => {
31+
const ref = useRef( null );
32+
const blockProps = useBlockProps( { ref } );
33+
34+
useEffect( () => {
35+
const ownerDoc = ref.current?.ownerDocument ?? document;
36+
if ( ownerDoc === window.document || ownerDoc.getElementById( "yoast-seo-tailwind-css" ) ) {
37+
return;
38+
}
39+
const mainLink = window.document.getElementById( "yoast-seo-tailwind-css" );
40+
if ( ! mainLink ) {
41+
return;
42+
}
43+
const link = ownerDoc.createElement( "link" );
44+
link.id = "yoast-seo-tailwind-css";
45+
link.rel = "stylesheet";
46+
link.href = mainLink.href;
47+
ownerDoc.head.appendChild( link );
48+
}, [] );
49+
50+
return (
51+
<div { ...blockProps }>
52+
<ContentSuggestionBlock contentNotes={ attributes.suggestions } />
53+
</div>
54+
);
55+
},
56+
save: () => null,
57+
} );
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Modal, useToggleState } from "@yoast/ui-library";
2+
import { useState, useEffect, useCallback, useRef } from "@wordpress/element";
3+
import { __ } from "@wordpress/i18n";
4+
import ApproveModal from "../containers/approve-modal";
5+
import { AiGrantConsent } from "../../shared-admin/components";
6+
import { ReplaceContentModal } from "./replace-content-modal";
7+
import { FEATURE_MODAL_STATUS, CONTENT_PLANNER_STORE } from "../constants";
8+
import { STORE_NAME_EDITOR, STORE_NAME_AI } from "../../ai-generator/constants";
9+
import { useFetchContentSuggestions, useApplyOutline } from "../hooks";
10+
import FeatureModal from "../containers/feature-modal";
11+
import { useSelect, useDispatch } from "@wordpress/data";
12+
13+
14+
/**
15+
* The modal that orchestrates the flow between the approve, content suggestions,
16+
* content outline, and replace content confirmation views.
17+
*
18+
* @returns {JSX.Element} The Content Planner Feature Modal.
19+
*/
20+
export const App = () => {
21+
const { status, hasConsent } = useSelect( select => {
22+
const contentPlannerSelectors = select( CONTENT_PLANNER_STORE );
23+
return {
24+
status: contentPlannerSelectors.selectFeatureModalStatus(),
25+
hasConsent: select( STORE_NAME_AI ).selectHasAiGeneratorConsent(),
26+
};
27+
}, [] );
28+
const { setFeatureModalStatus, closeModal } = useDispatch( CONTENT_PLANNER_STORE );
29+
const [ hasVisitedReplace, setHasVisitedReplace ] = useState( false );
30+
const [ replaceContentModalIsOpen, toggleReplaceContentModal, , openReplaceContentModal ] = useToggleState( false );
31+
const editedOutlineRef = useRef( null );
32+
const handleApplyOutline = useApplyOutline( { editedOutlineRef } );
33+
const fetchContentSuggestions = useFetchContentSuggestions();
34+
const isConsentModalOpen = status === FEATURE_MODAL_STATUS.consent;
35+
36+
/**
37+
* Handles the click on the "Get content suggestions" button in the ApproveModal.
38+
* Sets the flag to indicate the transition is coming from the ApproveModal, then fetches content suggestions.
39+
* The flag is used to determine whether to apply a cross-fade transition when showing the SuggestionsPanel.
40+
* Updates the modal status to "content-suggestions" once suggestions are requested.
41+
*
42+
* @returns {void}
43+
*/
44+
const handleGetSuggestionsClick = useCallback( () => {
45+
if ( ! hasConsent ) {
46+
setFeatureModalStatus( FEATURE_MODAL_STATUS.consent );
47+
return;
48+
}
49+
fetchContentSuggestions();
50+
}, [ hasConsent, setFeatureModalStatus, fetchContentSuggestions ] );
51+
52+
const handleConfirmReplace = useCallback( () => {
53+
handleApplyOutline();
54+
}, [ handleApplyOutline ] );
55+
56+
useEffect( () => {
57+
if ( ! status ) {
58+
setHasVisitedReplace( false );
59+
}
60+
}, [ status ] );
61+
62+
return (
63+
<>
64+
<ApproveModal
65+
onClick={ handleGetSuggestionsClick }
66+
/>
67+
<FeatureModal
68+
editedOutlineRef={ editedOutlineRef }
69+
handleApplyOutline={ handleApplyOutline }
70+
openReplaceContentModal={ openReplaceContentModal }
71+
setHasVisitedReplace={ setHasVisitedReplace }
72+
/>
73+
<ReplaceContentModal
74+
onConfirm={ handleConfirmReplace }
75+
isOpen={ replaceContentModalIsOpen && hasVisitedReplace }
76+
onClose={ toggleReplaceContentModal }
77+
/>
78+
79+
<Modal isOpen={ isConsentModalOpen } onClose={ closeModal } className="yst-introduction-modal">
80+
<Modal.Panel
81+
className="yst-max-w-lg yst-p-0 yst-rounded-3xl"
82+
closeButtonScreenReaderText={ __( "Close modal", "wordpress-seo" ) }
83+
>
84+
<AiGrantConsent
85+
storeName={ STORE_NAME_AI }
86+
linkStoreName={ STORE_NAME_EDITOR }
87+
onConsentGranted={ fetchContentSuggestions }
88+
/>
89+
</Modal.Panel>
90+
</Modal>
91+
</>
92+
);
93+
};

0 commit comments

Comments
 (0)