diff --git a/composer.json b/composer.json index 2e157103..971e4531 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,10 @@ } }, "require": { - "statamic/cms": "^5.0.0" + "statamic/cms": "^5.0.0", + "donatello-za/rake-php-plus": "^1.0", + "openai-php/client": "^0.10.1", + "ext-dom": "*" }, "require-dev": { "orchestra/testbench": "^8.0 || ^9.0", @@ -31,7 +34,8 @@ }, "config": { "allow-plugins": { - "pixelfear/composer-dist-plugin": true + "pixelfear/composer-dist-plugin": true, + "php-http/discovery": true } }, "minimum-stability": "dev", diff --git a/config/seo-pro.php b/config/seo-pro.php index 42161fce..9927488e 100644 --- a/config/seo-pro.php +++ b/config/seo-pro.php @@ -52,4 +52,55 @@ 'queue_chunk_size' => 1000, ], + 'jobs' => [ + 'connection' => env('SEO_PRO_JOB_CONNECTION'), + 'queue' => env('SEO_PRO_JOB_QUEUE'), + ], + + 'linking' => [ + + 'enabled' => false, + + 'openai' => [ + 'api_key' => env('SEO_PRO_OPENAI_API_KEY'), + 'model' => 'text-embedding-3-small', + 'token_limit' => 8000, + ], + + 'keyword_threshold' => 65, + + 'prevent_circular_links' => false, + + 'internal_links' => [ + 'min_desired' => 3, + 'max_desired' => 6, + ], + + 'external_links' => [ + 'min_desired' => 0, + 'max_desired' => 3, + ], + + 'suggestions' => [ + 'result_limit' => 10, + 'related_entry_limit' => 20, + ], + + 'rake' => [ + 'phrase_min_length' => 0, + 'filter_numerics' => true, + ], + + 'drivers' => [ + 'embeddings' => \Statamic\SeoPro\TextProcessing\Embeddings\OpenAiEmbeddings::class, + 'keywords' => \Statamic\SeoPro\TextProcessing\Keywords\Rake::class, + 'tokenizer' => \Statamic\SeoPro\Content\Tokenizer::class, + 'content' => \Statamic\SeoPro\Content\ContentRetriever::class, + 'link_scanner' => \Statamic\SeoPro\TextProcessing\Links\LinkCrawler::class, + ], + + 'disabled_collections' => [ + ], + + ], ]; diff --git a/database/migrations/2024_07_26_184745_create_seopro_entry_embeddings_table.php b/database/migrations/2024_07_26_184745_create_seopro_entry_embeddings_table.php new file mode 100644 index 00000000..229131cf --- /dev/null +++ b/database/migrations/2024_07_26_184745_create_seopro_entry_embeddings_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('entry_id')->index(); + $table->string('site')->index(); + $table->string('collection')->index(); + $table->string('blueprint'); + $table->string('content_hash'); + $table->string('configuration_hash'); + $table->json('embedding'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('seopro_entry_embeddings'); + } +}; diff --git a/database/migrations/2024_08_10_154109_create_seopro_entry_links_table.php b/database/migrations/2024_08_10_154109_create_seopro_entry_links_table.php new file mode 100644 index 00000000..208f4934 --- /dev/null +++ b/database/migrations/2024_08_10_154109_create_seopro_entry_links_table.php @@ -0,0 +1,51 @@ +id(); + $table->string('entry_id')->index(); + $table->string('cached_title'); + $table->string('cached_uri'); + $table->string('site')->index(); + $table->string('collection')->index(); + $table->string('content_hash'); + $table->longText('analyzed_content'); + $table->json('content_mapping'); + $table->integer('external_link_count'); + $table->integer('internal_link_count'); + $table->integer('inbound_internal_link_count'); + + $table->json('external_links'); + $table->json('internal_links'); + + $table->json('normalized_external_links'); + $table->json('normalized_internal_links'); + + $table->boolean('can_be_suggested')->default(true)->index(); + $table->boolean('include_in_reporting')->default(true)->index(); + + $table->json('ignored_entries'); + $table->json('ignored_phrases'); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('seopro_entry_links'); + } +}; diff --git a/database/migrations/2024_08_17_123712_create_seopro_entry_keywords_table.php b/database/migrations/2024_08_17_123712_create_seopro_entry_keywords_table.php new file mode 100644 index 00000000..70c69abb --- /dev/null +++ b/database/migrations/2024_08_17_123712_create_seopro_entry_keywords_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('entry_id')->index(); + $table->string('site')->index(); + $table->string('collection')->index(); + $table->string('blueprint'); + $table->string('content_hash'); + $table->json('meta_keywords'); + $table->json('content_keywords'); // Keywords retrieved from content. + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('seopro_entry_keywords'); + } +}; diff --git a/database/migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php b/database/migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php new file mode 100644 index 00000000..05f7c51a --- /dev/null +++ b/database/migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('site')->index(); + $table->json('ignored_phrases'); + $table->float('keyword_threshold'); + $table->integer('min_internal_links'); + $table->integer('max_internal_links'); + $table->integer('min_external_links'); + $table->integer('max_external_links'); + $table->boolean('prevent_circular_links')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('seopro_site_link_settings'); + } +}; diff --git a/database/migrations/2024_09_02_135056_create_seopro_global_automatic_links_table.php b/database/migrations/2024_09_02_135056_create_seopro_global_automatic_links_table.php new file mode 100644 index 00000000..0ce1b5b2 --- /dev/null +++ b/database/migrations/2024_09_02_135056_create_seopro_global_automatic_links_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('site')->nullable()->index(); + $table->boolean('is_active')->index(); + $table->string('link_text'); + $table->string('entry_id')->nullable()->index(); + $table->string('link_target'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('seopro_global_automatic_links'); + } +}; diff --git a/database/migrations/2024_09_03_102233_create_seopro_collection_link_settings_table.php b/database/migrations/2024_09_03_102233_create_seopro_collection_link_settings_table.php new file mode 100644 index 00000000..0c504539 --- /dev/null +++ b/database/migrations/2024_09_03_102233_create_seopro_collection_link_settings_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('collection')->index(); + $table->boolean('linking_enabled')->index(); + + $table->boolean('allow_linking_across_sites'); + $table->boolean('allow_linking_to_all_collections'); + $table->json('linkable_collections'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('seopro_collection_link_settings'); + } +}; diff --git a/resources/dist/build/assets/cp-56146771.css b/resources/dist/build/assets/cp-56146771.css deleted file mode 100644 index df59aae2..00000000 --- a/resources/dist/build/assets/cp-56146771.css +++ /dev/null @@ -1 +0,0 @@ -.seo_pro-fieldtype>.field-inner>label{display:none!important}.seo_pro-fieldtype,.seo_pro-fieldtype .publish-fields{padding:0!important}.source-type-select{width:20rem}.inherit-placeholder{padding-top:5px}.source-field-select .selectize-dropdown,.source-field-select .selectize-input span{font-family:Menlo,Monaco,Consolas,Liberation Mono,Courier New,"monospace";font-size:12px} diff --git a/resources/dist/build/assets/cp-7025c2cd.css b/resources/dist/build/assets/cp-7025c2cd.css deleted file mode 100644 index 645267a9..00000000 --- a/resources/dist/build/assets/cp-7025c2cd.css +++ /dev/null @@ -1 +0,0 @@ -.bg-yellow-dark{background-color:#f6ad55}.text-red-800{color:#991b1b} diff --git a/resources/dist/build/assets/cp-c6df7fbd.js b/resources/dist/build/assets/cp-c6df7fbd.js deleted file mode 100644 index 7a5ae3fa..00000000 --- a/resources/dist/build/assets/cp-c6df7fbd.js +++ /dev/null @@ -1 +0,0 @@ -function o(s,t,e,a,r,d,u,h){var i=typeof s=="function"?s.options:s;t&&(i.render=t,i.staticRenderFns=e,i._compiled=!0),a&&(i.functional=!0),d&&(i._scopeId="data-v-"+d);var l;if(u?(l=function(n){n=n||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,!n&&typeof __VUE_SSR_CONTEXT__<"u"&&(n=__VUE_SSR_CONTEXT__),r&&r.call(this,n),n&&n._registeredComponents&&n._registeredComponents.add(u)},i._ssrRegister=l):r&&(l=h?function(){r.call(this,(i.functional?this.parent:this).$root.$options.shadowRoot)}:r),l)if(i.functional){i._injectStyles=l;var v=i.render;i.render=function(m,f){return l.call(f),v(m,f)}}else{var p=i.beforeCreate;i.beforeCreate=p?[].concat(p,l):[l]}return{exports:s,options:i}}const g={mixins:[Fieldtype],computed:{fields(){return _.chain(this.meta.fields).map(s=>({handle:s.handle,...s.field})).values().value()}},methods:{updateKey(s,t){let e=this.value;Vue.set(e,s,t),this.update(e)}}};var C=function(){var t=this,e=t._self._c;return e("div",{staticClass:"publish-fields"},t._l(t.fields,function(a){return e("publish-field",{key:a.handle,staticClass:"form-group",attrs:{config:a,value:t.value[a.handle],meta:t.meta.meta[a.handle],"read-only":!a.localizable},on:{"meta-updated":function(r){return t.metaUpdated(a.handle,r)},focus:function(r){return t.$emit("focus")},blur:function(r){return t.$emit("blur")},input:function(r){return t.updateKey(a.handle,r)}}})}),1)},x=[],y=o(g,C,x,!1,null,null,null,null);const b=y.exports;const $={mixins:[Fieldtype],data(){return{autoBindChangeWatcher:!1,changeWatcherWatchDeep:!1,allowedFieldtypes:[]}},computed:{source(){return this.value.source},sourceField(){return this.value.source==="field"?this.value.value:null},componentName(){return this.config.field.type.replace(".","-")+"-fieldtype"},sourceTypeSelectOptions(){let s=[];return this.config.field!==!1&&s.push({label:__("seo-pro::messages.custom"),value:"custom"}),this.config.from_field!==!1&&s.unshift({label:__("seo-pro::messages.from_field"),value:"field"}),this.config.inherit!==!1&&s.unshift({label:__("seo-pro::messages.inherit"),value:"inherit"}),this.config.disableable&&s.push({label:__("seo-pro::messages.disable"),value:"disable"}),s},fieldConfig(){return Object.assign(this.config.field,{placeholder:this.config.placeholder})},placeholder(){return this.config.placeholder}},mounted(){let s=this.config.allowed_fieldtypes||["text","textarea","markdown","redactor"];this.allowedFieldtypes=s.concat(this.config.merge_allowed_fieldtypes||[])},methods:{sourceDropdownChanged(s){this.value.source=s,s!=="field"&&(this.value.value=this.meta.defaultValue,this.meta.fieldMeta=this.meta.defaultFieldMeta)},sourceFieldChanged(s){this.value.value=s},customValueChanged(s){let t=this.value;t.value=s,this.update(t)}}};var w=function(){var t=this,e=t._self._c;return e("div",{staticClass:"flex"},[e("div",{staticClass:"source-type-select pr-4"},[e("v-select",{attrs:{options:t.sourceTypeSelectOptions,reduce:a=>a.value,disabled:!t.config.localizable,clearable:!1,value:t.source},on:{input:t.sourceDropdownChanged}})],1),e("div",{staticClass:"flex-1"},[t.source==="inherit"?e("div",{staticClass:"text-sm text-grey inherit-placeholder mt-1"},[t.placeholder!==!1?[t._v(" "+t._s(t.placeholder)+" ")]:t._e()],2):t.source==="field"?e("div",{staticClass:"source-field-select"},[e("text-input",{attrs:{value:t.sourceField,disabled:!t.config.localizable},on:{input:t.sourceFieldChanged}})],1):t.source==="custom"?e(t.componentName,{tag:"component",attrs:{name:t.name,config:t.fieldConfig,value:t.value.value,meta:t.meta.fieldMeta,"read-only":!t.config.localizable,handle:"source_value"},on:{input:t.customValueChanged}}):t._e()],1)])},S=[],F=o($,w,S,!1,null,null,null,null);const R=F.exports,T={props:["status"]};var k=function(){var t=this,e=t._self._c;return e("div",[t.status==="pending"?e("span",{staticClass:"icon icon-circular-graph animation-spin"}):e("span",{staticClass:"little-dot",class:{"bg-green-600":t.status==="pass","bg-red-500":t.status==="fail","bg-yellow-dark":t.status==="warning"}})])},D=[],V=o(T,k,D,!1,null,null,null,null);const c=V.exports,P={props:["id","initialStatus","initialScore"],data(){return{status:this.initialStatus,score:this.initialScore}},created(){this.score||this.updateScore()},methods:{updateScore(){Statamic.$request.get(cp_url(`seo-pro/reports/${this.id}`)).then(s=>{if(s.data.status==="pending"||s.data.status==="generating"){setTimeout(()=>this.updateScore(),1e3);return}this.status=s.data.status,this.score=s.data.score})}}};var z=function(){var t=this,e=t._self._c;return e("div",[t.score?e("div",[e("seo-pro-status-icon",{staticClass:"inline-block ml-1 mr-3",attrs:{status:t.status}}),t._v(" "+t._s(t.score)+"% ")],1):e("loading-graphic",{attrs:{text:null,inline:!0}})],1)},M=[],N=o(P,z,M,!1,null,null,null,null);const O=N.exports,H={props:["item"],components:{StatusIcon:c}};var W=function(){var t=this,e=t._self._c;return e("modal",{attrs:{name:"report-details","click-to-close":!0},on:{closed:function(a){return t.$emit("closed")}}},[e("div",{staticClass:"p-0"},[e("h1",{staticClass:"p-4 bg-gray-200 border-b text-lg"},[t._v(" "+t._s(t.__("seo-pro::messages.page_details"))+" ")]),e("div",{staticClass:"modal-body"},t._l(t.item.results,function(a){return e("div",{staticClass:"flex px-4 leading-normal pb-2",class:{"bg-red-100":a.status!=="pass"}},[e("status-icon",{staticClass:"mr-3 mt-2",attrs:{status:a.status}}),e("div",{staticClass:"flex-1 mt-2 prose text-gray-700"},[e("div",{staticClass:"text-gray-900",domProps:{innerHTML:t._s(a.description)}}),a.comment?e("div",{staticClass:"text-xs",class:{"text-red-800":a.status!=="pass"},domProps:{innerHTML:t._s(a.comment)}}):t._e()])],1)}),0),e("footer",{staticClass:"px-5 py-3 bg-gray-200 rounded-b-lg border-t flex items-center font-mono text-xs"},[t._v(" "+t._s(t.item.url)+" ")])])])},U=[],q=o(H,W,U,!1,null,null,null,null);const E=q.exports,G={props:["date"],data(){return{text:null}},mounted(){this.update()},methods:{update(){this.text=moment(this.date*1e3).fromNow(),setTimeout(()=>this.update(),6e4)}}};var I=function(){var t=this,e=t._self._c;return e("span",[t._v(t._s(t.text))])},K=[],L=o(G,I,K,!1,null,null,null,null);const X=L.exports,B={components:{ReportDetails:E,RelativeDate:X,StatusIcon:c},props:["initialReport"],data(){return{loading:!1,report:this.initialReport,selected:null}},computed:{isGenerating(){return this.initialReport.status==="pending"||this.initialReport.status==="generating"},id(){return this.report.id},isCachedHeaderReady(){return this.report.date&&this.report.pages_crawled&&this.report.score}},mounted(){this.load()},methods:{load(){this.loading=!0,Statamic.$request.get(cp_url(`seo-pro/reports/${this.id}`)).then(s=>{if(s.data.status==="pending"||s.data.status==="generating"){setTimeout(()=>this.load(),1e3);return}this.report=s.data,this.loading=!1})}}};var A=function(){var t=this,e=t._self._c;return e("div",[e("header",{staticClass:"flex items-center mb-6"},[e("h1",{staticClass:"flex-1"},[t._v(t._s(t.__("seo-pro::messages.seo_report")))]),t.loading?t._e():e("a",{staticClass:"btn-primary",attrs:{href:t.cp_url("seo-pro/reports/create")}},[t._v(t._s(t.__("seo-pro::messages.generate_report")))])]),t.report?e("div",[t.isCachedHeaderReady?e("div",[e("div",{staticClass:"flex flex-wrap -mx-4"},[e("div",{staticClass:"w-1/3 px-4"},[e("div",{staticClass:"card py-2"},[e("h2",{staticClass:"text-sm text-gray-700"},[t._v(t._s(t.__("seo-pro::messages.generated")))]),e("div",{staticClass:"text-lg"},[e("relative-date",{attrs:{date:t.report.date}})],1)])]),e("div",{staticClass:"w-1/3 px-4"},[e("div",{staticClass:"card py-2"},[e("h2",{staticClass:"text-sm text-gray-700"},[t._v(t._s(t.__("Pages Crawled")))]),e("div",{staticClass:"text-lg"},[t._v(t._s(t.report.pages_crawled))])])]),e("div",{staticClass:"w-1/3 px-4"},[e("div",{staticClass:"card py-2"},[e("h2",{staticClass:"text-sm text-gray-700"},[t._v(t._s(t.__("Site Score")))]),e("div",{staticClass:"text-lg flex items-center"},[e("div",{staticClass:"bg-gray-200 h-3 w-full rounded flex mr-2"},[e("div",{staticClass:"h-3 rounded-l",class:{"bg-red-500":t.report.score<70,"bg-yellow-dark":t.report.score<90,"bg-green-500":t.report.score>=90},style:`width: ${t.report.score}%`})]),e("span",[t._v(t._s(t.report.score)+"%")])])])])]),e("div",{staticClass:"card p-0 mt-6"},[e("table",{staticClass:"data-table"},[e("tbody",t._l(t.report.results,function(a){return e("tr",[e("td",{staticClass:"w-8 text-center"},[e("status-icon",{attrs:{status:a.status}})],1),e("td",{staticClass:"pl-0"},[t._v(t._s(a.description))]),e("td",{staticClass:"text-grey text-right"},[t._v(t._s(a.comment))])])}),0)])])]):t._e(),t.loading?e("div",{staticClass:"card loading mt-6"},[t.isGenerating?e("loading-graphic",{attrs:{text:t.__("seo-pro::messages.report_is_being_generated")}}):e("loading-graphic")],1):e("div",{staticClass:"card p-0 mt-6"},[e("table",{staticClass:"data-table"},[e("tbody",t._l(t.report.pages,function(a){return e("tr",[e("td",{staticClass:"w-8 text-center"},[e("status-icon",{attrs:{status:a.status}})],1),e("td",{staticClass:"pl-0"},[e("a",{attrs:{href:""},on:{click:function(r){r.preventDefault(),t.selected=a.id}}},[t._v(t._s(a.url))]),t.selected===a.id?e("report-details",{attrs:{item:a},on:{closed:function(r){t.selected=null}}}):t._e()],1),e("td",{staticClass:"text-right text-xs pr-0 whitespace-no-wrap"},[e("a",{staticClass:"text-gray-700 mr-4 hover:text-grey-80",domProps:{textContent:t._s(t.__("Details"))},on:{click:function(r){r.preventDefault(),t.selected=a.id}}}),a.edit_url?e("a",{staticClass:"mr-4 text-gray-700 hover:text-gray-800",attrs:{target:"_blank",href:a.edit_url},domProps:{textContent:t._s(t.__("Edit"))}}):t._e()])])}),0)])])]):t._e()])},J=[],Q=o(B,A,J,!1,null,null,null,null);const Y=Q.exports;Statamic.$components.register("seo_pro-fieldtype",b);Statamic.$components.register("seo_pro_source-fieldtype",R);Statamic.$components.register("seo-pro-status-icon",c);Statamic.$components.register("seo-pro-report",Y);Statamic.$components.register("seo-pro-index-score",O); diff --git a/resources/dist/build/manifest.json b/resources/dist/build/manifest.json deleted file mode 100644 index 57184bcc..00000000 --- a/resources/dist/build/manifest.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "resources/css/cp.css": { - "file": "assets/cp-7025c2cd.css", - "isEntry": true, - "src": "resources/css/cp.css" - }, - "resources/js/cp.css": { - "file": "assets/cp-56146771.css", - "src": "resources/js/cp.css" - }, - "resources/js/cp.js": { - "css": [ - "assets/cp-56146771.css" - ], - "file": "assets/cp-c6df7fbd.js", - "isEntry": true, - "src": "resources/js/cp.js" - } -} \ No newline at end of file diff --git a/resources/js/components/links/AutomaticLinkEditor.vue b/resources/js/components/links/AutomaticLinkEditor.vue new file mode 100644 index 00000000..95e2dcb6 --- /dev/null +++ b/resources/js/components/links/AutomaticLinkEditor.vue @@ -0,0 +1,131 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/AutomaticLinksListing.vue b/resources/js/components/links/AutomaticLinksListing.vue new file mode 100644 index 00000000..c9560ca2 --- /dev/null +++ b/resources/js/components/links/AutomaticLinksListing.vue @@ -0,0 +1,169 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/ExternalLinkListing.vue b/resources/js/components/links/ExternalLinkListing.vue new file mode 100644 index 00000000..e2476878 --- /dev/null +++ b/resources/js/components/links/ExternalLinkListing.vue @@ -0,0 +1,69 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/FakesResources.vue b/resources/js/components/links/FakesResources.vue new file mode 100644 index 00000000..4ff7439d --- /dev/null +++ b/resources/js/components/links/FakesResources.vue @@ -0,0 +1,39 @@ + \ No newline at end of file diff --git a/resources/js/components/links/InboundInternalLinks.vue b/resources/js/components/links/InboundInternalLinks.vue new file mode 100644 index 00000000..58253059 --- /dev/null +++ b/resources/js/components/links/InboundInternalLinks.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/InternalLinkListing.vue b/resources/js/components/links/InternalLinkListing.vue new file mode 100644 index 00000000..0930f689 --- /dev/null +++ b/resources/js/components/links/InternalLinkListing.vue @@ -0,0 +1,79 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/LinkDashboard.vue b/resources/js/components/links/LinkDashboard.vue new file mode 100644 index 00000000..85183bc5 --- /dev/null +++ b/resources/js/components/links/LinkDashboard.vue @@ -0,0 +1,130 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/Listing.vue b/resources/js/components/links/Listing.vue new file mode 100644 index 00000000..85b9fbed --- /dev/null +++ b/resources/js/components/links/Listing.vue @@ -0,0 +1,174 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/NavContainer.vue b/resources/js/components/links/NavContainer.vue new file mode 100644 index 00000000..9ef38061 --- /dev/null +++ b/resources/js/components/links/NavContainer.vue @@ -0,0 +1,37 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/Overview.vue b/resources/js/components/links/Overview.vue new file mode 100644 index 00000000..af1b5ade --- /dev/null +++ b/resources/js/components/links/Overview.vue @@ -0,0 +1,72 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/ProvidesControlPanelLinks.vue b/resources/js/components/links/ProvidesControlPanelLinks.vue new file mode 100644 index 00000000..c8c8d519 --- /dev/null +++ b/resources/js/components/links/ProvidesControlPanelLinks.vue @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/resources/js/components/links/RelatedContent.vue b/resources/js/components/links/RelatedContent.vue new file mode 100644 index 00000000..209a0e6d --- /dev/null +++ b/resources/js/components/links/RelatedContent.vue @@ -0,0 +1,113 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/SuggestionsListing.vue b/resources/js/components/links/SuggestionsListing.vue new file mode 100644 index 00000000..844c01da --- /dev/null +++ b/resources/js/components/links/SuggestionsListing.vue @@ -0,0 +1,151 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/config/CollectionBehaviorEditor.vue b/resources/js/components/links/config/CollectionBehaviorEditor.vue new file mode 100644 index 00000000..20cdab00 --- /dev/null +++ b/resources/js/components/links/config/CollectionBehaviorEditor.vue @@ -0,0 +1,102 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/config/CollectionBehaviorListing.vue b/resources/js/components/links/config/CollectionBehaviorListing.vue new file mode 100644 index 00000000..ee44d6cc --- /dev/null +++ b/resources/js/components/links/config/CollectionBehaviorListing.vue @@ -0,0 +1,136 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/config/ConfigResetter.vue b/resources/js/components/links/config/ConfigResetter.vue new file mode 100644 index 00000000..2e846f1c --- /dev/null +++ b/resources/js/components/links/config/ConfigResetter.vue @@ -0,0 +1,114 @@ + + + diff --git a/resources/js/components/links/config/EntryConfigEditor.vue b/resources/js/components/links/config/EntryConfigEditor.vue new file mode 100644 index 00000000..f433a27f --- /dev/null +++ b/resources/js/components/links/config/EntryConfigEditor.vue @@ -0,0 +1,107 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/config/SiteConfigEditor.vue b/resources/js/components/links/config/SiteConfigEditor.vue new file mode 100644 index 00000000..4d8f7d77 --- /dev/null +++ b/resources/js/components/links/config/SiteConfigEditor.vue @@ -0,0 +1,106 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/config/SiteConfigListing.vue b/resources/js/components/links/config/SiteConfigListing.vue new file mode 100644 index 00000000..d4ae8280 --- /dev/null +++ b/resources/js/components/links/config/SiteConfigListing.vue @@ -0,0 +1,133 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/suggestions/IgnoreConfirmation.vue b/resources/js/components/links/suggestions/IgnoreConfirmation.vue new file mode 100644 index 00000000..8b34d937 --- /dev/null +++ b/resources/js/components/links/suggestions/IgnoreConfirmation.vue @@ -0,0 +1,146 @@ + + + \ No newline at end of file diff --git a/resources/js/components/links/suggestions/SuggestionEditor.vue b/resources/js/components/links/suggestions/SuggestionEditor.vue new file mode 100644 index 00000000..3685fdc7 --- /dev/null +++ b/resources/js/components/links/suggestions/SuggestionEditor.vue @@ -0,0 +1,510 @@ + + + + + diff --git a/resources/js/cp.js b/resources/js/cp.js index e614c362..150efcaf 100644 --- a/resources/js/cp.js +++ b/resources/js/cp.js @@ -3,6 +3,15 @@ import SourceFieldtype from './components/fieldtypes/SourceFieldtype.vue'; import StatusIcon from './components/reporting/StatusIcon.vue'; import IndexScore from './components/reporting/IndexScore.vue'; import Report from './components/reporting/Report.vue'; +import Suggestions from './components/links/SuggestionsListing.vue'; +import Listing from './components/links/Listing.vue'; +import RelatedContent from './components/links/RelatedContent.vue'; +import InternalLinkListing from './components/links/InternalLinkListing.vue'; +import ExternalLinkListing from './components/links/ExternalLinkListing.vue'; +import CollectionBehaviorListing from './components/links/config/CollectionBehaviorListing.vue'; +import SiteConfigListing from './components/links/config/SiteConfigListing.vue'; +import AutomaticLinksListing from './components/links/AutomaticLinksListing.vue'; +import LinkDashboard from './components/links/LinkDashboard.vue'; Statamic.$components.register('seo_pro-fieldtype', SeoProFieldtype); Statamic.$components.register('seo_pro_source-fieldtype', SourceFieldtype); @@ -10,3 +19,9 @@ Statamic.$components.register('seo_pro_source-fieldtype', SourceFieldtype); Statamic.$components.register('seo-pro-status-icon', StatusIcon); Statamic.$components.register('seo-pro-report', Report); Statamic.$components.register('seo-pro-index-score', IndexScore); +Statamic.$components.register('seo-pro-link-dashboard', LinkDashboard); + +Statamic.$components.register('seo-pro-link-listing', Listing); +Statamic.$components.register('seo-pro-collection-behavior-listing', CollectionBehaviorListing); +Statamic.$components.register('seo-pro-site-config-listing', SiteConfigListing); +Statamic.$components.register('seo-pro-automatic-links-listing', AutomaticLinksListing); diff --git a/resources/lang/en/messages.php b/resources/lang/en/messages.php index 6e6722fe..60361ce3 100644 --- a/resources/lang/en/messages.php +++ b/resources/lang/en/messages.php @@ -66,4 +66,6 @@ 'three_segment_urls_site_warning' => ':count page with more than 3 segments in URL.|:count pages with more than 3 segments in their URLs.', ], + 'link_manager' => 'Link Manager', + 'links_description' => 'View internal entry link suggestions and related link statistics.', ]; diff --git a/resources/views/config/link_collections.blade.php b/resources/views/config/link_collections.blade.php new file mode 100644 index 00000000..bbb31bfd --- /dev/null +++ b/resources/views/config/link_collections.blade.php @@ -0,0 +1,12 @@ +@extends('statamic::layout') +@section('title', 'A better title will surely appear') +@section('wrapper_class', 'max-w-full') + +@section('content') + +@stop \ No newline at end of file diff --git a/resources/views/config/sites.blade.php b/resources/views/config/sites.blade.php new file mode 100644 index 00000000..aa6c00ce --- /dev/null +++ b/resources/views/config/sites.blade.php @@ -0,0 +1,11 @@ +@extends('statamic::layout') +@section('title', 'Site Linking Configuration') + +@section('content') + +@stop \ No newline at end of file diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index d21315cd..9d7194b1 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -1,53 +1,66 @@ -@extends('statamic::layout') -@section('title', 'SEO Pro') - -@section('content') - -
-

{{ 'SEO Pro' }}

-
- -
-
- @can('view seo reports') - -
- @cp_svg('icons/light/charts') -
-
-

{{ __('seo-pro::messages.reports') }}

-

{{ __('seo-pro::messages.seo_reports_description') }}

-
-
- @endcan - @can('edit seo site defaults') - -
- @cp_svg('icons/light/hammer-wrench') -
-
-

{{ __('seo-pro::messages.site_defaults') }}

-

{{ __('seo-pro::messages.site_defaults_description') }}

-
-
- @endcan - @can('edit seo section defaults') - -
- @cp_svg('icons/light/hammer-wrench') -
-
-

{{ __('seo-pro::messages.section_defaults') }}

-

{{ __('seo-pro::messages.section_defaults_description') }}

-
-
- @endcan -
-
- - @include('statamic::partials.docs-callout', [ - 'topic' => 'SEO Pro', - 'url' => 'https://statamic.com/addons/statamic/seo-pro' - ]) - -@endsection +@extends('statamic::layout') +@section('title', 'SEO Pro') + +@section('content') + +
+

{{ 'SEO Pro' }}

+
+ +
+
+ @can('view seo reports') + +
+ @cp_svg('icons/light/charts') +
+
+

{{ __('seo-pro::messages.reports') }}

+

{{ __('seo-pro::messages.seo_reports_description') }}

+
+
+ @endcan + @can('edit seo site defaults') + +
+ @cp_svg('icons/light/hammer-wrench') +
+
+

{{ __('seo-pro::messages.site_defaults') }}

+

{{ __('seo-pro::messages.site_defaults_description') }}

+
+
+ @endcan + @can('edit seo section defaults') + +
+ @cp_svg('icons/light/hammer-wrench') +
+
+

{{ __('seo-pro::messages.section_defaults') }}

+

{{ __('seo-pro::messages.section_defaults_description') }}

+
+
+ @endcan + @if (config('statamic.seo-pro.text_analysis.enabled', false)) + @can('view seo links') + +
+ @cp_svg('icons/light/link') +
+
+

{{ __('seo-pro::messages.link_manager') }}

+

{{ __('seo-pro::messages.links_description') }}

+
+
+ @endcan + @endif +
+
+ + @include('statamic::partials.docs-callout', [ + 'topic' => 'SEO Pro', + 'url' => 'https://statamic.com/addons/statamic/seo-pro' + ]) + +@endsection diff --git a/resources/views/linking/automatic.blade.php b/resources/views/linking/automatic.blade.php new file mode 100644 index 00000000..cc35f82c --- /dev/null +++ b/resources/views/linking/automatic.blade.php @@ -0,0 +1,13 @@ +@extends('statamic::layout') +@section('title', 'Automatic Global Links') +@section('wrapper_class', 'max-w-full') + +@section('content') + +@stop \ No newline at end of file diff --git a/resources/views/linking/dashboard.blade.php b/resources/views/linking/dashboard.blade.php new file mode 100644 index 00000000..101be3db --- /dev/null +++ b/resources/views/linking/dashboard.blade.php @@ -0,0 +1,14 @@ +@extends('statamic::layout') +@section('title', $title) +@section('wrapper_class', 'max-w-full') + +@section('content') + +@stop \ No newline at end of file diff --git a/resources/views/linking/index.blade.php b/resources/views/linking/index.blade.php new file mode 100644 index 00000000..bca4b10b --- /dev/null +++ b/resources/views/linking/index.blade.php @@ -0,0 +1,14 @@ +@extends('statamic::layout') +@section('title', __('seo-pro::messages.link_manager')) +@section('wrapper_class', 'max-w-full') + +@section('content') + +@stop \ No newline at end of file diff --git a/resources/views/links/automatic.antlers.html b/resources/views/links/automatic.antlers.html new file mode 100644 index 00000000..c48c3b7d --- /dev/null +++ b/resources/views/links/automatic.antlers.html @@ -0,0 +1 @@ +{{ text }} \ No newline at end of file diff --git a/resources/views/links/html.antlers.html b/resources/views/links/html.antlers.html new file mode 100644 index 00000000..c48c3b7d --- /dev/null +++ b/resources/views/links/html.antlers.html @@ -0,0 +1 @@ +{{ text }} \ No newline at end of file diff --git a/resources/views/links/markdown.antlers.html b/resources/views/links/markdown.antlers.html new file mode 100644 index 00000000..535b2fea --- /dev/null +++ b/resources/views/links/markdown.antlers.html @@ -0,0 +1 @@ +[{{ text }}]({{ url }}) \ No newline at end of file diff --git a/routes/cp.php b/routes/cp.php index f32851aa..18452d11 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -16,3 +16,52 @@ Route::post('seo-pro/section-defaults/collections/{seo_pro_collection}', [Controllers\CollectionDefaultsController::class, 'update'])->name('seo-pro.section-defaults.collections.update'); Route::get('seo-pro/section-defaults/taxonomies/{seo_pro_taxonomy}/edit', [Controllers\TaxonomyDefaultsController::class, 'edit'])->name('seo-pro.section-defaults.taxonomies.edit'); Route::post('seo-pro/section-defaults/taxonomies/{seo_pro_taxonomy}', [Controllers\TaxonomyDefaultsController::class, 'update'])->name('seo-pro.section-defaults.taxonomies.update'); + +Route::prefix('seo-pro/links')->group(function () { + Route::get('/', [Controllers\Linking\LinksController::class, 'index'])->name('seo-pro.internal-links.index'); + Route::get('/filter', [Controllers\Linking\LinksController::class, 'filter'])->name('seo-pro.entry-links.index'); + + Route::prefix('link')->group(function () { + Route::get('/{link}', [Controllers\Linking\LinksController::class, 'getLink'])->name('seo-pro.entry-links.get-link'); + Route::put('/{link}', [Controllers\Linking\LinksController::class, 'updateLink'])->name('seo-pro.entry-links.update-link'); + Route::delete('/{link}', [Controllers\Linking\LinksController::class, 'resetEntrySuggestions'])->name('seo-pro.entry-links.reset-suggesions'); + }); + + Route::get('/overview', [Controllers\Linking\LinksController::class, 'getOverview'])->name('seo-pro.entry-links.overview'); + Route::get('/{entryId}/suggestions', [Controllers\Linking\LinksController::class, 'getSuggestions'])->name('seo-pro.internal-links.get-suggestions'); + Route::get('/{entryId}/related', [Controllers\Linking\LinksController::class, 'getRelatedContent'])->name('seo-pro.internal-links.related'); + Route::get('/{entryId}/internal', [Controllers\Linking\LinksController::class, 'getInternalLinks'])->name('seo-pro.internal-links.internal'); + Route::get('/{entryId}/external', [Controllers\Linking\LinksController::class, 'getExternalLinks'])->name('seo-pro.internal-links.external'); + Route::get('/{entryId}/inbound', [Controllers\Linking\LinksController::class, 'getInboundInternalLinks'])->name('seo-pro.internal-links.inbound'); + Route::get('/{entryId}/sections', [Controllers\Linking\LinksController::class, 'getSections'])->name('seo-pro.internal-links.sections'); + + Route::get('/field-details/{entryId}/{fieldPath}', [Controllers\Linking\LinksController::class, 'getLinkFieldDetails'])->name('seo-pro.internal-links.field-details'); + + Route::post('/', [Controllers\Linking\LinksController::class, 'insertLink'])->name('seo-pro.internal-links.insert-link'); + Route::post('/check', [Controllers\Linking\LinksController::class, 'checkLinkReplacement'])->name('seo-pro.internal-links.check-link'); + + Route::post('/ignored-suggestions', [Controllers\Linking\IgnoredSuggestionsController::class, 'create'])->name('seo-pro.ignored-suggestions.create'); + + Route::prefix('/config')->group(function () { + Route::prefix('/collections')->group(function () { + Route::get('/', [Controllers\Linking\CollectionLinkSettingsController::class, 'index'])->name('seo-pro.internal-link-settings.collections'); + Route::put('/{collection}', [Controllers\Linking\CollectionLinkSettingsController::class, 'update'])->name('seo-pro.internal-link-settings.collections.update'); + Route::delete('/{collection}', [Controllers\Linking\CollectionLinkSettingsController::class, 'resetConfig'])->name('seo-pro.internal-link-settings.collections.delete'); + }); + + Route::prefix('/sites')->group(function () { + Route::get('/', [Controllers\Linking\SiteLinkSettingsController::class, 'index'])->name('seo-pro.internal-link-settings.sites'); + Route::put('/{site}', [Controllers\Linking\SiteLinkSettingsController::class, 'update'])->name('seo-pro.internal-link-settings.sites.update'); + Route::delete('/{site}', [Controllers\Linking\SiteLinkSettingsController::class, 'resetConfig'])->name('seo-pro.internal-link-settings.sites.reset'); + }); + }); + + Route::prefix('/automatic')->group(function () { + Route::get('/', [Controllers\Linking\GlobalAutomaticLinksController::class, 'index'])->name('seo-pro.automatic-links.index'); + Route::get('/filter', [Controllers\Linking\GlobalAutomaticLinksController::class, 'filter'])->name('seo-pro.automatic-links.filter'); + + Route::post('/', [Controllers\Linking\GlobalAutomaticLinksController::class, 'create'])->name('seo-pro.automatic-links.create'); + Route::delete('/{automaticLink}', [Controllers\Linking\GlobalAutomaticLinksController::class, 'delete'])->name('seo-pro.automatic-links.delete'); + Route::post('/{automaticLink}', [Controllers\Linking\GlobalAutomaticLinksController::class, 'update'])->name('seo-pro.automatic-links.update'); + }); +}); diff --git a/src/Actions/ViewLinkSuggestions.php b/src/Actions/ViewLinkSuggestions.php new file mode 100644 index 00000000..df11843b --- /dev/null +++ b/src/Actions/ViewLinkSuggestions.php @@ -0,0 +1,33 @@ +first()->id(); + + return route('statamic.cp.seo-pro.internal-links.get-suggestions', $id); + } + + public function visibleTo($item) + { + return $item instanceof Entry; + } + + public function visibleToBulk($items) + { + return false; + } +} diff --git a/src/Blueprints/CollectionConfigBlueprint.php b/src/Blueprints/CollectionConfigBlueprint.php new file mode 100644 index 00000000..3444f147 --- /dev/null +++ b/src/Blueprints/CollectionConfigBlueprint.php @@ -0,0 +1,53 @@ +setContents([ + 'sections' => [ + 'settings' => [ + 'fields' => [ + [ + 'handle' => 'linking_enabled', + 'field' => [ + 'display' => 'Linking Enabled', + 'type' => 'toggle', + ], + ], + [ + 'handle' => 'allow_cross_site_linking', + 'field' => [ + 'display' => 'Allow Cross-Site Suggestions', + 'type' => 'toggle', + 'default' => false, + ], + ], + [ + 'handle' => 'allow_cross_collection_suggestions', + 'field' => [ + 'display' => 'Allow Suggestions from all Collections', + 'type' => 'toggle', + ], + ], + [ + 'handle' => 'allowed_collections', + 'field' => [ + 'display' => 'Receive Suggestions From', + 'type' => 'collections', + 'mode' => 'select', + 'if' => [ + 'allow_cross_collection_suggestions' => 'equals false', + ], + ], + ], + ], + ], + ], + ]); + } +} diff --git a/src/Blueprints/EntryConfigBlueprint.php b/src/Blueprints/EntryConfigBlueprint.php new file mode 100644 index 00000000..a84c896b --- /dev/null +++ b/src/Blueprints/EntryConfigBlueprint.php @@ -0,0 +1,34 @@ +setContents([ + 'sections' => [ + 'settings' => [ + 'fields' => [ + [ + 'handle' => 'can_be_suggested', + 'field' => [ + 'type' => 'toggle', + 'default' => true, + ], + ], + [ + 'handle' => 'include_in_reporting', + 'field' => [ + 'type' => 'toggle', + 'default' => true, + ], + ], + ], + ], + ], + ]); + } +} diff --git a/src/Blueprints/GlobalAutomaticLinksBlueprint.php b/src/Blueprints/GlobalAutomaticLinksBlueprint.php new file mode 100644 index 00000000..4ac12add --- /dev/null +++ b/src/Blueprints/GlobalAutomaticLinksBlueprint.php @@ -0,0 +1,41 @@ +setContents([ + 'sections' => [ + 'settings' => [ + 'fields' => [ + [ + 'handle' => 'link_text', + 'field' => [ + 'display' => 'Link Text', + 'type' => 'text', + ], + ], + [ + 'handle' => 'link_target', + 'field' => [ + 'display' => 'Link Target', + 'type' => 'text', + ], + ], + [ + 'handle' => 'is_active', + 'field' => [ + 'display' => 'Active Link', + 'type' => 'toggle', + ], + ], + ], + ], + ], + ]); + } +} diff --git a/src/Blueprints/LinkBlueprint.php b/src/Blueprints/LinkBlueprint.php new file mode 100644 index 00000000..682404eb --- /dev/null +++ b/src/Blueprints/LinkBlueprint.php @@ -0,0 +1,41 @@ +setContents([ + 'sections' => [ + 'filters' => [ + 'fields' => [ + [ + 'handle' => 'internal_link_count', + 'field' => [ + 'display' => 'Internal Link Count', + 'type' => 'integer', + ], + ], + [ + 'handle' => 'external_link_count', + 'field' => [ + 'display' => 'External Link Count', + 'type' => 'integer', + ], + ], + [ + 'handle' => 'inbound_internal_link_count', + 'field' => [ + 'display' => 'Inbound Internal Link Count', + 'type' => 'integer', + ], + ], + ], + ], + ], + ]); + } +} diff --git a/src/Blueprints/SiteConfigBlueprint.php b/src/Blueprints/SiteConfigBlueprint.php new file mode 100644 index 00000000..eece6b2c --- /dev/null +++ b/src/Blueprints/SiteConfigBlueprint.php @@ -0,0 +1,88 @@ +setContents([ + 'sections' => [ + 'thresholds' => [ + 'fields' => [ + [ + 'handle' => 'keyword_threshold', + 'field' => [ + 'display' => 'Keyword Threshold', + 'type' => 'range', + 'default' => config('statamic.seo-pro.linking.keyword_threshold', 65), + 'width' => 50, + ], + ], + [ + 'handle' => 'prevent_circular_links', + 'field' => [ + 'display' => 'Prevent Circular Link Suggestions', + 'type' => 'toggle', + 'width' => 50, + ], + ], + ], + ], + 'settings' => [ + 'fields' => [ + [ + 'handle' => 'min_internal_links', + 'field' => [ + 'display' => 'Min. Internal Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.linking.internal_links.min_desired', 3), + 'width' => 50, + ], + ], + [ + 'handle' => 'max_internal_links', + 'field' => [ + 'display' => 'Max. Internal Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.linking.internal_links.max_desired', 6), + 'width' => 50, + ], + ], + [ + 'handle' => 'min_external_links', + 'field' => [ + 'display' => 'Min. External Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.linking.external_links.min_desired', 0), + 'width' => 50, + ], + ], + [ + 'handle' => 'max_external_links', + 'field' => [ + 'display' => 'Max. External Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.linking.external_links.max_desired', 0), + 'width' => 50, + ], + ], + ], + ], + 'phrases' => [ + 'fields' => [ + [ + 'handle' => 'ignored_phrases', + 'field' => [ + 'display' => 'Ignored Phrases', + 'type' => 'list', + ], + ], + ], + ], + ], + ]); + } +} diff --git a/src/Commands/GenerateEmbeddingsCommand.php b/src/Commands/GenerateEmbeddingsCommand.php new file mode 100644 index 00000000..e715f366 --- /dev/null +++ b/src/Commands/GenerateEmbeddingsCommand.php @@ -0,0 +1,25 @@ +line('Generating...'); + + $vectors->generateEmbeddingsForAllEntries(); + + $this->info('Embeddings generated.'); + } +} diff --git a/src/Commands/GenerateKeywordsCommand.php b/src/Commands/GenerateKeywordsCommand.php new file mode 100644 index 00000000..177ea261 --- /dev/null +++ b/src/Commands/GenerateKeywordsCommand.php @@ -0,0 +1,25 @@ +line('Generating...'); + + $keywords->generateKeywordsForAllEntries(); + + $this->info('Keywords generated.'); + } +} diff --git a/src/Commands/ScanLinksCommand.php b/src/Commands/ScanLinksCommand.php new file mode 100644 index 00000000..9a450f04 --- /dev/null +++ b/src/Commands/ScanLinksCommand.php @@ -0,0 +1,25 @@ +line('Scanning links...'); + + $crawler->scanAllEntries(); + + $this->info('Links scanned.'); + } +} diff --git a/src/Commands/StartTheEnginesCommand.php b/src/Commands/StartTheEnginesCommand.php new file mode 100644 index 00000000..74ebe0b0 --- /dev/null +++ b/src/Commands/StartTheEnginesCommand.php @@ -0,0 +1,32 @@ +line('Getting things ready...'); + + $crawler->scanAllEntries(); + $keywordsRepository->generateKeywordsForAllEntries(); + $entryEmbeddingsRepository->generateEmbeddingsForAllEntries(); + + $this->info('Vroom vroom.'); + } +} diff --git a/src/Content/ContentMapper.php b/src/Content/ContentMapper.php new file mode 100644 index 00000000..af5c00fe --- /dev/null +++ b/src/Content/ContentMapper.php @@ -0,0 +1,287 @@ +isValidMapper($mapper)) { + return $this; + } + + $this->fieldtypeMappers[$mapper::fieldtype()] = $mapper; + + return $this; + } + + public function registerMappers(array $mappers): static + { + foreach ($mappers as $mapper) { + $this->registerMapper($mapper); + } + + return $this; + } + + public function startFieldPath(string $handle): static + { + $this->path = [$handle]; + + return $this; + } + + public function append(string $value): static + { + $this->path[] = $value; + + return $this; + } + + public function escapeMetaValue(string $value): string + { + return Str::swap([ + '\\' => '\\\\', + '{' => '\\{', + '}' => '\\}', + '[' => '\\[', + ']' => '\\]', + ], $value); + } + + public function appendMeta(string $name, string $value): static + { + return $this->append('{'.$name.':'.$this->escapeMetaValue($value).'}'); + } + + public function appendDisplayName(?string $display): static + { + if (! $display) { + return $this; + } + + return $this->appendMeta('display_name', $display); + } + + public function appendFieldType(string $type): static + { + return $this->appendMeta('type', $type); + } + + public function appendSetName(string $set): static + { + return $this->appendMeta('set', $set); + } + + public function appendNode(string $name): static + { + return $this->appendMeta('node', $name); + } + + public function pushIndex(string $index): static + { + $this->path[] = "[{$index}]"; + $this->pushedIndexStack[] = $this->path; + + return $this; + } + + public function hasMapper(string $fieldType): bool + { + return array_key_exists($fieldType, $this->fieldtypeMappers); + } + + public function getFieldtypeMapper(string $fieldType): \Statamic\SeoPro\Contracts\Content\FieldtypeContentMapper + { + /** @var \Statamic\SeoPro\Contracts\Content\FieldtypeContentMapper $fieldtypeMapper */ + $fieldtypeMapper = app($this->fieldtypeMappers[$fieldType]); + + $this->appendFieldType($fieldType); + + return $fieldtypeMapper->withMapper($this); + } + + protected function indexValues(Entry $entry): void + { + $this->values = $entry->toDeferredAugmentedArray(); + } + + public function dropNestingLevel(): static + { + $stackCount = count($this->pushedIndexStack); + + if ($stackCount === 0) { + return $this; + } + + $this->path = $this->pushedIndexStack[$stackCount - 1]; + + return $this; + } + + public function popIndex(): static + { + $toRestore = array_pop($this->pushedIndexStack); + + if (! $toRestore) { + return $this; + } + + array_pop($toRestore); + + $this->path = $toRestore; + + return $this; + } + + public function getPath(): string + { + return implode('', $this->path); + } + + public function addMapping(string $path, string $value): static + { + $this->contentMapping[$path] = $value; + + return $this; + } + + public function finish(string $value): static + { + $valuePath = implode('', $this->path); + + if (count($this->pushedIndexStack) > 0) { + $this->contentMapping[$valuePath] = $value; + + return $this; + } + + $this->contentMapping[$valuePath] = $value; + $this->path = []; + + return $this; + } + + public function newMapper(): ContentMapper + { + return tap(new ContentMapper)->registerMappers($this->fieldtypeMappers); + } + + public function reset(): static + { + $this->contentMapping = []; + $this->values = []; + $this->path = []; + $this->pushedIndexStack = []; + + return $this; + } + + public function getContentMappingFromArray(array $fields, array $values): array + { + $this->reset(); + + $this->values = $values; + + foreach ($fields as $handle => $field) { + if (! Arr::get($field, 'field.type')) { + continue; + } + + $field = $field['field']; + $type = $field['type']; + + if (! $this->hasMapper($type)) { + continue; + } + + $this->startFieldPath($handle) + ->appendDisplayName(Arr::get($field, 'field.display')) + ->getFieldtypeMapper($type) + ->withFieldConfig($field) + ->withValue($this->values[$handle] ?? null) + ->getContent(); + } + + return $this->contentMapping; + } + + public function getContentMapping(Entry $entry): array + { + $this->reset()->indexValues($entry); + + /** @var Field $field */ + foreach ($entry->blueprint()->fields()->all() as $field) { + $type = $field->type(); + + if (! $this->hasMapper($type)) { + continue; + } + + $this->startFieldPath($field->handle()) + ->appendDisplayName($field->display()) + ->getFieldtypeMapper($type) + ->withEntry($entry) + ->withFieldConfig($field->config()) + ->withValue($this->values[$field->handle()]?->raw() ?? null) + ->getContent(); + } + + return $this->contentMapping; + } + + public function getContentFields(Entry $entry): Collection + { + return collect($this->getContentMapping($entry)) + ->map(fn ($_, $path) => $this->retrieveField($entry, $path)); + } + + public function getFieldNames(string $path): array + { + return (new ContentPathParser)->parse($path)->getDisplayNames(); + } + + public function retrieveField(Entry $entry, string $path): RetrievedField + { + $parsedPath = (new ContentPathParser)->parse($path); + $dotPath = (string) $parsedPath; + + $rootData = $entry->get($parsedPath->root->name); + $data = $rootData; + + if (mb_strlen(trim($dotPath)) > 0) { + $data = Arr::get($rootData, $dotPath); + } + + return new RetrievedField( + $data, + $entry, + $parsedPath->root->name, + $path, + $dotPath, + $parsedPath->getLastType(), + ); + } +} diff --git a/src/Content/ContentMatching.php b/src/Content/ContentMatching.php new file mode 100644 index 00000000..6bdc5b4c --- /dev/null +++ b/src/Content/ContentMatching.php @@ -0,0 +1,24 @@ +]*>(.*?)<\/a>/i'; + + preg_match_all($pattern, $value, $matches, PREG_SET_ORDER); + + foreach ($matches as $match) { + $value = Str::replace($match[0], '', $value); + } + + return $value; + } + + public static function removePreCodeBlocks(string $value): string + { + $preCodePattern = '/
]*>.*?<\/code><\/pre>/is';
+
+        return preg_replace($preCodePattern, '', $value);
+    }
+
+    public static function removeHeadings(string $value): string
+    {
+        $headingPattern = '/]*>.*?<\/h[1-6]>/is';
+
+        return preg_replace($headingPattern, '', $value);
+    }
+}
diff --git a/src/Content/ContentRetriever.php b/src/Content/ContentRetriever.php
new file mode 100644
index 00000000..5df84ef7
--- /dev/null
+++ b/src/Content/ContentRetriever.php
@@ -0,0 +1,133 @@
+stripTags($content);
+    }
+
+    public function stripTags(string $content): string
+    {
+        // Remove additional items that can cause issues with keywords, etc.
+        $content = ContentRemoval::removePreCodeBlocks($content);
+
+        return Str::squish(strip_tags($content));
+    }
+
+    public function getContent(Entry $entry, bool $stripTags = true): string
+    {
+        if ($entry instanceof Page) {
+            $entry = $entry->entry();
+        }
+
+        $originalRequest = app('request');
+        $request = tap(Request::capture(), function ($request) {
+            app()->instance('request', $request);
+            Cascade::withRequest($request);
+        });
+
+        try {
+            $content = SeoPro::withSeoProFlag(function () use ($entry, $request) {
+                return $entry->toResponse($request)->getContent();
+            });
+        } finally {
+            app()->instance('request', $originalRequest);
+        }
+
+        if (! Str::contains($content, '')) {
+            return '';
+        }
+
+        preg_match_all('/(.*?)/si', $content, $matches);
+
+        if (! isset($matches[1]) || ! is_array($matches[1])) {
+            return '';
+        }
+
+        return $this->adjustContent(
+            $this->transformContent(implode('', $matches[1])),
+            $stripTags
+        );
+    }
+
+    public function getContentMapping(Entry $entry): array
+    {
+        return $this->mapper->getContentMapping($entry);
+    }
+
+    /**
+     * @return array{id:string,text:string}
+     */
+    public function getSections(Entry $entry): array
+    {
+        $entryLink = EntryLink::where('entry_id', $entry->id())->first();
+
+        if (! $entryLink) {
+            return [];
+        }
+
+        $sections = [];
+
+        $dom = new DOMDocument;
+
+        @$dom->loadHTML($entryLink->analyzed_content);
+        $xpath = new DOMXPath($dom);
+
+        $headings = $xpath->query('//h1 | //h2 | //h3 | //h4 | //h5 | //h6');
+
+        foreach ($headings as $heading) {
+            $id = $heading->getAttribute('id');
+            $name = $heading->getAttribute('name');
+
+            if ($id) {
+                $sections[] = [
+                    'id' => $id,
+                    'text' => trim($heading->textContent),
+                ];
+            } elseif ($name) {
+                $sections[] = [
+                    'id' => $name,
+                    'text' => trim($heading->textContent),
+                ];
+            }
+        }
+
+        return $sections;
+    }
+}
diff --git a/src/Content/FieldIndex.php b/src/Content/FieldIndex.php
new file mode 100644
index 00000000..fa987eb8
--- /dev/null
+++ b/src/Content/FieldIndex.php
@@ -0,0 +1,64 @@
+query = EntryLink::query();
+    }
+
+    public function query()
+    {
+        $this->query = EntryLink::query();
+
+        return $this;
+    }
+
+    public function inCollection(string $collection): static
+    {
+        $this->query = $this->query->where('collection', $collection);
+
+        return $this;
+    }
+
+    public function havingFieldType(string $type): static
+    {
+        $this->query = $this->query
+            ->where('content_mapping', 'LIKE', '%"last_fieldtype":"'.$type.'"%');
+
+        return $this;
+    }
+
+    public function havingFieldWithType(string $handle, string $fieldType): static
+    {
+        $search = $handle.'{type:'.$fieldType.'}';
+
+        $this->query = $this->query
+            ->where('content_mapping', 'LIKE', '%"fqn_path":"%'.$search.'%"%');
+
+        return $this;
+    }
+
+    public function whereValue(string $value): static
+    {
+        $this->query = $this->query
+            ->where('content_mapping', 'LIKE', '%"value":"'.$value.'"%');
+
+        return $this;
+    }
+
+    public function entries()
+    {
+        $ids = $this->query->select('entry_id')->distinct()->get()->pluck('entry_id')->all();
+
+        return Entry::query()->whereIn('id', $ids)->get();
+    }
+}
diff --git a/src/Content/LinkReplacement.php b/src/Content/LinkReplacement.php
new file mode 100644
index 00000000..56fbc3a1
--- /dev/null
+++ b/src/Content/LinkReplacement.php
@@ -0,0 +1,42 @@
+cachedTarget) {
+            return $this->cachedTarget;
+        }
+
+        $linkTarget = $this->target;
+
+        if (str_starts_with($this->target, 'entry::')) {
+            $targetEntry = EntryApi::find(substr($this->target, 7));
+
+            if (! $targetEntry) {
+                return null;
+            }
+
+            $linkTarget = $targetEntry->uri();
+        }
+
+        if ($this->section !== '--none--') {
+            $linkTarget .= '#'.$this->section;
+        }
+
+        return $this->cachedTarget = $linkTarget;
+    }
+}
diff --git a/src/Content/LinkReplacer.php b/src/Content/LinkReplacer.php
new file mode 100644
index 00000000..986bb69f
--- /dev/null
+++ b/src/Content/LinkReplacer.php
@@ -0,0 +1,91 @@
+isValidReplacer($replacer)) {
+            return $this;
+        }
+
+        $this->fieldtypeReplacers[$replacer::fieldtype()] = $replacer;
+
+        return $this;
+    }
+
+    /**
+     * @param  string[]  $replacers
+     * @return $this
+     */
+    public function registerReplacers(array $replacers): static
+    {
+        foreach ($replacers as $replacer) {
+            $this->registerReplacer($replacer);
+        }
+
+        return $this;
+    }
+
+    protected function getReplacer(string $handle): ?FieldtypeLinkReplacer
+    {
+        $parsedPath = (new ContentPathParser)->parse($handle);
+        $fieldtype = $parsedPath->getLastType();
+
+        if (! array_key_exists($fieldtype, $this->fieldtypeReplacers)) {
+            return null;
+        }
+
+        return app($this->fieldtypeReplacers[$fieldtype]);
+    }
+
+    protected function getReplacementContext(Entry $entry, LinkReplacement $replacement): ReplacementContext
+    {
+        $retrievedField = $this->contentMapper->retrieveField($entry, $replacement->fieldHandle);
+
+        return new ReplacementContext(
+            $entry,
+            $replacement,
+            $retrievedField,
+        );
+    }
+
+    protected function withReplacer(Entry $entry, LinkReplacement $replacement, callable $callback): bool
+    {
+        if ($replacer = $this->getReplacer($replacement->fieldHandle)) {
+            return $callback($replacer, $this->getReplacementContext($entry, $replacement));
+        }
+
+        return false;
+    }
+
+    public function canReplace(Entry $entry, LinkReplacement $replacement): bool
+    {
+        return $this->withReplacer($entry, $replacement, fn (FieldtypeLinkReplacer $replacer, ReplacementContext $context) => $replacer->canReplace($context));
+    }
+
+    public function replaceLink(Entry $entry, LinkReplacement $replacement): bool
+    {
+        if (! $replacement->getTarget()) {
+            return false;
+        }
+
+        return $this->withReplacer($entry, $replacement, fn (FieldtypeLinkReplacer $replacer, ReplacementContext $context) => $replacer->replace($context));
+    }
+}
diff --git a/src/Content/LinkReplacers/Bard/BardLink.php b/src/Content/LinkReplacers/Bard/BardLink.php
new file mode 100644
index 00000000..50e08d97
--- /dev/null
+++ b/src/Content/LinkReplacers/Bard/BardLink.php
@@ -0,0 +1,13 @@
+ $word,
+                    'origin' => $i,
+                    'node' => $node,
+                ];
+            }
+        }
+
+        return $index;
+    }
+
+    protected function findPositionInParagraph(array $content, string $searchString): ?array
+    {
+        if (! array_key_exists('type', $content) || $content['type'] != 'paragraph') {
+            return null;
+        }
+
+        if (! array_key_exists('content', $content)) {
+            return null;
+        }
+
+        $pos = null;
+
+        $index = $this->indexParagraph($content['content']);
+        $indexCount = count($index);
+
+        $searchWords = explode(' ', $searchString);
+        $searchSpaceCount = count($searchWords);
+
+        for ($i = 0; $i < count($index); $i++) {
+            if ($i + $searchSpaceCount > $indexCount) {
+                break;
+            }
+
+            $section = array_slice($index, $i, $searchSpaceCount);
+            $sectionWords = collect($section)->pluck('text')->implode(' ');
+
+            if ($sectionWords != $searchString) {
+                continue;
+            }
+
+            // Prevent stomping on existing links.
+            foreach ($section as $item) {
+                if ($this->hasLinkMark($item['node'])) {
+                    return null;
+                }
+            }
+
+            $pos = $i;
+            break;
+        }
+
+        return [$pos, $searchSpaceCount];
+    }
+
+    public function findPositionIn(array $content, string $searchString): ?array
+    {
+        if (array_key_exists('type', $content)) {
+            return $this->findPositionInParagraph($content, $searchString);
+        }
+
+        for ($i = 0; $i < count($content); $i++) {
+            $paragraph = $content[$i];
+
+            if ($pos = $this->findPositionInParagraph($paragraph, $searchString)) {
+                if (is_null($pos[0])) {
+                    continue;
+                }
+
+                return [$i, $pos[0], $pos[1]];
+            }
+        }
+
+        return null;
+    }
+
+    public function insertLinkAt(array $content, ?array $pos, BardLink $link): array
+    {
+        if (! $pos) {
+            return $content;
+        }
+
+        $locOffset = 0;
+
+        if (count($pos) === 3) {
+            $repContent = $content[$pos[0]]['content'];
+        } else {
+            $repContent = $content['content'];
+            $locOffset = 1;
+        }
+
+        $index = $this->indexParagraph($repContent);
+        $replacementStarts = $pos[1 - $locOffset];
+        $replacementLength = $pos[2 - $locOffset];
+
+        $before = array_slice($index, 0, $replacementStarts);
+        $middle = array_slice($index, $replacementStarts, $replacementLength);
+        $after = array_slice($index, $replacementStarts + $replacementLength);
+
+        $textMerger = function ($group) {
+            $first = $group->first();
+            $firstNode = $first['node'];
+
+            if ($group->count() == 1) {
+
+                $firstNode['text'] = $first['text'] ?? '';
+
+                return $firstNode;
+            }
+
+            $groupWords = $group->pluck('text')->implode(' ');
+            $groupWords .= $this->findTrailingWhitespace($firstNode['text'], $groupWords);
+
+            $firstNode['text'] = $groupWords;
+
+            return $firstNode;
+        };
+
+        $result = collect($before)->groupBy('origin')->map($textMerger)
+            ->concat(
+                collect($this->mergeNodes($middle))->map(function ($node) use ($link) {
+                    $newNode = $node['node'];
+
+                    $newNode['text'] = $node['text'].$this->findTrailingWhitespace($newNode['text'], $node['text']);
+
+                    return $this->setLinkMark($newNode, $link);
+                })->all()
+            )
+            ->concat(
+                collect($after)->groupBy('origin')->map($textMerger)
+            )->all();
+
+        if (count($pos) === 3) {
+            $content[$pos[0]]['content'] = $result;
+        } else {
+            $content['content'] = $result;
+        }
+
+        return $content;
+    }
+
+    protected function findTrailingWhitespace(string $haystack, string $needle): string
+    {
+        $lastPos = strrpos($haystack, $needle);
+
+        if ($lastPos !== false) {
+            $afterSearch = substr($haystack, $lastPos + strlen($needle));
+
+            if (preg_match('/^\s+/', $afterSearch, $matches)) {
+                return $matches[0];
+            }
+        }
+
+        return '';
+    }
+
+    public function canInsertLink(array $content, string $search): bool
+    {
+        return $this->findPositionIn($content, $search) != null;
+    }
+
+    public function replaceFirstWithLink(array $content, string $search, BardLink $link): array
+    {
+        return $this->insertLinkAt(
+            $content,
+            $this->findPositionIn($content, $search),
+            $link
+        );
+    }
+
+    protected function mergeNodes(array $nodes): array
+    {
+        if (count($nodes) <= 1) {
+            return $nodes;
+        }
+
+        $newNodes = [];
+
+        $lastNode = $nodes[0];
+        $lastMark = [];
+
+        if (array_key_exists('marks', $lastNode['node'])) {
+            $lastMark = $lastNode['node']['marks'];
+        }
+
+        for ($i = 1; $i < count($nodes); $i++) {
+            $node = $nodes[$i];
+            $currentMark = [];
+
+            if (array_key_exists('marks', $node['node'])) {
+                $currentMark = $node['node']['marks'];
+            }
+
+            if ($currentMark != $lastMark) {
+                $newNodes[] = $lastNode;
+
+                $lastNode = $node;
+                $lastMark = $currentMark;
+
+                continue;
+            }
+
+            $lastNode['text'] .= ' '.$node['text'];
+        }
+
+        if (count($newNodes) > 0) {
+            if ($newNodes[count($newNodes) - 1] != $lastNode) {
+                $newNodes[] = $lastNode;
+            }
+        } else {
+            $newNodes[] = $lastNode;
+        }
+
+        return $newNodes;
+    }
+
+    protected function setLinkMark(array $node, BardLink $link): array
+    {
+        $marks = collect($node['marks'] ?? [])->where(function ($mark) {
+            return $mark['type'] != 'link';
+        })->all();
+
+        $marks[] = [
+            'type' => 'link',
+            'attrs' => [
+                'href' => $link->href,
+                'rel' => $link->rel,
+                'target' => $link->target,
+                'title' => $link->title,
+            ],
+        ];
+
+        $node['marks'] = $marks;
+
+        return $node;
+    }
+
+    protected function hasLinkMark(array $node): bool
+    {
+        return collect($node['marks'] ?? [])->where(fn ($node) => $node['type'] === 'link')->count() > 0;
+    }
+}
diff --git a/src/Content/LinkReplacers/Bard/BardReplacer.php b/src/Content/LinkReplacers/Bard/BardReplacer.php
new file mode 100644
index 00000000..155e0dd6
--- /dev/null
+++ b/src/Content/LinkReplacers/Bard/BardReplacer.php
@@ -0,0 +1,43 @@
+bardManipulator->canInsertLink(
+            $context->field->getValue(),
+            $context->replacement->phrase,
+        );
+    }
+
+    public function replace(ReplacementContext $context): bool
+    {
+        $currentContent = $context->field->getValue();
+        $updatedContent = $this->bardManipulator->replaceFirstWithLink(
+            $currentContent,
+            $context->replacement->phrase,
+            new BardLink(
+                $context->replacement->getTarget()
+            ),
+        );
+
+        $context->field->update($updatedContent)->save();
+
+        return json_encode($currentContent) == json_encode($updatedContent);
+    }
+}
diff --git a/src/Content/LinkReplacers/MarkdownReplacer.php b/src/Content/LinkReplacers/MarkdownReplacer.php
new file mode 100644
index 00000000..891a5751
--- /dev/null
+++ b/src/Content/LinkReplacers/MarkdownReplacer.php
@@ -0,0 +1,37 @@
+field->getValue(),
+            $context->replacement->phrase
+        );
+    }
+
+    public function replace(ReplacementContext $context): bool
+    {
+        $markdown = Str::replaceFirst(
+            $context->replacement->phrase,
+            $context->render('markdown'),
+            $context->field->getValue()
+        );
+
+        $context->field->update($markdown)->save();
+
+        return true;
+    }
+}
diff --git a/src/Content/LinkReplacers/TextReplacer.php b/src/Content/LinkReplacers/TextReplacer.php
new file mode 100644
index 00000000..6a532089
--- /dev/null
+++ b/src/Content/LinkReplacers/TextReplacer.php
@@ -0,0 +1,37 @@
+field->getValue(),
+            $context->replacement->phrase
+        );
+    }
+
+    public function replace(ReplacementContext $context): bool
+    {
+        $html = Str::replaceFirst(
+            $context->replacement->phrase,
+            $context->render('html'),
+            $context->field->getValue()
+        );
+
+        $context->field->update($html)->save();
+
+        return true;
+    }
+}
diff --git a/src/Content/LinkReplacers/TextareaReplacer.php b/src/Content/LinkReplacers/TextareaReplacer.php
new file mode 100644
index 00000000..d596fb36
--- /dev/null
+++ b/src/Content/LinkReplacers/TextareaReplacer.php
@@ -0,0 +1,13 @@
+value)) {
+            return [];
+        }
+
+        return $this->value;
+    }
+
+    protected function mapNestedFields(array $values, array $fields): void
+    {
+        foreach ($values as $fieldName => $fieldValue) {
+            if (! Arr::get($fields, $fieldName.'.field.type')) {
+                continue;
+            }
+
+            $field = $fields[$fieldName];
+            $type = $field['field']['type'];
+
+            $this->mapper
+                ->append($fieldName)
+                ->appendDisplayName(Arr::get($field, 'field.display'))
+                ->getFieldtypeMapper($type)
+                ->withFieldConfig($field['field'])
+                ->withValue($fieldValue)
+                ->getContent();
+
+            $this->mapper->dropNestingLevel();
+        }
+    }
+
+    public function withMapper(ContentMapper $mapper): static
+    {
+        $this->mapper = $mapper;
+
+        return $this;
+    }
+
+    public function withEntry(?Entry $entry): static
+    {
+        $this->entry = $entry;
+
+        return $this;
+    }
+
+    public function withValue(mixed $value): static
+    {
+        $this->value = $value;
+
+        return $this;
+    }
+
+    public function withFieldConfig(array $fieldConfig): static
+    {
+        $this->fieldConfig = $fieldConfig;
+
+        return $this;
+    }
+
+    public function getNestedFields(): array
+    {
+        return [];
+    }
+}
diff --git a/src/Content/Mappers/BardFieldMapper.php b/src/Content/Mappers/BardFieldMapper.php
new file mode 100644
index 00000000..c128e55f
--- /dev/null
+++ b/src/Content/Mappers/BardFieldMapper.php
@@ -0,0 +1,121 @@
+mapper->appendNode('paragraph')
+            ->finish($this->getParagraphContent($value))
+            ->popIndex();
+    }
+
+    protected function mapSets($value, $sets)
+    {
+        $setValues = $value['attrs']['values'] ?? null;
+
+        if (! $setValues) {
+            return;
+        }
+
+        if (! array_key_exists('type', $setValues)) {
+            return;
+        }
+
+        if (! array_key_exists($setValues['type'], $sets)) {
+            return;
+        }
+
+        $this->mapper->appendSetName($setValues['type']);
+
+        $set = $sets[$setValues['type']];
+        $setFields = collect($set['fields'])->keyBy('handle')->all();
+        $setValues = collect($setValues)->except(['type'])->all();
+        $mapper = $this->mapper->newMapper();
+
+        $mappedContent = $mapper->getContentMappingFromArray($setFields, $setValues);
+        $currentPath = $this->mapper->getPath();
+
+        foreach ($mappedContent as $mappedPath => $mappedValue) {
+            $this->mapper->addMapping($currentPath.$mappedPath, $mappedValue);
+        }
+
+        $this->mapper->popIndex();
+    }
+
+    public function getContent(): void
+    {
+        if (! is_array($this->value) || count($this->value) === 0) {
+            return;
+        }
+
+        if (! array_key_exists('sets', $this->fieldConfig)) {
+            $this->noSetContent();
+
+            return;
+        }
+
+        $sets = $this->getSets();
+
+        foreach ($this->value as $index => $value) {
+            if (! array_key_exists('type', $value)) {
+                continue;
+            }
+
+            $handlerMethod = 'map'.Str::studly($value['type']);
+
+            if (! method_exists($this, $handlerMethod)) {
+                continue;
+            }
+
+            $this->mapper->pushIndex($index);
+
+            $this->$handlerMethod($value, $sets);
+        }
+    }
+
+    protected function noSetContent(): void
+    {
+        $content = '';
+
+        foreach ($this->value as $value) {
+            $content .= $this->getParagraphContent($value);
+        }
+
+        $this->mapper->finish(Stringy::collapseWhitespace($content));
+    }
+
+    protected function getParagraphContent(array $value): string
+    {
+        $content = '';
+
+        if (! array_key_exists('content', $value)) {
+            return $content;
+        }
+
+        foreach ($value['content'] as $contentValue) {
+            if (! array_key_exists('type', $contentValue)) {
+                continue;
+            }
+
+            if ($contentValue['type'] === 'text') {
+                $content .= $contentValue['text'];
+            }
+        }
+
+        return $content;
+    }
+}
diff --git a/src/Content/Mappers/Concerns/GetsSets.php b/src/Content/Mappers/Concerns/GetsSets.php
new file mode 100644
index 00000000..138e082e
--- /dev/null
+++ b/src/Content/Mappers/Concerns/GetsSets.php
@@ -0,0 +1,27 @@
+fieldConfig)) {
+            return [];
+        }
+
+        $sets = [];
+
+        foreach ($this->fieldConfig['sets'] as $setGroup => $config) {
+            if (! array_key_exists('sets', $config)) {
+                continue;
+            }
+
+            foreach ($config['sets'] as $setName => $setConfig) {
+                $sets[$setName] = $setConfig;
+            }
+        }
+
+        return $sets;
+    }
+}
diff --git a/src/Content/Mappers/GridFieldMapper.php b/src/Content/Mappers/GridFieldMapper.php
new file mode 100644
index 00000000..9f475128
--- /dev/null
+++ b/src/Content/Mappers/GridFieldMapper.php
@@ -0,0 +1,53 @@
+fieldConfig['fields'] ?? [])
+            ->keyBy('handle')
+            ->all();
+
+        foreach ($this->getValues() as $index => $values) {
+            if (count($values) === 0) {
+                continue;
+            }
+
+            $this->mapper->pushIndex($index);
+
+            foreach ($values as $handle => $value) {
+                if (! array_key_exists($handle, $fields)) {
+                    continue;
+                }
+
+                $fieldType = $fields[$handle]['field']['type'] ?? null;
+
+                if (! $this->mapper->hasMapper($fieldType)) {
+                    continue;
+                }
+
+                $this->mapper
+                    ->append($handle)
+                    ->appendDisplayName(Arr::get($fields[$handle], 'field.display'))
+                    ->getFieldtypeMapper($fieldType)
+                    ->withFieldConfig($fields[$handle]['field'])
+                    ->withValue($value)
+                    ->getContent();
+
+                $this->mapper->dropNestingLevel();
+            }
+
+            $this->mapper->popIndex();
+        }
+    }
+}
diff --git a/src/Content/Mappers/GroupFieldMapper.php b/src/Content/Mappers/GroupFieldMapper.php
new file mode 100644
index 00000000..33734a0f
--- /dev/null
+++ b/src/Content/Mappers/GroupFieldMapper.php
@@ -0,0 +1,23 @@
+mapper->append('[.]');
+
+        $this->mapNestedFields(
+            $this->getValues(),
+            collect($this->fieldConfig['fields'] ?? [])->keyBy('handle')->all()
+        );
+    }
+}
diff --git a/src/Content/Mappers/MarkdownFieldMapper.php b/src/Content/Mappers/MarkdownFieldMapper.php
new file mode 100644
index 00000000..c1c4b407
--- /dev/null
+++ b/src/Content/Mappers/MarkdownFieldMapper.php
@@ -0,0 +1,13 @@
+getSets();
+
+        foreach ($this->getValues() as $index => $values) {
+            if (count($values) === 0) {
+                continue;
+            }
+
+            if (! array_key_exists('type', $values)) {
+                continue;
+            }
+
+            $type = $values['type'];
+
+            if (! array_key_exists($type, $sets)) {
+                continue;
+            }
+
+            $set = $sets[$type];
+
+            if (! array_key_exists('fields', $set)) {
+                continue;
+            }
+
+            $this->mapper->pushIndex($index)->appendSetName($type);
+
+            $this->mapNestedFields(
+                collect($values)->except(['id', 'type', 'enabled'])->all(),
+                collect($set['fields'])->keyBy('handle')->all()
+            );
+
+            $this->mapper->popIndex();
+        }
+    }
+}
diff --git a/src/Content/Mappers/TextFieldMapper.php b/src/Content/Mappers/TextFieldMapper.php
new file mode 100644
index 00000000..8e1dc57a
--- /dev/null
+++ b/src/Content/Mappers/TextFieldMapper.php
@@ -0,0 +1,19 @@
+mapper->finish($this->value ?? '');
+    }
+}
diff --git a/src/Content/Mappers/TextareaFieldMapper.php b/src/Content/Mappers/TextareaFieldMapper.php
new file mode 100644
index 00000000..313d51fd
--- /dev/null
+++ b/src/Content/Mappers/TextareaFieldMapper.php
@@ -0,0 +1,13 @@
+root->getAttribute('type');
+
+        /** @var ContentPathPart $part */
+        foreach ($this->parts as $part) {
+            if ($type = $part->getAttribute('type')) {
+                $lastType = $type;
+            }
+        }
+
+        return $lastType;
+    }
+
+    public function getDisplayNames(): array
+    {
+        return collect($this->parts)
+            ->map(fn (ContentPathPart $part) => $part->displayName())
+            ->prepend($this->root->displayName())
+            ->filter()
+            ->all();
+    }
+
+    public function __toString(): string
+    {
+        return str(
+            collect($this->parts)
+                ->map(fn ($part) => (string) $part)
+                ->implode('.')
+        )
+            ->trim('.')
+            ->value();
+    }
+}
diff --git a/src/Content/Paths/ContentPathParser.php b/src/Content/Paths/ContentPathParser.php
new file mode 100644
index 00000000..7207e429
--- /dev/null
+++ b/src/Content/Paths/ContentPathParser.php
@@ -0,0 +1,132 @@
+ implode($buffer),
+                            'meta' => $metaData,
+                            'type' => 'named',
+                        ];
+
+                        $metaData = [];
+
+                        $parts[] = $part;
+                        $buffer = [];
+                    }
+                }
+
+                continue;
+            }
+
+            if ($cur === '{') {
+                $inMeta = true;
+                $metaBuffer[] = $cur;
+
+                continue;
+            } elseif ($next === null || $next === '[') {
+                $buffer[] = $cur;
+
+                $part = [
+                    'name' => implode($buffer),
+                    'meta' => $metaData,
+                    'type' => 'named',
+                ];
+
+                $metaData = [];
+
+                $parts[] = $part;
+                $buffer = [];
+
+                continue;
+            } elseif ($cur === ']') {
+                array_shift($buffer);
+
+                $parts[] = [
+                    'name' => implode($buffer),
+                    'meta' => $metaData,
+                    'type' => 'index',
+                ];
+
+                $metaData = [];
+                $buffer = [];
+
+                continue;
+            }
+
+            $buffer[] = $cur;
+        }
+
+        if (count($metaBuffer) > 0) {
+            $metaData[] = implode('', $metaBuffer);
+        }
+
+        if (count($buffer) > 0) {
+            $part = implode($buffer);
+
+            $part = [
+                'name' => $part,
+                'meta' => $metaData,
+                'type' => 'named',
+            ];
+
+            $parts[] = $part;
+        }
+
+        $chars = null;
+        $root = array_shift($parts);
+        $contentParts = [];
+
+        foreach ($parts as $part) {
+            $contentParts[] = new ContentPathPart(
+                $part['name'],
+                $part['type'],
+                $part['meta'] ?? [],
+            );
+        }
+
+        return new ContentPath(
+            $contentParts,
+            new ContentPathPart(
+                $root['name'],
+                $root['type'],
+                $root['meta']
+            ),
+        );
+    }
+}
diff --git a/src/Content/Paths/ContentPathPart.php b/src/Content/Paths/ContentPathPart.php
new file mode 100644
index 00000000..7a7eb8d3
--- /dev/null
+++ b/src/Content/Paths/ContentPathPart.php
@@ -0,0 +1,44 @@
+metaData as $metaDatum) {
+            $item = mb_substr($metaDatum, 1, -1);
+            [$key, $val] = explode(':', $item, 2);
+
+            $this->attributes[$key] = $val;
+        }
+    }
+
+    public function displayName(): ?string
+    {
+        return $this->getAttribute('display_name');
+    }
+
+    public function getAttribute(string $key): mixed
+    {
+        return $this->attributes[$key] ?? null;
+    }
+
+    public function __toString(): string
+    {
+        if ($this->type === 'index') {
+            return $this->name;
+        }
+
+        if (array_key_exists('set', $this->attributes)) {
+            return 'attrs.values.'.$this->name;
+        }
+
+        return $this->name;
+    }
+}
diff --git a/src/Content/ReplacementContext.php b/src/Content/ReplacementContext.php
new file mode 100644
index 00000000..5bc7fe6b
--- /dev/null
+++ b/src/Content/ReplacementContext.php
@@ -0,0 +1,28 @@
+ $this->entry->toDeferredAugmentedArray(),
+            'text' => $this->replacement->phrase,
+            'url' => $this->replacement->getTarget(),
+        ];
+    }
+
+    public function render(string $view): string
+    {
+        return (string) view('seo-pro::'.$view, $this->toViewData());
+    }
+}
diff --git a/src/Content/RetrievedField.php b/src/Content/RetrievedField.php
new file mode 100644
index 00000000..3701eb33
--- /dev/null
+++ b/src/Content/RetrievedField.php
@@ -0,0 +1,123 @@
+ $this->value,
+            'root' => $this->root,
+            'fqn_path' => $this->originalPath,
+            'normalized_path' => $this->path,
+            'last_fieldtype' => $this->lastType,
+        ];
+    }
+
+    public function getLastType(): ?string
+    {
+        return $this->lastType;
+    }
+
+    public function getValue(): mixed
+    {
+        return $this->value;
+    }
+
+    public function getEntry(): Entry
+    {
+        return $this->entry;
+    }
+
+    public function getRoot(): string
+    {
+        return $this->root;
+    }
+
+    public function getOriginalPath(): string
+    {
+        return $this->originalPath;
+    }
+
+    public function getPath(): string
+    {
+        return $this->path;
+    }
+
+    public function getRootData()
+    {
+        return $this->entry->get($this->root);
+    }
+
+    protected function arrUnset(&$array, $key)
+    {
+        if (is_null($key)) {
+            return;
+        }
+
+        $keys = explode('.', $key);
+
+        while (count($keys) > 1) {
+            $key = array_shift($keys);
+
+            if (! isset($array[$key]) || ! is_array($array[$key])) {
+                return;
+            }
+
+            $array = &$array[$key];
+        }
+
+        unset($array[array_shift($keys)]);
+
+    }
+
+    protected function setRoot($newData): static
+    {
+        $this->entry->set($this->root, $newData);
+
+        return $this;
+    }
+
+    public function update(mixed $newValue): static
+    {
+        $data = $this->getRootData();
+
+        if (is_array($data)) {
+            if (strlen($this->path) > 0) {
+                Arr::set($data, $this->path, $newValue);
+            } else {
+                $data = $newValue;
+            }
+        } elseif (is_string($data) && is_string($newValue)) {
+            $data = $newValue;
+        }
+
+        return $this->setRoot($data);
+    }
+
+    public function delete(): static
+    {
+        $data = $this->getRootData();
+        $this->arrUnset($data, $this->path);
+
+        return $this->setRoot($data);
+    }
+
+    public function save()
+    {
+        return $this->entry->save();
+    }
+}
diff --git a/src/Content/Tokenizer.php b/src/Content/Tokenizer.php
new file mode 100644
index 00000000..f5f0a444
--- /dev/null
+++ b/src/Content/Tokenizer.php
@@ -0,0 +1,50 @@
+tokenize($content) as $token) {
+            $tokenLength = mb_strlen($token);
+
+            if ($currentTokenCount + $tokenLength > $tokenLimit) {
+                $chunks[] = implode('', $currentChunk);
+                $currentChunk = [];
+                $currentTokenCount = 0;
+            }
+
+            $currentChunk[] = $token;
+            $currentTokenCount += $tokenLength;
+        }
+
+        if (! empty($currentChunk)) {
+            $chunks[] = implode('', $currentChunk);
+        }
+
+        return $chunks;
+    }
+}
diff --git a/src/Contracts/Content/ContentRetriever.php b/src/Contracts/Content/ContentRetriever.php
new file mode 100644
index 00000000..7133bba8
--- /dev/null
+++ b/src/Contracts/Content/ContentRetriever.php
@@ -0,0 +1,18 @@
+runHooksWith('query', [
+            'query' => $this->query,
+        ]);
+
+        return $payload->query->paginate($perPage);
+    }
+}
diff --git a/src/Hooks/Keywords/StopWordsHook.php b/src/Hooks/Keywords/StopWordsHook.php
new file mode 100644
index 00000000..4ecf09cf
--- /dev/null
+++ b/src/Hooks/Keywords/StopWordsHook.php
@@ -0,0 +1,24 @@
+runHooksWith('loading', [
+            'stopWords' => $this->stopWords,
+        ]);
+
+        return $payload->stopWords->toArray();
+    }
+}
diff --git a/src/Http/Concerns/MergesBlueprintFields.php b/src/Http/Concerns/MergesBlueprintFields.php
new file mode 100644
index 00000000..01bacffa
--- /dev/null
+++ b/src/Http/Concerns/MergesBlueprintFields.php
@@ -0,0 +1,25 @@
+fields();
+        $values = $fields->values()->all();
+
+        if ($callback) {
+            $callback($values, $blueprint);
+        }
+
+        return array_merge($target, [
+            'blueprint' => $blueprint->toPublishArray(),
+            'meta' => (object) $fields->meta()->all(),
+            'fields' => $fields->toPublishArray(),
+            'values' => $values,
+        ]);
+    }
+}
diff --git a/src/Http/Controllers/Linking/CollectionLinkSettingsController.php b/src/Http/Controllers/Linking/CollectionLinkSettingsController.php
new file mode 100644
index 00000000..a082ca6e
--- /dev/null
+++ b/src/Http/Controllers/Linking/CollectionLinkSettingsController.php
@@ -0,0 +1,59 @@
+ajax()) {
+            return $this->configurationRepository->getCollections()->map(fn (CollectionConfig $config) => $config->toArray());
+        }
+
+        return view('seo-pro::config.link_collections', $this->mergeBlueprintIntoContext(
+            CollectionConfigBlueprint::blueprint(),
+            callback: fn (&$values) => $values['allowed_collections'] = [],
+        ));
+    }
+
+    public function update(UpdateCollectionBehaviorRequest $request, $collection)
+    {
+        abort_unless($collection, 404);
+
+        $this->configurationRepository->updateCollectionConfiguration(
+            $collection->handle(),
+            new CollectionConfig(
+                $collection->handle(),
+                $collection->title(),
+                request('linking_enabled'),
+                request('allow_cross_site_linking'),
+                request('allow_cross_collection_suggestions'),
+                request('allowed_collections'),
+            )
+        );
+    }
+
+    public function resetConfig($collection)
+    {
+        abort_unless($collection, 404);
+
+        $this->configurationRepository->resetCollectionConfiguration($collection);
+    }
+}
diff --git a/src/Http/Controllers/Linking/GlobalAutomaticLinksController.php b/src/Http/Controllers/Linking/GlobalAutomaticLinksController.php
new file mode 100644
index 00000000..015b00e7
--- /dev/null
+++ b/src/Http/Controllers/Linking/GlobalAutomaticLinksController.php
@@ -0,0 +1,87 @@
+site ? Site::get($request->site) : Site::selected();
+
+        return view('seo-pro::linking.automatic', $this->mergeBlueprintIntoContext(
+            GlobalAutomaticLinksBlueprint::blueprint(),
+            [
+                'site' => $site->handle(),
+            ],
+        ));
+    }
+
+    public function create(AutomaticLinkRequest $request)
+    {
+        $link = new AutomaticLink($request->all());
+
+        $link->save();
+    }
+
+    public function update(AutomaticLinkRequest $request, $automaticLink)
+    {
+        /** @var AutomaticLink $link */
+        $link = AutomaticLink::findOrFail($automaticLink);
+
+        $link->link_target = request('link_target');
+        $link->link_text = request('link_text');
+        $link->entry_id = request('entry_id');
+        $link->is_active = request('is_active', false);
+
+        $link->save();
+    }
+
+    public function filter(FilteredRequest $request)
+    {
+        $sortField = $this->getSortField();
+        $sortDirection = request('order', 'asc');
+
+        $query = $this->indexQuery();
+
+        $activeFilterBadges = $this->queryFilters($request, $request->filters);
+
+        if ($sortField) {
+            $query->orderBy($sortField, $sortDirection);
+        }
+
+        $links = $query->paginate(request('perPage'));
+
+        return (new AutomaticLinks($links))
+            ->additional(['meta' => [
+                'activeFilterBadges' => $activeFilterBadges,
+            ]]);
+    }
+
+    public function delete($automaticLink)
+    {
+        AutomaticLink::find($automaticLink)?->delete();
+    }
+
+    private function getSortField(): string
+    {
+        return request('sort', 'link_text');
+    }
+
+    protected function indexQuery()
+    {
+        return AutomaticLink::query();
+    }
+}
diff --git a/src/Http/Controllers/Linking/IgnoredSuggestionsController.php b/src/Http/Controllers/Linking/IgnoredSuggestionsController.php
new file mode 100644
index 00000000..7ace1110
--- /dev/null
+++ b/src/Http/Controllers/Linking/IgnoredSuggestionsController.php
@@ -0,0 +1,34 @@
+only(['action', 'scope', 'phrase', 'entry', 'ignored_entry', 'site']);
+
+        $this->linksRepository->ignoreSuggestion(new IgnoredSuggestion(
+            $data['action'],
+            $data['scope'],
+            $data['phrase'] ?? '',
+            $data['entry'],
+            $data['ignored_entry'],
+            $data['site']
+        ));
+    }
+}
diff --git a/src/Http/Controllers/Linking/LinksController.php b/src/Http/Controllers/Linking/LinksController.php
new file mode 100644
index 00000000..1a4afb18
--- /dev/null
+++ b/src/Http/Controllers/Linking/LinksController.php
@@ -0,0 +1,314 @@
+ 'cached_title',
+        'slug' => 'cached_slug',
+    ];
+
+    public function __construct(
+        Request $request,
+        protected readonly ConfigurationRepository $configurationRepository,
+        protected readonly ReportBuilder $reportBuilder,
+        protected readonly ContentRetriever $contentRetriever,
+        protected readonly LinkReplacer $linkReplacer,
+        protected readonly ContentMapper $contentMapper,
+    ) {
+        parent::__construct($request);
+    }
+
+    protected function mergeEntryConfigBlueprint(array $target = []): array
+    {
+        return $this->mergeBlueprintIntoContext(
+            EntryConfigBlueprint::blueprint(),
+            $target,
+            callback: function (&$values) {
+                $values['can_be_suggested'] = true;
+                $values['include_in_reporting'] = true;
+            }
+        );
+    }
+
+    protected function makeDashboardResponse(string $entryId, string $tab, string $title)
+    {
+        return view('seo-pro::linking.dashboard', $this->mergeEntryConfigBlueprint([
+            'report' => $this->reportBuilder->getBaseReport(Entry::findOrFail($entryId)),
+            'tab' => $tab,
+            'title' => $title,
+        ]));
+    }
+
+    public function index(Request $request)
+    {
+        $site = $request->site ? Site::get($request->site) : Site::selected();
+
+        return view('seo-pro::linking.index', $this->mergeEntryConfigBlueprint([
+            'site' => $site->handle(),
+            'filters' => Scope::filters('seo_pro.links', $this->makeFiltersContext()),
+        ]));
+    }
+
+    public function getLink(string $link)
+    {
+        return EntryLink::where('entry_id', $link)->firstOrFail();
+    }
+
+    public function updateLink(UpdateEntryLinkRequest $request, string $link)
+    {
+        /** @var EntryLink $entryLink */
+        $entryLink = EntryLink::where('entry_id', $link)->firstOrFail();
+
+        $entryLink->can_be_suggested = $request->get('can_be_suggested');
+        $entryLink->include_in_reporting = $request->get('include_in_reporting');
+
+        $entryLink->save();
+    }
+
+    public function resetEntrySuggestions(string $link)
+    {
+        /** @var EntryLink $entryLink */
+        $entryLink = EntryLink::where('entry_id', $link)->firstOrFail();
+
+        $entryLink->ignored_entries = [];
+        $entryLink->ignored_phrases = [];
+
+        $entryLink->save();
+    }
+
+    public function filter(FilteredRequest $request)
+    {
+        $sortField = $this->getSortField();
+        $sortDirection = request('order', 'asc');
+
+        $query = $this->indexQuery();
+
+        $activeFilterBadges = $this->queryFilters($query, $request->filters);
+
+        if ($sortField) {
+            $query->orderBy($sortField, $sortDirection);
+        }
+
+        if (request('search')) {
+            $query->where(function (Builder $q) {
+                $q->where('analyzed_content', 'like', '%'.request('search').'%')
+                    ->orWhere('cached_title', 'like', '%'.request('search').'%')
+                    ->orWhere('cached_uri', 'like', '%'.request('search').'%');
+            });
+        }
+
+        $links = (new EntryLinksIndexQuery($query))->paginate(request('perPage'));
+
+        return (new EntryLinks($links))
+            ->additional(['meta' => [
+                'activeFilterBadges' => $activeFilterBadges,
+            ]]);
+    }
+
+    public function getOverview()
+    {
+        // TODO: Revisit this.
+        $entriesAnalyzed = EntryLinksModel::query()->count();
+        $orphanedEntries = EntryLinksModel::query()->where('inbound_internal_link_count', 0)->count();
+
+        $entriesNeedingMoreLinks = EntryLinksModel::query()
+            ->where('include_in_reporting', true)
+            ->where('internal_link_count', '=', 0)->count();
+
+        return [
+            'total' => $entriesAnalyzed,
+            'orphaned' => $orphanedEntries,
+            'needs_links' => $entriesNeedingMoreLinks,
+        ];
+    }
+
+    public function getSuggestions($entryId)
+    {
+        if (request()->ajax()) {
+            return $this->reportBuilder->getSuggestionsReport(
+                Entry::findOrFail($entryId),
+                config('statamic.seo-pro.linking.suggestions.result_limit', 10),
+                config('statamic.seo-pro.linking.suggestions.related_entry_limit', 20),
+            )->suggestions();
+        }
+
+        return $this->makeDashboardResponse($entryId, 'suggestions', 'Link Suggestions');
+    }
+
+    public function getLinkFieldDetails($entryId, $fieldPath)
+    {
+        $entry = Entry::findOrFail($entryId);
+
+        return [
+            'field_names' => $this->contentMapper->getFieldNames($fieldPath),
+        ];
+    }
+
+    public function getRelatedContent($entryId)
+    {
+        if (request()->ajax()) {
+            return $this->reportBuilder->getRelatedContentReport(
+                Entry::findOrFail($entryId),
+                config('statamic.seo-pro.linking.suggestions.related_entry_limit', 20),
+            )->getRelated();
+        }
+
+        return $this->makeDashboardResponse($entryId, 'related', 'Related Content');
+    }
+
+    public function getInternalLinks($entryId)
+    {
+        if (request()->ajax()) {
+            return $this->reportBuilder->getInternalLinks(Entry::findOrFail($entryId))->getLinks();
+        }
+
+        return $this->makeDashboardResponse($entryId, 'internal', 'Internal Links');
+    }
+
+    public function getExternalLinks($entryId)
+    {
+        if (request()->ajax()) {
+            return $this->reportBuilder->getExternalLinks(Entry::findOrFail($entryId))->getLinks();
+        }
+
+        return $this->makeDashboardResponse($entryId, 'external', 'External Links');
+    }
+
+    public function getInboundInternalLinks($entryId)
+    {
+        if (request()->ajax()) {
+            return $this->reportBuilder->getInboundInternalLinks(Entry::findOrFail($entryId))->getLinks();
+        }
+
+        return $this->makeDashboardResponse($entryId, 'inbound', 'Inbound Internal Links');
+    }
+
+    public function getSections($entryId)
+    {
+        $entry = Entry::find($entryId);
+
+        if (! $entry) {
+            return [];
+        }
+
+        return $this->contentRetriever->getSections($entry);
+    }
+
+    protected function makeReplacementFromRequest(): LinkReplacement
+    {
+        return new LinkReplacement(
+            request('phrase') ?? '',
+            request('section') ?? '',
+            request('target') ?? '',
+            request('field') ?? ''
+        );
+    }
+
+    public function checkLinkReplacement(InsertLinkRequest $request)
+    {
+        $entry = Entry::findOrFail(request('entry'));
+
+        return [
+            'can_replace' => $this->linkReplacer->canReplace(
+                $entry,
+                $this->makeReplacementFromRequest(),
+            ),
+        ];
+    }
+
+    public function insertLink(InsertLinkRequest $request)
+    {
+        $entry = Entry::findOrFail(request('entry'));
+
+        if ($request->get('auto_link', false) === true && request('auto_link_entry')) {
+            $site = $entry->site()?->handle() ?? $request->site ? Site::get($request->site) : Site::selected();
+            $autoLinkEntry = Entry::find(request('auto_link_entry'));
+
+            $link = new AutomaticLink;
+            $link->site = $site->handle();
+            $link->is_active = true;
+            $link->link_text = request('phrase');
+            $link->link_target = $autoLinkEntry->uri();
+            $link->entry_id = request('auto_link_entry');
+
+            $link->save();
+        }
+
+        $this->linkReplacer->replaceLink(
+            $entry,
+            $this->makeReplacementFromRequest(),
+        );
+    }
+
+    private function getSortField(): string
+    {
+        $sortField = request('sort', 'title');
+
+        if (! $sortField) {
+            return $sortField;
+        }
+
+        $checkField = strtolower($sortField);
+
+        if (array_key_exists($checkField, $this->sortFieldMappings)) {
+            $sortField = $this->sortFieldMappings[$checkField];
+        }
+
+        return $sortField;
+    }
+
+    protected function indexQuery(): Builder
+    {
+        $disabledCollections = $this->configurationRepository->getDisabledCollections();
+
+        return EntryLinksModel::query()->whereNotIn('collection', $disabledCollections);
+    }
+
+    protected function makeFiltersContext(): array
+    {
+        $collections = $this->configurationRepository
+            ->getCollections()
+            ->where(fn (CollectionConfig $config) => $config->linkingEnabled)
+            ->map(fn (CollectionConfig $config) => $config->handle)
+            ->all();
+
+        $sites = Site::all()
+            ->map(fn ($site) => $site->handle())
+            ->values()
+            ->all();
+
+        return [
+            'collections' => $collections,
+            'sites' => $sites,
+        ];
+    }
+}
diff --git a/src/Http/Controllers/Linking/SiteLinkSettingsController.php b/src/Http/Controllers/Linking/SiteLinkSettingsController.php
new file mode 100644
index 00000000..749b61f3
--- /dev/null
+++ b/src/Http/Controllers/Linking/SiteLinkSettingsController.php
@@ -0,0 +1,62 @@
+ajax()) {
+            return $this->configurationRepository->getSites()->map(fn (SiteConfig $config) => $config->toArray());
+        }
+
+        return view('seo-pro::config.sites', $this->mergeBlueprintIntoContext(
+            SiteConfigBlueprint::blueprint(),
+            callback: fn (&$values) => $values['ignored_phrases'] = [],
+        ));
+    }
+
+    public function update(UpdateSiteConfigRequest $request, $site)
+    {
+        abort_unless($site, 404);
+
+        $this->configurationRepository->updateSiteConfiguration(
+            $site->handle(),
+            new SiteConfig(
+                $site->handle(),
+                '',
+                request('ignored_phrases') ?? [],
+                (int) request('keyword_threshold'),
+                (int) request('min_internal_links'),
+                (int) request('max_internal_links'),
+                (int) request('min_external_links'),
+                (int) request('max_external_links'),
+                request('prevent_circular_links'),
+            )
+        );
+    }
+
+    public function resetConfig($site)
+    {
+        abort_unless($site, 404);
+
+        $this->configurationRepository->resetSiteConfiguration($site);
+    }
+}
diff --git a/src/Http/Requests/AutomaticLinkRequest.php b/src/Http/Requests/AutomaticLinkRequest.php
new file mode 100644
index 00000000..69d9c0c3
--- /dev/null
+++ b/src/Http/Requests/AutomaticLinkRequest.php
@@ -0,0 +1,18 @@
+ 'required',
+            'is_active' => 'boolean',
+            'link_text' => 'required',
+            'link_target' => 'required',
+        ];
+    }
+}
diff --git a/src/Http/Requests/IgnoreSuggestionRequest.php b/src/Http/Requests/IgnoreSuggestionRequest.php
new file mode 100644
index 00000000..cb671d03
--- /dev/null
+++ b/src/Http/Requests/IgnoreSuggestionRequest.php
@@ -0,0 +1,27 @@
+ [
+                'required',
+                Rule::in(['ignore_entry', 'ignore_phrase']),
+            ],
+            'scope' => [
+                'required',
+                Rule::in(['entry', 'all_entries']),
+            ],
+            'phrase' => 'required_if:action,ignore_phrase',
+            'entry' => 'required_if:scope,entry',
+            'ignored_entry' => 'required_if:action,ignore_entry',
+            'site' => 'required',
+        ];
+    }
+}
diff --git a/src/Http/Requests/InsertLinkRequest.php b/src/Http/Requests/InsertLinkRequest.php
new file mode 100644
index 00000000..8aa1f2e4
--- /dev/null
+++ b/src/Http/Requests/InsertLinkRequest.php
@@ -0,0 +1,19 @@
+ 'required',
+            'phrase' => 'required',
+            'target' => 'required',
+            'field' => 'required',
+            'auto_link' => 'boolean',
+        ];
+    }
+}
diff --git a/src/Http/Requests/UpdateCollectionBehaviorRequest.php b/src/Http/Requests/UpdateCollectionBehaviorRequest.php
new file mode 100644
index 00000000..e86d9696
--- /dev/null
+++ b/src/Http/Requests/UpdateCollectionBehaviorRequest.php
@@ -0,0 +1,17 @@
+ 'required|boolean',
+            'allow_cross_collection_suggestions' => 'required|boolean',
+            'allowed_collections' => 'array',
+        ];
+    }
+}
diff --git a/src/Http/Requests/UpdateEntryLinkRequest.php b/src/Http/Requests/UpdateEntryLinkRequest.php
new file mode 100644
index 00000000..5625f633
--- /dev/null
+++ b/src/Http/Requests/UpdateEntryLinkRequest.php
@@ -0,0 +1,16 @@
+ 'required|boolean',
+            'include_in_reporting' => 'required|boolean',
+        ];
+    }
+}
diff --git a/src/Http/Requests/UpdateSiteConfigRequest.php b/src/Http/Requests/UpdateSiteConfigRequest.php
new file mode 100644
index 00000000..e9f174a0
--- /dev/null
+++ b/src/Http/Requests/UpdateSiteConfigRequest.php
@@ -0,0 +1,21 @@
+ 'array',
+            'keyword_threshold' => 'required|int',
+            'min_internal_links' => 'required|int',
+            'max_internal_links' => 'required|int',
+            'min_external_links' => 'required|int',
+            'max_external_links' => 'required|int',
+            'prevent_circular_links' => 'required|boolean',
+        ];
+    }
+}
diff --git a/src/Http/Resources/BaseResourceCollection.php b/src/Http/Resources/BaseResourceCollection.php
new file mode 100644
index 00000000..d8af2cb7
--- /dev/null
+++ b/src/Http/Resources/BaseResourceCollection.php
@@ -0,0 +1,56 @@
+columnPreferenceKey = $key;
+
+        return $this;
+    }
+
+    protected function makeColumn(string $field, string $label, bool $visible = true): Column
+    {
+        return Column::make($field)
+            ->listable(true)
+            ->label($label)
+            ->visible($visible)
+            ->defaultVisibility(true)
+            ->defaultOrder($this->columns->count() + 1)
+            ->sortable(true);
+    }
+
+    protected function addColumn(string $field, string $label, bool $visible = true): static
+    {
+        $this->columns->put($field, $this->makeColumn($field, $label, $visible));
+
+        return $this;
+    }
+
+    abstract protected function setColumns(): void;
+
+    public function toArray($request)
+    {
+        $this->setColumns();
+
+        return $this->collection;
+    }
+
+    public function with(Request $request)
+    {
+        return [
+            'meta' => [
+                'columns' => $this->visibleColumns(),
+            ],
+        ];
+    }
+}
diff --git a/src/Http/Resources/Links/AutomaticLinks.php b/src/Http/Resources/Links/AutomaticLinks.php
new file mode 100644
index 00000000..8c2275f0
--- /dev/null
+++ b/src/Http/Resources/Links/AutomaticLinks.php
@@ -0,0 +1,30 @@
+columns = new Columns;
+
+        $this->addColumn('link_text', 'Link Text')
+            ->addColumn('link_target', 'Link Target')
+            ->addColumn('is_active', 'Active')
+            ->addColumn('site', 'Site');
+
+        if ($this->columnPreferenceKey) {
+            $this->columns->setPreferred($this->columnPreferenceKey);
+        }
+
+        $this->columns = $this->columns->rejectUnlisted()->values();
+    }
+}
diff --git a/src/Http/Resources/Links/EntryLinks.php b/src/Http/Resources/Links/EntryLinks.php
new file mode 100644
index 00000000..ccb6416f
--- /dev/null
+++ b/src/Http/Resources/Links/EntryLinks.php
@@ -0,0 +1,34 @@
+columns = new Columns;
+
+        $this->addColumn('title', 'Title')
+            ->addColumn('uri', 'URI')
+            ->addColumn('site', 'Site', Site::hasMultiple())
+            ->addColumn('collection', 'Collection')
+            ->addColumn('internal_link_count', 'Internal Link Count')
+            ->addColumn('external_link_count', 'External Link Count')
+            ->addColumn('inbound_internal_link_count', 'Inbound Internal Link Count');
+
+        if ($this->columnPreferenceKey) {
+            $this->columns->setPreferred($this->columnPreferenceKey);
+        }
+
+        $this->columns = $this->columns->rejectUnlisted()->values();
+    }
+}
diff --git a/src/Http/Resources/Links/ListedAutomaticLink.php b/src/Http/Resources/Links/ListedAutomaticLink.php
new file mode 100644
index 00000000..52a5acae
--- /dev/null
+++ b/src/Http/Resources/Links/ListedAutomaticLink.php
@@ -0,0 +1,33 @@
+columns = $columns;
+
+        return $this;
+    }
+
+    public function toArray($request)
+    {
+        /** @var AutomaticLink $link */
+        $link = $this->resource;
+
+        return [
+            'id' => $link->id,
+            'site' => $link->site,
+            'is_active' => $link->is_active,
+            'link_text' => $link->link_text,
+            'link_target' => $link->link_target,
+            'entry_id' => $link->entry_id,
+        ];
+    }
+}
diff --git a/src/Http/Resources/Links/ListedEntryLink.php b/src/Http/Resources/Links/ListedEntryLink.php
new file mode 100644
index 00000000..f0d155e2
--- /dev/null
+++ b/src/Http/Resources/Links/ListedEntryLink.php
@@ -0,0 +1,34 @@
+columns = $columns;
+
+        return $this;
+    }
+
+    public function toArray($request)
+    {
+        $link = $this->resource;
+
+        return [
+            'id' => $link->id,
+            'entry_id' => $link->entry_id,
+            'title' => $link->cached_title,
+            'uri' => $link->cached_uri,
+            'site' => $link->site,
+            'collection' => $link->collection,
+            'internal_link_count' => $link->internal_link_count,
+            'external_link_count' => $link->external_link_count,
+            'inbound_internal_link_count' => $link->inbound_internal_link_count,
+        ];
+    }
+}
diff --git a/src/Jobs/CleanupCollectionLinks.php b/src/Jobs/CleanupCollectionLinks.php
new file mode 100644
index 00000000..24438fd3
--- /dev/null
+++ b/src/Jobs/CleanupCollectionLinks.php
@@ -0,0 +1,36 @@
+handle) {
+            return;
+        }
+
+        $configurationRepository->deleteCollectionConfiguration($this->handle);
+        $linksRepository->deleteLinksForCollection($this->handle);
+        $keywordsRepository->deleteKeywordsForCollection($this->handle);
+        $entryEmbeddingsRepository->deleteEmbeddingsForCollection($this->handle);
+    }
+}
diff --git a/src/Jobs/CleanupEntryLinks.php b/src/Jobs/CleanupEntryLinks.php
new file mode 100644
index 00000000..598ab4c1
--- /dev/null
+++ b/src/Jobs/CleanupEntryLinks.php
@@ -0,0 +1,29 @@
+deleteLinksForEntry($this->entryId);
+        $keywordsRepository->deleteKeywordsForEntry($this->entryId);
+        $entryEmbeddingsRepository->deleteEmbeddingsForEntry($this->entryId);
+    }
+}
diff --git a/src/Jobs/CleanupSiteLinks.php b/src/Jobs/CleanupSiteLinks.php
new file mode 100644
index 00000000..f503a6b2
--- /dev/null
+++ b/src/Jobs/CleanupSiteLinks.php
@@ -0,0 +1,32 @@
+deleteSiteConfiguration($this->handle);
+        $linksRepository->deleteLinksForSite($this->handle);
+        $keywordsRepository->deleteKeywordsForSite($this->handle);
+        $entryEmbeddingsRepository->deleteEmbeddingsForSite($this->handle);
+    }
+}
diff --git a/src/Jobs/Concerns/DispatchesSeoProJobs.php b/src/Jobs/Concerns/DispatchesSeoProJobs.php
new file mode 100644
index 00000000..906ca114
--- /dev/null
+++ b/src/Jobs/Concerns/DispatchesSeoProJobs.php
@@ -0,0 +1,28 @@
+onConnection($connection)
+            ->onQueue($queue ?? 'default');
+    }
+}
diff --git a/src/Jobs/ScanEntryLinks.php b/src/Jobs/ScanEntryLinks.php
new file mode 100644
index 00000000..6f1d09d4
--- /dev/null
+++ b/src/Jobs/ScanEntryLinks.php
@@ -0,0 +1,45 @@
+entryId);
+
+        if (! $entry) {
+            return;
+        }
+
+        $linkCrawler->scanEntry($entry, new LinkScanOptions(
+            withInternalChangeSets: true
+        ));
+
+        $linkCrawler->updateInboundInternalLinkCount($entry);
+
+        if ($linksRepository->isLinkingEnabledForEntry($entry)) {
+            $keywordsRepository->generateKeywordsForEntry($entry);
+            $entryEmbeddingsRepository->generateEmbeddingsForEntry($entry);
+        }
+    }
+}
diff --git a/src/Listeners/CollectionDeletedListener.php b/src/Listeners/CollectionDeletedListener.php
new file mode 100644
index 00000000..6a99b3db
--- /dev/null
+++ b/src/Listeners/CollectionDeletedListener.php
@@ -0,0 +1,14 @@
+collection->handle());
+    }
+}
diff --git a/src/Listeners/EntryDeletedListener.php b/src/Listeners/EntryDeletedListener.php
new file mode 100644
index 00000000..195b8c57
--- /dev/null
+++ b/src/Listeners/EntryDeletedListener.php
@@ -0,0 +1,14 @@
+entry->id());
+    }
+}
diff --git a/src/Listeners/EntrySavedListener.php b/src/Listeners/EntrySavedListener.php
new file mode 100644
index 00000000..d1adeea5
--- /dev/null
+++ b/src/Listeners/EntrySavedListener.php
@@ -0,0 +1,14 @@
+entry->id());
+    }
+}
diff --git a/src/Listeners/InternalLinksUpdatedListener.php b/src/Listeners/InternalLinksUpdatedListener.php
new file mode 100644
index 00000000..de5dfa07
--- /dev/null
+++ b/src/Listeners/InternalLinksUpdatedListener.php
@@ -0,0 +1,19 @@
+changeSet->entries()->each(fn (Entry $entry) => $this->crawler->updateLinkStatistics($entry));
+    }
+}
diff --git a/src/Listeners/SiteDeletedListener.php b/src/Listeners/SiteDeletedListener.php
new file mode 100644
index 00000000..79e1571b
--- /dev/null
+++ b/src/Listeners/SiteDeletedListener.php
@@ -0,0 +1,14 @@
+site->handle());
+    }
+}
diff --git a/src/Query/Scopes/Filters/Collection.php b/src/Query/Scopes/Filters/Collection.php
new file mode 100644
index 00000000..352ed09a
--- /dev/null
+++ b/src/Query/Scopes/Filters/Collection.php
@@ -0,0 +1,13 @@
+availableSites()->count() > 0;
+    }
+}
diff --git a/src/Reporting/Linking/BaseLinkReport.php b/src/Reporting/Linking/BaseLinkReport.php
new file mode 100644
index 00000000..11fb4d53
--- /dev/null
+++ b/src/Reporting/Linking/BaseLinkReport.php
@@ -0,0 +1,114 @@
+fluentlyGetOrSet('internalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function externalLinkCount(?int $externalLinkCount = null)
+    {
+        return $this->fluentlyGetOrSet('externalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function inboundInternalLinkCount(?int $linkCount = null)
+    {
+        return $this->fluentlyGetOrSet('inboundInternalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function minInternalLinkCount(?int $count = null)
+    {
+        return $this->fluentlyGetOrSet('minInternalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function maxInternalLinkCount(?int $count = null)
+    {
+        return $this->fluentlyGetOrSet('maxInternalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function minExternalLinkCount(?int $count = null)
+    {
+        return $this->fluentlyGetOrSet('minExternalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function maxExternalLinkCount(?int $count = null)
+    {
+        return $this->fluentlyGetOrSet('maxExternalLinkCount')
+            ->args(func_get_args());
+    }
+
+    public function entry(?Entry $entry = null)
+    {
+        return $this->fluentlyGetOrSet('entry')
+            ->args(func_get_args());
+    }
+
+    protected function extraData(): array
+    {
+        return [];
+    }
+
+    protected function dumpEntry(?Entry $entry): ?array
+    {
+        if (! $entry) {
+            return [];
+        }
+
+        return [
+            'title' => $entry->title,
+            'url' => $entry->absoluteUrl(),
+            'edit_url' => $entry->editUrl(),
+            'uri' => $entry->uri,
+            'id' => $entry->id(),
+            'site'=> $entry->site()?->handle() ?? 'default',
+        ];
+    }
+
+    public function toArray(): array
+    {
+        return array_merge([
+            'entry' => $this->dumpEntry($this->entry),
+            'overview' => [
+                'internal_link_count' => $this->internalLinkCount,
+                'external_link_count' => $this->externalLinkCount,
+                'inbound_internal_link_count' => $this->inboundInternalLinkCount,
+            ],
+            'preferences' => [
+                'min_internal_link_count' => $this->minInternalLinkCount,
+                'max_internal_link_count' => $this->maxInternalLinkCount,
+                'min_external_link_count' => $this->minExternalLinkCount,
+                'max_external_link_count' => $this->maxExternalLinkCount,
+            ],
+        ], $this->extraData());
+    }
+
+    public function toJson($options = 0)
+    {
+        return json_encode($this->toArray());
+    }
+}
diff --git a/src/Reporting/Linking/Concerns/ResolvesSimilarItems.php b/src/Reporting/Linking/Concerns/ResolvesSimilarItems.php
new file mode 100644
index 00000000..301446be
--- /dev/null
+++ b/src/Reporting/Linking/Concerns/ResolvesSimilarItems.php
@@ -0,0 +1,139 @@
+ [],
+                'uri' => [],
+                'content' => [],
+            ];
+        }
+
+        $metaKeywords = $keywords->meta_keywords ?? [];
+
+        return [
+            'title' => $this->filterKeywords($metaKeywords['title'] ?? [], $ignoredKeywords),
+            'uri' => $this->filterKeywords($metaKeywords['uri'] ?? [], $ignoredKeywords),
+            'content' => $this->filterKeywords($keywords->content_keywords ?? [], $ignoredKeywords),
+        ];
+    }
+
+    protected function findSimilarTo(Entry $entry, int $limit, ?ResolverOptions $options = null): Collection
+    {
+        $options = $this->getOptions($options);
+
+        $entryId = $entry->id();
+        $targetVectors = $this->embeddingsRepository->getEmbeddingsForEntry($entry)?->vector() ?? [];
+
+        $tmpMapping = [];
+
+        /** @var Vector $vector */
+        foreach ($this->embeddingsRepository->getRelatedEmbeddingsForEntryLazy($entry, $options) as $vector) {
+            if ($vector->id() === $entryId) {
+                continue;
+            }
+
+            $score = CosineSimilarity::calculate($targetVectors, $vector->vector());
+
+            if ($score <= 0) {
+                continue;
+            }
+
+            $tmpMapping[$vector->id()] = $score;
+        }
+
+        arsort($tmpMapping);
+        $tmpMapping = array_slice($tmpMapping, 0, $limit, true);
+
+        /** @var Result[] $results */
+        $results = [];
+
+        $entries = EntryApi::query()
+            ->whereIn('id', array_keys($tmpMapping))
+            ->get()
+            ->keyBy('id')
+            ->all();
+
+        foreach ($tmpMapping as $id => $score) {
+            if (! array_key_exists($id, $entries)) {
+                continue;
+            }
+
+            $result = new Result;
+
+            $result->entry($entries[$id]);
+            $result->score($score);
+
+            $results[$id] = $result;
+        }
+
+        unset($entries, $tmpMapping);
+
+        return collect(array_values($results));
+    }
+
+    protected function addKeywordsToResults(Entry $entry, Collection $results, ?ResolverOptions $options = null): Collection
+    {
+        $options = $this->getOptions($options);
+
+        $entryIds = array_merge([$entry->id()], $results->map(fn (Result $result) => $result->entry()->id())->all());
+        $ignoredKeywords = array_flip($this->keywordsRepository->getIgnoredKeywordsForEntry($entry));
+        $keywords = $this->keywordsRepository->getKeywordsForEntries($entryIds);
+        $primaryKeywords = $keywords[$entry->id()] ?? null;
+
+        if (! $primaryKeywords) {
+            return collect();
+        }
+
+        $primaryKeywords = $this->convertEntryKeywords($primaryKeywords, $ignoredKeywords);
+
+        $results->each(function (Result $result) use (&$keywords, &$ignoredKeywords) {
+            $result->keywords($this->convertEntryKeywords(
+                $keywords[$result->entry()->id()] ?? null,
+                $ignoredKeywords
+            ));
+        });
+
+        return collect((new KeywordComparator)->compare($primaryKeywords)->to($results->all()))
+            ->each(function (Result $result) use ($options) {
+                // Reset the keywords.
+                $result->similarKeywords(
+                    collect($result->similarKeywords())->sortByDesc('score')->mapWithKeys(function ($item) {
+                        return [$item['keyword'] => $item['score']];
+                    })->take($options->keywordLimit)->all()
+                );
+            })
+            ->where(fn (Result $result) => $result->keywordScore() > $options->keywordThreshold)
+            ->sortByDesc(fn (Result $result) => $result->keywordScore())
+            ->values();
+    }
+}
diff --git a/src/Reporting/Linking/ExternalLinksReport.php b/src/Reporting/Linking/ExternalLinksReport.php
new file mode 100644
index 00000000..7c617ec4
--- /dev/null
+++ b/src/Reporting/Linking/ExternalLinksReport.php
@@ -0,0 +1,30 @@
+fluentlyGetOrSet('externalLinks')
+            ->args(func_get_args());
+    }
+
+    public function getLinks(): array
+    {
+        return collect($this->externalLinks)->map(function ($link) {
+            return [
+                'link' => $link,
+            ];
+        })->all();
+    }
+
+    protected function extraData(): array
+    {
+        return [
+            'links' => $this->getLinks(),
+        ];
+    }
+}
diff --git a/src/Reporting/Linking/InternalLinksReport.php b/src/Reporting/Linking/InternalLinksReport.php
new file mode 100644
index 00000000..f801d79f
--- /dev/null
+++ b/src/Reporting/Linking/InternalLinksReport.php
@@ -0,0 +1,31 @@
+fluentlyGetOrSet('internalLinks')
+            ->args(func_get_args());
+    }
+
+    public function getLinks(): array
+    {
+        return collect($this->internalLinks)->map(function ($link) {
+            return [
+                'entry' => $this->dumpEntry($link['entry']),
+                'uri' => $link['uri'],
+            ];
+        })->all();
+    }
+
+    protected function extraData(): array
+    {
+        return [
+            'links' => $this->getLinks(),
+        ];
+    }
+}
diff --git a/src/Reporting/Linking/RelatedContentReport.php b/src/Reporting/Linking/RelatedContentReport.php
new file mode 100644
index 00000000..485aae6d
--- /dev/null
+++ b/src/Reporting/Linking/RelatedContentReport.php
@@ -0,0 +1,35 @@
+fluentlyGetOrSet('relatedContent')
+            ->args(func_get_args());
+    }
+
+    public function getRelated(bool $returnFullEntry = false): array
+    {
+        return collect($this->relatedContent)->map(function (Result $result) use ($returnFullEntry) {
+            return [
+                'entry' => $returnFullEntry ? $result->entry() : $this->dumpEntry($result->entry()),
+                'score' => $result->score(),
+                'keyword_score' => $result->keywordScore(),
+                'related_keywords' => implode(', ', array_keys($result->similarKeywords())),
+            ];
+        })->all();
+    }
+
+    protected function extraData(): array
+    {
+        return [
+            'related_content' => $this->getRelated(),
+        ];
+    }
+}
diff --git a/src/Reporting/Linking/ReportBuilder.php b/src/Reporting/Linking/ReportBuilder.php
new file mode 100644
index 00000000..88b45a20
--- /dev/null
+++ b/src/Reporting/Linking/ReportBuilder.php
@@ -0,0 +1,232 @@
+where('entry_id', $entry->id())->first();
+
+        $this->lastLinks = $overviewData;
+
+        $report->internalLinkCount($overviewData?->internal_link_count ?? 0);
+        $report->externalLinkCount($overviewData?->external_link_count ?? 0);
+        $report->inboundInternalLinkCount($overviewData?->inbound_internal_link_count ?? 0);
+
+        $siteConfig = $this->configurationRepository->getSiteConfiguration($entry->site()?->handle() ?? 'default');
+
+        $report->minInternalLinkCount($siteConfig->minInternalLinks);
+        $report->maxInternalLinkCount($siteConfig->maxInternalLinks);
+        $report->minExternalLinkCount($siteConfig->minExternalLinks);
+        $report->maxExternalLinkCount($siteConfig->maxExternalLinks);
+
+        $report->entry($entry);
+    }
+
+    public function getBaseReport(Entry $entry): BaseLinkReport
+    {
+        $baseReport = new BaseLinkReport;
+
+        $this->fillBaseReportData($entry, $baseReport);
+
+        return $baseReport;
+    }
+
+    protected function getResolvedSimilarItems(Entry $entry, int $resultLimit, int $relatedEntryLimit, ?ResolverOptions $options = null): Collection
+    {
+        return $this->addKeywordsToResults(
+            $entry,
+            $this->findSimilarTo($entry, $relatedEntryLimit, $options),
+            $options
+        )->take($resultLimit);
+    }
+
+    public function getSuggestionsReport(Entry $entry, int $resultLimit = 10, int $similarEntryLimit = 20): SuggestionsReport
+    {
+        $report = new SuggestionsReport;
+
+        $siteConfig = $this->configurationRepository->getSiteConfiguration($entry->site()?->handle() ?? 'default');
+
+        $resolverOptions = new ResolverOptions(
+            keywordThreshold: $siteConfig->keywordThreshold / 100,
+            preventCircularLinks: $siteConfig->preventCircularLinks,
+        );
+
+        $suggestions = $this->suggestionEngine
+            ->withResults($this->getResolvedSimilarItems($entry, $similarEntryLimit, $resultLimit, $resolverOptions))
+            ->suggest($entry);
+
+        $this->fillBaseReportData($entry, $report);
+
+        $report->suggestions($suggestions->all());
+
+        return $report;
+    }
+
+    public function getRelatedContentReport(Entry $entry, int $relatedEntryLimit = 20): RelatedContentReport
+    {
+        $report = new RelatedContentReport;
+
+        $report->relatedContent(
+            $this->getResolvedSimilarItems(
+                $entry,
+                $relatedEntryLimit,
+                $relatedEntryLimit,
+                new ResolverOptions(keywordThreshold: -1)
+            )->all()
+        );
+
+        $this->fillBaseReportData($entry, $report);
+
+        return $report;
+    }
+
+    public function getExternalLinks(Entry $entry): ExternalLinksReport
+    {
+        $report = new ExternalLinksReport;
+        $this->fillBaseReportData($entry, $report);
+
+        if (! $this->lastLinks) {
+            return $report;
+        }
+
+        $report->externalLinks($this->lastLinks->external_links);
+
+        return $report;
+    }
+
+    public function getInboundInternalLinks(Entry $entry): InternalLinksReport
+    {
+        $report = new InternalLinksReport;
+        $this->fillBaseReportData($entry, $report);
+
+        if (! $this->lastLinks) {
+            return $report;
+        }
+
+        $targetUri = $entry->uri;
+        /** @var EntryLink[] $entryLinks */
+        $entryLinks = EntryLink::query()->whereJsonContains('internal_links', $targetUri)->get();
+        $matches = [];
+        $lookupIds = [];
+
+        foreach ($entryLinks as $link) {
+            if (str_starts_with($link, '#')) {
+                continue;
+            }
+
+            if ($link->id === $this->lastLinks->id) {
+                continue;
+            }
+
+            if (in_array($targetUri, $link->internal_links)) {
+                $lookupIds[$link->entry_id] = 1;
+
+                $matches[] = [
+                    'entry_id' => $link->entry_id,
+                    'uri' => $targetUri,
+                ];
+            }
+        }
+
+        if (! $matches) {
+            return $report;
+        }
+
+        $entries = EntryApi::query()
+            ->whereIn('id', array_keys($lookupIds))
+            ->get()
+            ->keyBy('id')
+            ->all();
+
+        $results = [];
+
+        foreach ($matches as $match) {
+            $results[] = [
+                'entry' => $entries[$match['entry_id']] ?? null,
+                'uri' => $match['uri'],
+            ];
+        }
+
+        $report->internalLinks($results);
+
+        return $report;
+    }
+
+    public function getInternalLinks(Entry $entry): InternalLinksReport
+    {
+        $report = new InternalLinksReport;
+        $this->fillBaseReportData($entry, $report);
+
+        if (! $this->lastLinks) {
+            return $report;
+        }
+
+        $toLookup = [];
+        $uris = [];
+
+        foreach ($this->lastLinks->internal_links as $link) {
+            if (str_starts_with($link, '#')) {
+                continue;
+            }
+
+            $uri = Str::before($link, '#');
+
+            if (str_ends_with($uri, '/')) {
+                $uri = mb_substr($uri, 0, mb_strlen($uri) - 1);
+            }
+
+            $toLookup[] = [
+                'original' => $link,
+                'uri' => $uri,
+            ];
+
+            $uris[] = $uri;
+        }
+
+        $entries = EntryApi::query()
+            ->whereIn('uri', $uris)
+            ->get()
+            ->keyBy('uri')
+            ->all();
+
+        $report->internalLinks(
+            collect($toLookup)->map(function ($link) use ($entries) {
+                return [
+                    'entry' => $entries[$link['uri']] ?? null,
+                    'uri' => $link['original'],
+                ];
+            })->all()
+        );
+
+        return $report;
+    }
+}
diff --git a/src/Reporting/Linking/SuggestionsReport.php b/src/Reporting/Linking/SuggestionsReport.php
new file mode 100644
index 00000000..2b6d019e
--- /dev/null
+++ b/src/Reporting/Linking/SuggestionsReport.php
@@ -0,0 +1,21 @@
+fluentlyGetOrSet('suggestions')
+            ->args(func_get_args());
+    }
+
+    protected function extraData(): array
+    {
+        return [
+            'suggestions' => $this->suggestions,
+        ];
+    }
+}
diff --git a/src/SeoPro.php b/src/SeoPro.php
new file mode 100644
index 00000000..63a2a870
--- /dev/null
+++ b/src/SeoPro.php
@@ -0,0 +1,24 @@
+ __DIR__.'/../routes/web.php',
     ];
 
+    protected $scopes = [
+        Query\Scopes\Filters\Collection::class,
+        Query\Scopes\Filters\Site::class,
+        Query\Scopes\Filters\Fields::class,
+    ];
+
     protected $config = false;
 
     public function bootAddon()
     {
         $this
             ->bootAddonConfig()
+            ->bootAddonMigrations()
             ->bootAddonViews()
             ->bootAddonBladeDirective()
             ->bootAddonPermissions()
@@ -55,7 +66,144 @@ public function bootAddon()
             ->bootAddonSubscriber()
             ->bootAddonGlidePresets()
             ->bootAddonCommands()
-            ->bootAddonGraphQL();
+            ->bootAddonGraphQL()
+            ->bootTextAnalysis()
+            ->bootEvents();
+    }
+
+    protected function isLinkingEnabled(): bool
+    {
+        return config('statamic.seo-pro.linking.enabled', false);
+    }
+
+    public function bootEvents()
+    {
+        if ($this->isLinkingEnabled()) {
+            $this->listen = array_merge($this->listen, [
+                EntrySaved::class => [
+                    SeoPro\Listeners\EntrySavedListener::class,
+                ],
+                EntryDeleted::class => [
+                    SeoPro\Listeners\EntryDeletedListener::class,
+                ],
+                SiteDeleted::class => [
+                    SeoPro\Listeners\SiteDeletedListener::class,
+                ],
+                CollectionDeleted::class => [
+                    SeoPro\Listeners\CollectionDeletedListener::class,
+                ],
+                SeoPro\Events\InternalLinksUpdated::class => [
+                    SeoPro\Listeners\InternalLinksUpdatedListener::class,
+                ],
+            ]);
+        }
+
+        return parent::bootEvents();
+    }
+
+    protected function bootTextAnalysis()
+    {
+        if (! $this->isLinkingEnabled()) {
+            return $this;
+        }
+
+        SeoPro\Actions\ViewLinkSuggestions::register();
+
+        $this->app->bind(
+            Contracts\Content\ContentRetriever::class,
+            config('statamic.seo-pro.linking.drivers.content'),
+        );
+
+        $this->app->bind(
+            Contracts\Content\Tokenizer::class,
+            config('statamic.seo-pro.linking.drivers.tokenizer'),
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Embeddings\Extractor::class,
+            config('statamic.seo-pro.linking.drivers.embeddings'),
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Keywords\KeywordRetriever::class,
+            config('statamic.seo-pro.linking.drivers.keywords'),
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\ConfigurationRepository::class,
+            TextProcessing\Config\ConfigurationRepository::class,
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Keywords\KeywordsRepository::class,
+            TextProcessing\Keywords\KeywordsRepository::class,
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Links\LinkCrawler::class,
+            config('statamic.seo-pro.linking.drivers.link_scanner'),
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Embeddings\EntryEmbeddingsRepository::class,
+            TextProcessing\Embeddings\EmbeddingsRepository::class,
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Links\GlobalAutomaticLinksRepository::class,
+            TextProcessing\Links\GlobalAutomaticLinksRepository::class,
+        );
+
+        $this->app->bind(
+            Contracts\TextProcessing\Links\LinksRepository::class,
+            TextProcessing\Links\LinkRepository::class,
+        );
+
+        $this->app->singleton(Content\ContentMapper::class, function () {
+            return new Content\ContentMapper;
+        });
+
+        $this->app->singleton(Content\LinkReplacer::class, function () {
+            return new Content\LinkReplacer(
+                app(Content\ContentMapper::class),
+            );
+        });
+
+        return $this->registerDefaultFieldtypeReplacers()
+            ->registerDefaultContentMappers();
+    }
+
+    protected function registerDefaultFieldtypeReplacers(): static
+    {
+        /** @var \Statamic\SeoPro\Content\LinkReplacer $linkReplacer */
+        $linkReplacer = $this->app->make(Content\LinkReplacer::class);
+
+        $linkReplacer->registerReplacers([
+            Content\LinkReplacers\MarkdownReplacer::class,
+            Content\LinkReplacers\TextReplacer::class,
+            Content\LinkReplacers\TextareaReplacer::class,
+            Content\LinkReplacers\Bard\BardReplacer::class,
+        ]);
+
+        return $this;
+    }
+
+    protected function registerDefaultContentMappers(): static
+    {
+        /** @var \Statamic\SeoPro\Content\ContentMapper $contentMapper */
+        $contentMapper = $this->app->make(Content\ContentMapper::class);
+
+        $contentMapper->registerMappers([
+            Content\Mappers\TextFieldMapper::class,
+            Content\Mappers\TextareaFieldMapper::class,
+            Content\Mappers\MarkdownFieldMapper::class,
+            Content\Mappers\GridFieldMapper::class,
+            Content\Mappers\ReplicatorFieldMapper::class,
+            Content\Mappers\BardFieldMapper::class,
+            Content\Mappers\GroupFieldMapper::class,
+        ]);
+
+        return $this;
     }
 
     protected function bootAddonConfig()
@@ -69,12 +217,28 @@ protected function bootAddonConfig()
         return $this;
     }
 
+    protected function bootAddonMigrations()
+    {
+        $this->publishes([
+            __DIR__.'/../database/migrations/2024_07_26_184745_create_seopro_entry_embeddings_table.php' => database_path('migrations/2024_07_26_184745_create_seopro_entry_embeddings_table.php'),
+            __DIR__.'/../database/migrations/2024_08_10_154109_create_seopro_entry_links_table.php' => database_path('migrations/2024_08_10_154109_create_seopro_entry_links_table.php'),
+            __DIR__.'/../database/migrations/2024_08_17_123712_create_seopro_entry_keywords_table.php' => database_path('migrations/2024_08_17_123712_create_seopro_entry_keywords_table.php'),
+            __DIR__.'/../database/migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php' => database_path('migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php'),
+            __DIR__.'/../database/migrations/2024_09_02_135056_create_seopro_global_automatic_links_table.php' => database_path('migrations/2024_09_02_135056_create_seopro_global_automatic_links_table.php'),
+            __DIR__.'/../database/migrations/2024_09_03_102233_create_seopro_collection_link_settings_table.php' => database_path('migrations/2024_09_03_102233_create_seopro_collection_link_settings_table.php'),
+        ], 'seo-pro-migrations');
+
+        return $this;
+    }
+
     protected function bootAddonViews()
     {
         $this->loadViewsFrom(__DIR__.'/../resources/views/generated', 'seo-pro');
+        $this->loadViewsFrom(__DIR__.'/../resources/views/links', 'seo-pro');
 
         $this->publishes([
             __DIR__.'/../resources/views/generated' => resource_path('views/vendor/seo-pro'),
+            __DIR__.'/../resources/views/links' => resource_path('views/vendor/seo-pro/links'),
         ], 'seo-pro-views');
 
         return $this;
@@ -106,17 +270,24 @@ protected function bootAddonPermissions()
 
     protected function bootAddonNav()
     {
+
         Nav::extend(function ($nav) {
             if ($this->userHasSeoPermissions()) {
                 $nav->tools('SEO Pro')
                     ->route('seo-pro.index')
                     ->icon('seo-search-graph')
                     ->children(function () use ($nav) {
-                        return [
+                        $menuItems = [
                             $nav->item(__('seo-pro::messages.reports'))->route('seo-pro.reports.index')->can('view seo reports'),
                             $nav->item(__('seo-pro::messages.site_defaults'))->route('seo-pro.site-defaults.edit')->can('edit seo site defaults'),
                             $nav->item(__('seo-pro::messages.section_defaults'))->route('seo-pro.section-defaults.index')->can('edit seo section defaults'),
                         ];
+
+                        if ($this->isLinkingEnabled()) {
+                            $menuItems[] = $nav->item(__('seo-pro::messages.link_manager'))->route('seo-pro.internal-links.index')->can('view seo links');
+                        }
+
+                        return $menuItems;
                     });
             }
         });
@@ -155,6 +326,10 @@ protected function bootAddonCommands()
     {
         $this->commands([
             SeoPro\Commands\GenerateReportCommand::class,
+            SeoPro\Commands\GenerateEmbeddingsCommand::class,
+            SeoPro\Commands\GenerateKeywordsCommand::class,
+            SeoPro\Commands\ScanLinksCommand::class,
+            SeoPro\Commands\StartTheEnginesCommand::class,
         ]);
 
         return $this;
diff --git a/src/Tags/SeoProTags.php b/src/Tags/SeoProTags.php
index 0bad613e..36319153 100755
--- a/src/Tags/SeoProTags.php
+++ b/src/Tags/SeoProTags.php
@@ -2,10 +2,17 @@
 
 namespace Statamic\SeoPro\Tags;
 
+use Statamic\Contracts\Entries\Entry;
+use Statamic\Facades\Entry as EntryApi;
+use Statamic\Facades\Site;
 use Statamic\SeoPro\Cascade;
 use Statamic\SeoPro\GetsSectionDefaults;
 use Statamic\SeoPro\RendersMetaHtml;
+use Statamic\SeoPro\Reporting\Linking\ReportBuilder;
+use Statamic\SeoPro\SeoPro;
 use Statamic\SeoPro\SiteDefaults;
+use Statamic\SeoPro\TextProcessing\Links\AutomaticLinkManager;
+use Statamic\Structures\Page;
 use Statamic\Tags\Tags;
 
 class SeoProTags extends Tags
@@ -62,6 +69,70 @@ public function dumpMetaData()
         return dd($this->metaData());
     }
 
+    protected function getAutoLinkedContent(string $content)
+    {
+        return app(AutomaticLinkManager::class)
+            ->inject(
+                $content,
+                Site::current()?->handle() ?? 'default',
+            );
+    }
+
+    public function content()
+    {
+        if (! SeoPro::isSeoProProcess()) {
+            $content = $this->parse();
+
+            if ($this->params->get('auto_link', false)) {
+                return $this->getAutoLinkedContent($content);
+            }
+
+            return $content;
+        }
+
+        return ''.$this->parse().'';
+    }
+
+    protected function makeRelatedContentReport(Entry $entry)
+    {
+        $related = app(ReportBuilder::class)
+            ->getRelatedContentReport($entry, $this->params->get('limit', 10))
+            ->getRelated(true);
+
+        if ($as = $this->params->get('as')) {
+            return [
+                $as => $related,
+            ];
+        }
+
+        return $related;
+    }
+
+    public function relatedContent()
+    {
+        $id = $this->params->get('for', $this->context->get('page.id'));
+
+        if (! $id) {
+            return [];
+        }
+
+        if ($id instanceof Page) {
+            $id = $id->entry();
+        }
+
+        if ($id instanceof Entry) {
+            return $this->makeRelatedContentReport($id);
+        }
+
+        $entry = EntryApi::find($id);
+
+        if (! $entry) {
+            return [];
+        }
+
+        return $this->makeRelatedContentReport($entry);
+    }
+
     /**
      * Check if glide preset is enabled.
      *
diff --git a/src/TextProcessing/Concerns/ChecksForContentChanges.php b/src/TextProcessing/Concerns/ChecksForContentChanges.php
new file mode 100644
index 00000000..ef390cee
--- /dev/null
+++ b/src/TextProcessing/Concerns/ChecksForContentChanges.php
@@ -0,0 +1,18 @@
+exists) {
+            return false;
+        }
+
+        return $this->contentRetriever->hashContent($content) === $model->content_hash;
+    }
+}
diff --git a/src/TextProcessing/Config/CollectionConfig.php b/src/TextProcessing/Config/CollectionConfig.php
new file mode 100644
index 00000000..09fcfa8a
--- /dev/null
+++ b/src/TextProcessing/Config/CollectionConfig.php
@@ -0,0 +1,27 @@
+ $this->allowLinkingToAllCollections,
+            'allow_cross_site_linking' => $this->allowLinkingAcrossSites,
+            'allowed_collections' => $this->linkableCollections,
+            'linking_enabled' => $this->linkingEnabled,
+            'handle' => $this->handle,
+            'title' => $this->title,
+        ];
+    }
+}
diff --git a/src/TextProcessing/Config/ConfigurationRepository.php b/src/TextProcessing/Config/ConfigurationRepository.php
new file mode 100644
index 00000000..fc9a9d79
--- /dev/null
+++ b/src/TextProcessing/Config/ConfigurationRepository.php
@@ -0,0 +1,224 @@
+keyBy('collection')->all();
+
+        foreach ($allCollections as $collection) {
+            $handle = $collection->handle();
+            $title = $collection->title();
+
+            if (array_key_exists($handle, $settings)) {
+                $collections[] = $this->makeCollectionConfig($handle, $title, $settings[$handle]);
+            } else {
+                $collections[] = $this->makeDefaultCollectionConfig($handle, $title);
+            }
+        }
+
+        return collect($collections);
+    }
+
+    protected function makeDefaultCollectionConfig(string $handle, string $title): CollectionConfig
+    {
+        return new CollectionConfig(
+            $handle,
+            $title,
+            true,
+            false,
+            true,
+            [],
+        );
+    }
+
+    protected function makeCollectionConfig(string $handle, string $title, CollectionLinkSettings $settings): CollectionConfig
+    {
+        return new CollectionConfig(
+            $handle,
+            $title,
+            $settings->linking_enabled,
+            $settings->allow_linking_across_sites,
+            $settings->allow_linking_to_all_collections,
+            $settings->linkable_collections ?? [],
+        );
+    }
+
+    public function getCollectionConfiguration(string $handle): ?CollectionConfig
+    {
+        $config = CollectionLinkSettings::query()->where('collection', $handle)->first();
+
+        if ($config) {
+            // TODO: Hydrate title.
+
+            return $this->makeCollectionConfig($handle, '', $config);
+        }
+
+        return $this->makeDefaultCollectionConfig($handle, '');
+    }
+
+    public function updateCollectionConfiguration(string $handle, CollectionConfig $config): void
+    {
+        /** @var CollectionLinkSettings $collectionSettings */
+        $collectionSettings = CollectionLinkSettings::query()->firstOrNew(['collection' => $handle]);
+
+        $collectionSettings->linkable_collections = $config->linkableCollections;
+        $collectionSettings->allow_linking_to_all_collections = $config->allowLinkingToAllCollections;
+        $collectionSettings->allow_linking_across_sites = $config->allowLinkingAcrossSites;
+        $collectionSettings->linking_enabled = $config->linkingEnabled;
+
+        $collectionSettings->saveQuietly();
+    }
+
+    public function getSites(): Collection
+    {
+        $sites = [];
+        $allSites = SiteApi::all();
+        $settings = SiteLinkSetting::all()->keyBy('site')->all();
+
+        foreach ($allSites as $site) {
+            $handle = $site->handle();
+            $name = $site->name();
+
+            if (array_key_exists($handle, $settings)) {
+                $sites[] = $this->makeSiteConfig($handle, $name, $settings[$handle]);
+            } else {
+                $sites[] = $this->makeDefaultSiteConfig($handle, $name);
+            }
+        }
+
+        return collect($sites);
+    }
+
+    protected function makeSiteConfig(string $handle, string $name, SiteLinkSetting $config): SiteConfig
+    {
+        return new SiteConfig(
+            $handle,
+            $name,
+            $config->ignored_phrases,
+            $config->keyword_threshold * 100,
+            $config->min_internal_links,
+            $config->max_internal_links,
+            $config->min_external_links,
+            $config->max_external_links,
+            $config->prevent_circular_links,
+        );
+    }
+
+    protected function makeDefaultSiteConfig(string $handle, string $name): SiteConfig
+    {
+        return new SiteConfig(
+            $handle,
+            $name,
+            [],
+            config('statamic.seo-pro.linking.keyword_threshold', 65),
+            config('statamic.seo-pro.linking.internal_links.min_desired', 3),
+            config('statamic.seo-pro.linking.internal_links.max_desired', 6),
+            config('statamic.seo-pro.linking.external_links.min_desired', 0),
+            config('statamic.seo-pro.linking.external_links.max_desired', 0),
+            config('statamic.seo-pro.linking.prevent_circular_links', false)
+        );
+    }
+
+    public function getSiteConfiguration(string $handle): ?SiteConfig
+    {
+        $config = SiteLinkSetting::query()->where('site', $handle)->first();
+
+        if ($config) {
+            // TODO: Hydrate name.
+
+            return $this->makeSiteConfig($handle, '', $config);
+        }
+
+        return $this->makeDefaultSiteConfig($handle, '');
+    }
+
+    public function updateSiteConfiguration(string $handle, SiteConfig $config): void
+    {
+        /** @var SiteLinkSetting $siteSettings */
+        $siteSettings = SiteLinkSetting::query()->firstOrNew(['site' => $handle]);
+
+        $siteSettings->ignored_phrases = $config->ignoredPhrases;
+        $siteSettings->keyword_threshold = $config->keywordThreshold / 100;
+        $siteSettings->min_internal_links = $config->minInternalLinks;
+        $siteSettings->max_internal_links = $config->maxInternalLinks;
+        $siteSettings->min_external_links = $config->minExternalLinks;
+        $siteSettings->max_external_links = $config->maxExternalLinks;
+        $siteSettings->prevent_circular_links = $config->preventCircularLinks;
+
+        $siteSettings->saveQuietly();
+    }
+
+    public function getDisabledCollections(): array
+    {
+        $disabled = CollectionLinkSettings::query()->where('linking_enabled', false)
+            ->select('collection')
+            ->get()
+            ->pluck('collection')
+            ->all();
+
+        return array_merge(
+            config('statamic.seo-pro.text_analysis.disabled_collections', []),
+            $disabled
+        );
+    }
+
+    public function deleteSiteConfiguration(string $handle): void
+    {
+        SiteLinkSetting::query()->where('site', $handle)->delete();
+    }
+
+    public function deleteCollectionConfiguration(string $handle): void
+    {
+        CollectionLinkSettings::query()->where('collection', $handle)->delete();
+    }
+
+    public function resetSiteConfiguration(string $handle): void
+    {
+        /** @var SiteLinkSetting $settings */
+        $settings = SiteLinkSetting::query()->where('site', $handle)->first();
+
+        if (! $settings) {
+            return;
+        }
+
+        $settings->keyword_threshold = config('statamic.seo-pro.linking.keyword_threshold', 65) / 100;
+        $settings->min_internal_links = config('statamic.seo-pro.linking.internal_links.min_desired', 3);
+        $settings->max_internal_links = config('statamic.seo-pro.linking.internal_links.max_desired', 6);
+        $settings->min_external_links = config('statamic.seo-pro.linking.external_links.min_desired', 0);
+        $settings->max_external_links = config('statamic.seo-pro.linking.external_links.max_desired', 0);
+        $settings->prevent_circular_links = config('statamic.seo-pro.linking.prevent_circular_links', false);
+
+        $settings->ignored_phrases = [];
+
+        $settings->save();
+    }
+
+    public function resetCollectionConfiguration(string $handle): void
+    {
+        /** @var CollectionLinkSettings $settings */
+        $settings = CollectionLinkSettings::query()->where('collection', $handle)->first();
+
+        if (! $settings) {
+            return;
+        }
+
+        $settings->allow_linking_to_all_collections = true;
+        $settings->linking_enabled = true;
+        $settings->allow_linking_across_sites = false;
+        $settings->linkable_collections = [];
+
+        $settings->save();
+    }
+}
diff --git a/src/TextProcessing/Config/SiteConfig.php b/src/TextProcessing/Config/SiteConfig.php
new file mode 100644
index 00000000..5c7f3488
--- /dev/null
+++ b/src/TextProcessing/Config/SiteConfig.php
@@ -0,0 +1,33 @@
+ $this->handle,
+            'name' => $this->name,
+            'ignored_phrases' => $this->ignoredPhrases,
+            'keyword_threshold' => $this->keywordThreshold,
+            'min_internal_links' => $this->minInternalLinks,
+            'max_internal_links' => $this->maxInternalLinks,
+            'min_external_links' => $this->minExternalLinks,
+            'max_external_links' => $this->maxExternalLinks,
+            'prevent_circular_links' => $this->preventCircularLinks,
+        ];
+    }
+}
diff --git a/src/TextProcessing/Embeddings/EmbeddingsRepository.php b/src/TextProcessing/Embeddings/EmbeddingsRepository.php
new file mode 100644
index 00000000..e275e454
--- /dev/null
+++ b/src/TextProcessing/Embeddings/EmbeddingsRepository.php
@@ -0,0 +1,273 @@
+ */
+    protected array $embeddingInstanceCache = [];
+
+    protected string $configurationHash;
+
+    public function __construct(
+        protected readonly Extractor $embeddingsExtractor,
+        protected readonly ContentRetriever $contentRetriever,
+        protected readonly ConfigurationRepository $configurationRepository,
+    ) {
+        $this->configurationHash = self::getConfigurationHash();
+    }
+
+    protected function relatedEmbeddingsQuery(Entry $entry, ResolverOptions $options): Builder
+    {
+        $site = $entry->site()?->handle() ?? 'default';
+        $entryLink = EntryLink::query()->where('entry_id', $entry->id())->first();
+
+        $collection = $entry->collection()->handle();
+        $collectionConfig = $this->configurationRepository->getCollectionConfiguration($collection);
+
+        $disabledCollections = $this->configurationRepository->getDisabledCollections();
+
+        $ignoredEntries = $entryLink?->ignored_entries ?? [];
+        $ignoredEntries[] = $entry->id();
+
+        $query = EntryEmbedding::query()
+            ->whereHas('entryLink', function (Builder $query) use ($entry, $options) {
+                $query = $query->where('can_be_suggested', true);
+
+                if ($options->preventCircularLinks) {
+                    $query = $query->whereJsonDoesntContain('normalized_internal_links', $entry->uri);
+                }
+
+                return $query;
+            })
+            ->whereNotIn('entry_id', $ignoredEntries)
+            ->whereNotIn('collection', $disabledCollections);
+
+        if (! $collectionConfig->allowLinkingAcrossSites) {
+            $query->where('site', $site);
+        }
+
+        if (! $collectionConfig->allowLinkingToAllCollections) {
+            $query->whereIn('collection', $collectionConfig->linkableCollections);
+        }
+
+        return $query;
+    }
+
+    public static function getConfigurationHash(): string
+    {
+        return sha1(implode('', [
+            'embeddings',
+            get_class(app(Tokenizer::class)),
+            (string) config('statamic.seo-pro.linking.openai.token_limit', 8000),
+            config('statamic.seo-pro.linking.openai.model', 'text-embeddings-3-small'),
+        ]));
+    }
+
+    public function getRelatedEmbeddingsForEntryLazy(Entry $entry, ResolverOptions $options, int $chunkSize = 100): Generator
+    {
+        /** @var EntryEmbedding $embedding */
+        foreach ($this->relatedEmbeddingsQuery($entry, $options)->lazy($chunkSize) as $embedding) {
+            yield $this->makeVector(
+                $embedding->entry_id,
+                null,
+                $embedding
+            );
+        }
+    }
+
+    public function getRelatedEmbeddingsForEntry(Entry $entry, ResolverOptions $options): Collection
+    {
+        return $this->makeVectorCollection(
+            $this->relatedEmbeddingsQuery($entry, $options)->get()
+        );
+    }
+
+    public function generateEmbeddingsForAllEntries(int $chunkSize = 100): void
+    {
+        EntryQuery::query()->chunk($chunkSize, function ($entries) {
+            $entryIds = $entries->pluck('id')->all();
+            $this->fillEmbeddingInstanceCache($entryIds);
+
+            /** @var array $entryLinks */
+            $entryLinks = EntryLink::query()
+                ->whereIn('entry_id', $entryIds)
+                ->get()
+                ->keyBy('entry_id')
+                ->all();
+
+            foreach ($entries as $entry) {
+                $entryId = $entry->id();
+
+                if (
+                    array_key_exists($entryId, $this->embeddingInstanceCache) &&
+                    array_key_exists($entryId, $entryLinks) &&
+                    $entryLinks[$entryId]->content_hash === $this->embeddingInstanceCache[$entryId]->content_hash &&
+                    $this->configurationHash === $this->embeddingInstanceCache[$entryId]->configuration_hash
+                ) {
+                    continue;
+                }
+
+                $this->generateEmbeddingsForEntry($entry);
+            }
+
+            unset($entryLinks);
+            $this->clearEmbeddingInstanceCache();
+        });
+    }
+
+    protected function fillEmbeddingInstanceCache(array $entryIds): void
+    {
+        $this->embeddingInstanceCache = EntryEmbedding::query()
+            ->whereIn('entry_id', $entryIds)
+            ->get()
+            ->keyBy('entry_id')
+            ->all();
+    }
+
+    protected function clearEmbeddingInstanceCache(): void
+    {
+        $this->embeddingInstanceCache = [];
+    }
+
+    protected function getEntryEmbedding(string $entryId): EntryEmbedding
+    {
+        if (array_key_exists($entryId, $this->embeddingInstanceCache)) {
+            return $this->embeddingInstanceCache[$entryId];
+        }
+
+        return EntryEmbedding::query()->firstOrNew(['entry_id' => $entryId]);
+    }
+
+    public function generateEmbeddingsForEntry(Entry $entry): void
+    {
+        $id = $entry->id();
+
+        $embedding = $this->getEntryEmbedding($id);
+
+        $content = $this->contentRetriever->getContent($entry, false);
+
+        if ($this->isContentSame($embedding, $content) && $embedding->configuration_hash === $this->configurationHash) {
+            return;
+        }
+
+        $contentHash = $this->contentRetriever->hashContent($content);
+
+        $content = $this->contentRetriever->stripTags($content);
+
+        $collection = $entry->collection()->handle();
+        $site = $entry->site()->handle();
+        $blueprint = $entry->blueprint()->handle();
+
+        $embedding->collection = $collection;
+        $embedding->site = $site;
+        $embedding->blueprint = $blueprint;
+        $embedding->content_hash = $contentHash;
+        $embedding->configuration_hash = $this->configurationHash;
+
+        try {
+            $embedding->embedding = array_values(
+                $this->embeddingsExtractor->transform($content) ?? []
+            );
+
+            $embedding->saveQuietly();
+        } catch (Exception $exception) {
+            Log::error($exception);
+        }
+    }
+
+    public function getEmbeddingsForEntry(Entry $entry): ?Vector
+    {
+        return $this->makeVector(
+            $entry->id(),
+            $entry,
+            EntryEmbedding::query()->where('entry_id', $entry->id())->first()
+        );
+    }
+
+    protected function makeVector(string $id, ?Entry $entry, ?EntryEmbedding $embedding): ?Vector
+    {
+        $entryVector = new Vector;
+        $entryVector->id($id);
+        $entryVector->entry($entry);
+        $entryVector->vector($embedding?->embedding ?? []);
+
+        return $entryVector;
+    }
+
+    protected function makeVectorCollection(Collection $vectors, bool $withEntries = false): Collection
+    {
+        $vectors = $vectors->keyBy('entry_id');
+        $entryIds = $vectors->keys()->all();
+        $entries = collect();
+
+        if ($withEntries) {
+            $entries = EntryApi::query()
+                ->whereIn('id', $entryIds)
+                ->get()
+                ->keyBy('id');
+        }
+
+        $results = [];
+
+        foreach ($entryIds as $id) {
+            $results[] = $this->makeVector(
+                $id,
+                $entries[$id] ?? null,
+                $vectors[$id] ?? null,
+            );
+        }
+
+        return collect($results);
+    }
+
+    public function getEmbeddingsForCollection(string $handle, string $site = 'default'): Collection
+    {
+        return $this->makeVectorCollection(
+            EntryEmbedding::query()->where('collection', $handle)->where('site', $site)->get()
+        );
+    }
+
+    public function getEmbeddingsForSite(string $handle): Collection
+    {
+        return $this->makeVectorCollection(
+            EntryEmbedding::query()->where('site', $handle)->get()
+        );
+    }
+
+    public function deleteEmbeddingsForEntry(string $entryId): void
+    {
+        EntryEmbedding::query()->where('entry_id', $entryId)->delete();
+    }
+
+    public function deleteEmbeddingsForCollection(string $handle): void
+    {
+        EntryEmbedding::query()->where('collection', $handle)->delete();
+    }
+
+    public function deleteEmbeddingsForSite(string $handle): void
+    {
+        EntryEmbedding::query()->where('site', $handle)->delete();
+    }
+}
diff --git a/src/TextProcessing/Embeddings/OpenAiEmbeddings.php b/src/TextProcessing/Embeddings/OpenAiEmbeddings.php
new file mode 100644
index 00000000..92331808
--- /dev/null
+++ b/src/TextProcessing/Embeddings/OpenAiEmbeddings.php
@@ -0,0 +1,57 @@
+tokenizer = $tokenizer;
+        $this->tokenLimit = config('statamic.seo-pro.linking.openai.token_limit', 8000);
+        $this->embeddingsModel = config('statamic.seo-pro.linking.openai.model', 'text-embeddings-3-small');
+    }
+
+    protected function makeClient(): OpenAI\Client
+    {
+        return OpenAI::factory()
+            ->withApiKey(config('statamic.seo-pro.linking.openai.api_key'))
+            ->make();
+    }
+
+    public function transform(string $content): array
+    {
+        $vector = [];
+
+        foreach ($this->tokenizer->chunk($content, $this->tokenLimit) as $chunk) {
+            $vector = array_merge($vector, $this->getEmbeddingFromApi($chunk));
+        }
+
+        return $vector;
+    }
+
+    protected function getEmbeddingFromApi(string $content): array
+    {
+        $response = $this->makeClient()->embeddings()->create([
+            'model' => $this->embeddingsModel,
+            'input' => $content,
+        ]);
+
+        $vector = [];
+
+        foreach ($response->embeddings as $embedding) {
+            $vector = array_merge($vector, $embedding->embedding);
+        }
+
+        return $vector;
+    }
+}
diff --git a/src/TextProcessing/Keywords/KeywordComparator.php b/src/TextProcessing/Keywords/KeywordComparator.php
new file mode 100644
index 00000000..d2b5a6df
--- /dev/null
+++ b/src/TextProcessing/Keywords/KeywordComparator.php
@@ -0,0 +1,105 @@
+= $this->keywordThreshold;
+    }
+
+    protected function getAdjustedScores(array $contentKeywords): array
+    {
+        $base = count($contentKeywords) > 0 ? max($contentKeywords) : 0;
+
+        if ($base <= 0) {
+            $base = 1;
+        }
+
+        $titleScore = $base * 200;
+        $uriScore = $base * 100;
+
+        return [
+            'title' => intval($titleScore),
+            'uri' => intval($uriScore),
+        ];
+    }
+
+    protected function getAdjustedKeywords(array $keywords, bool $includeMeta = true)
+    {
+        $contentKeywords = $keywords['content'] ?? [];
+
+        if (! $includeMeta) {
+            return $contentKeywords;
+        }
+
+        $adjustedScores = $this->getAdjustedScores($contentKeywords);
+
+        foreach ($keywords['uri'] as $keyword) {
+            $contentKeywords[$keyword] = $adjustedScores['uri'];
+        }
+
+        foreach ($keywords['title'] as $keyword) {
+            $contentKeywords[$keyword] = $adjustedScores['title'];
+        }
+
+        return $contentKeywords;
+    }
+
+    public function compare(array $primaryKeywords): static
+    {
+        $this->primaryKeywords = $this->getAdjustedKeywords($primaryKeywords, false);
+
+        return $this;
+    }
+
+    protected function compareKeywords(array $keywordsA, $keywordsB): array
+    {
+        $score = 0;
+        $keywords = [];
+
+        foreach ($keywordsA as $keywordA => $scoreA) {
+            foreach ($keywordsB as $keywordB => $scoreB) {
+                if ($this->keywordMatch($keywordA, $keywordB)) {
+                    $score += $scoreB;
+                    $keywords[] = [
+                        'keyword' => $keywordA,
+                        'score' => $scoreB,
+                    ];
+                }
+            }
+        }
+
+        return [
+            'keywords' => $keywords,
+            'score' => $score,
+        ];
+    }
+
+    /**
+     * @param  Result[]  $results
+     */
+    public function to(array $results): array
+    {
+        foreach ($results as $result) {
+            $keywordResults = $this->compareKeywords(
+                $this->primaryKeywords,
+                $this->getAdjustedKeywords($result->keywords())
+            );
+
+            $result->keywordScore($keywordResults['score']);
+            $result->similarKeywords($keywordResults['keywords']);
+        }
+
+        return $results;
+    }
+}
diff --git a/src/TextProcessing/Keywords/KeywordsRepository.php b/src/TextProcessing/Keywords/KeywordsRepository.php
new file mode 100644
index 00000000..fde52f47
--- /dev/null
+++ b/src/TextProcessing/Keywords/KeywordsRepository.php
@@ -0,0 +1,212 @@
+ */
+    protected array $keywordInstanceCache = [];
+
+    public function __construct(
+        protected readonly KeywordRetriever $keywordRetriever,
+        protected readonly ContentRetriever $contentRetriever,
+    ) {}
+
+    public function generateKeywordsForAllEntries(int $chunkSize = 100): void
+    {
+        EntryQuery::query()->chunk($chunkSize, function ($entries) {
+            $entryIds = $entries->pluck('id')->all();
+            $this->fillKeywordInstanceCache($entryIds);
+
+            /** @var array $entryLinks */
+            $entryLinks = EntryLink::query()
+                ->whereIn('entry_id', $entryIds)
+                ->get()
+                ->keyBy('entry_id')
+                ->all();
+
+            foreach ($entries as $entry) {
+                $entryId = $entry->id();
+
+                // If the cached content is still good, let's skip generating keywords.
+                if (
+                    array_key_exists($entryId, $this->keywordInstanceCache) &&
+                    array_key_exists($entryId, $entryLinks) &&
+                    $entryLinks[$entryId]->content_hash === $this->keywordInstanceCache[$entryId]->content_hash
+                ) {
+                    continue;
+                }
+
+                $this->generateKeywordsForEntry($entry);
+            }
+
+            unset($entryLinks);
+            $this->clearKeywordInstanceCache();
+        });
+    }
+
+    protected function fillKeywordInstanceCache(array $entryIds): void
+    {
+        $this->keywordInstanceCache = EntryKeyword::query()
+            ->whereIn('entry_id', $entryIds)
+            ->get()
+            ->keyBy('entry_id')
+            ->all();
+    }
+
+    protected function clearKeywordInstanceCache(): void
+    {
+        $this->keywordInstanceCache = [];
+    }
+
+    protected function expandKeywords(array $keywords, $stopWords = []): array
+    {
+        $keywordsToReturn = [];
+
+        foreach ($keywords as $keyword) {
+            $keywordsToReturn[] = $keyword;
+
+            if (! Str::contains($keyword, ' ')) {
+                continue;
+            }
+
+            foreach (explode(' ', $keyword) as $newKeyword) {
+                if (is_numeric($newKeyword) || mb_strlen($newKeyword) <= 2) {
+                    continue;
+                }
+
+                if (in_array($newKeyword, $stopWords)) {
+                    continue;
+                }
+
+                $keywordsToReturn[] = $newKeyword;
+            }
+        }
+
+        return $keywordsToReturn;
+    }
+
+    protected function getMetaKeywords(Entry $entry, $stopWords = []): array
+    {
+        $uri = str($entry->uri ?? '')
+            ->afterLast('/')
+            ->swap([
+                '-' => ' ',
+            ])->value();
+
+        return [
+            'title' => $this->expandKeywords($this->keywordRetriever->extractKeywords($entry->title ?? '')->all(), $stopWords),
+            'uri' => $this->expandKeywords($this->keywordRetriever->extractKeywords($uri)->all(), $stopWords),
+        ];
+    }
+
+    protected function getEntryKeyword(string $entryId): EntryKeyword
+    {
+        if (array_key_exists($entryId, $this->keywordInstanceCache)) {
+            return $this->keywordInstanceCache[$entryId];
+        }
+
+        return EntryKeyword::query()->firstOrNew(['entry_id' => $entryId]);
+    }
+
+    public function generateKeywordsForEntry(Entry $entry): void
+    {
+        $id = $entry->id();
+
+        $keywords = $this->getEntryKeyword($id);
+
+        $content = $this->contentRetriever->getContent($entry, false);
+
+        if ($this->isContentSame($keywords, $content)) {
+            return;
+        }
+
+        $contentHash = $this->contentRetriever->hashContent($content);
+
+        $content = $this->contentRetriever->stripTags($content);
+
+        // Remove some extra stuff we wouldn't want to ultimately link to/suggest.
+        $content = ContentRemoval::removeHeadings($content);
+        $content = ContentRemoval::removePreCodeBlocks($content);
+
+        $collection = $entry->collection()->handle();
+        $site = $entry->site()->handle();
+        $blueprint = $entry->blueprint()->handle();
+
+        $keywords->collection = $collection;
+        $keywords->site = $site;
+        $keywords->blueprint = $blueprint;
+
+        $keywords->content_hash = $contentHash;
+
+        $stopWords = $this->keywordRetriever->inLocale($entry->site()?->locale() ?? 'en_US')->getStopWords();
+
+        $keywords->meta_keywords = $this->getMetaKeywords($entry, $stopWords);
+        $keywords->content_keywords = $this->keywordRetriever->extractRankedKeywords($content)->sortDesc()->take(30)->all();
+
+        $keywords->saveQuietly();
+    }
+
+    public function deleteKeywordsForEntry(string $entryId): void
+    {
+        EntryKeyword::query()->where('entry_id', $entryId)->delete();
+    }
+
+    /**
+     * @return array
+     */
+    public function getKeywordsForEntries(array $entryIds): array
+    {
+        return EntryKeyword::query()
+            ->whereIn('entry_id', $entryIds)
+            ->get()
+            ->keyBy('entry_id')
+            ->all();
+    }
+
+    public function getIgnoredKeywordsForEntry(Entry $entry): array
+    {
+        $site = $entry->site()?->handle() ?? 'default';
+        $ignoredKeywords = [];
+
+        /** @var EntryLink $entryLink */
+        $entryLink = EntryLink::query()->where('entry_id', $entry->id())->first();
+
+        /** @var SiteLinkSetting $siteSettings */
+        $siteSettings = SiteLinkSetting::query()->where('site', $site)->first();
+
+        if ($entryLink) {
+            $ignoredKeywords = array_merge($ignoredKeywords, $entryLink->ignored_phrases ?? []);
+        }
+
+        if ($siteSettings) {
+            $ignoredKeywords = array_merge($ignoredKeywords, $siteSettings->ignored_phrases ?? []);
+        }
+
+        return $ignoredKeywords;
+    }
+
+    public function deleteKeywordsForSite(string $handle): void
+    {
+        EntryKeyword::query()->where('site', $handle)->delete();
+    }
+
+    public function deleteKeywordsForCollection(string $handle): void
+    {
+        EntryKeyword::query()->where('collection', $handle)->delete();
+    }
+}
diff --git a/src/TextProcessing/Keywords/Rake.php b/src/TextProcessing/Keywords/Rake.php
new file mode 100644
index 00000000..9d960e8f
--- /dev/null
+++ b/src/TextProcessing/Keywords/Rake.php
@@ -0,0 +1,129 @@
+stopWordFiles = collect(scandir($this->stopWordDir()))
+            ->filter(fn ($fileName) => str_ends_with($fileName, '.php'))
+            ->map(fn ($fileName) => (string) str($fileName)->substr(0, mb_strlen($fileName) - 4)->lower())
+            ->all();
+    }
+
+    private function rake(): RakePlus
+    {
+        return RakePlus::create(
+            null,
+            stopwords: $this->getStopWords(),
+            phrase_min_length: config('statamic.seo-pro.linking.rake.phrase_min_length', 0),
+            filter_numerics: config('statamic.seo-pro.linking.rake.filter_numerics', true)
+        );
+    }
+
+    protected function stopWordDir(): string
+    {
+        return base_path('vendor/donatello-za/rake-php-plus/lang/');
+    }
+
+    public function getStopWords(): array
+    {
+        $path = $this->stopWordDir().$this->locale.'.php';
+
+        if (! in_array(mb_strtolower($this->locale), $this->stopWordFiles) || ! file_exists($path)) {
+            return $this->runStopWordsHook();
+        }
+
+        $stopWords = include $path;
+
+        return $this->runStopWordsHook($stopWords);
+    }
+
+    protected function runStopWordsHook(array $stopWords = []): array
+    {
+        return (new StopWordsHook(new StopWordsBag($stopWords, $this->locale)))->getStopWords();
+    }
+
+    protected function runRake(string $content): RakePlus
+    {
+        return $this->rake()->extract($content);
+    }
+
+    protected function shouldKeepKeyword(string $keyword): bool
+    {
+        if (mb_strlen(trim($keyword)) <= 1) {
+            return false;
+        }
+
+        if (Str::contains($keyword, ['/', '\\', '{', '}', '’', '<', '>', '=', '-'])) {
+            return false;
+        }
+
+        return true;
+    }
+
+    protected function filterKeywords(array $keywords): array
+    {
+        $results = [];
+
+        foreach ($keywords as $keyword) {
+            if (! $this->shouldKeepKeyword($keyword)) {
+                continue;
+            }
+
+            $results[] = $keyword;
+        }
+
+        return $results;
+    }
+
+    protected function filterKeywordScores(array $keywords): array
+    {
+        $results = [];
+
+        foreach ($keywords as $keyword => $score) {
+            if (! $this->shouldKeepKeyword($keyword)) {
+                continue;
+            }
+
+            $results[$keyword] = $score;
+        }
+
+        return $results;
+    }
+
+    public function extractKeywords(string $content): Collection
+    {
+        return collect($this->filterKeywords($this->runRake($content)->get()));
+    }
+
+    public function extractRankedKeywords(string $content): Collection
+    {
+        return collect($this->filterKeywordScores($this->runRake($content)->scores()));
+    }
+
+    public function transform(string $content): array
+    {
+        $scores = $this->runRake($content)->scores();
+
+        return array_values($scores);
+    }
+
+    public function inLocale(string $locale): static
+    {
+        $this->locale = $locale;
+
+        return $this;
+    }
+}
diff --git a/src/TextProcessing/Keywords/StopWordsBag.php b/src/TextProcessing/Keywords/StopWordsBag.php
new file mode 100644
index 00000000..1782cdf8
--- /dev/null
+++ b/src/TextProcessing/Keywords/StopWordsBag.php
@@ -0,0 +1,16 @@
+stopWords;
+    }
+}
diff --git a/src/TextProcessing/Links/AutomaticLinkManager.php b/src/TextProcessing/Links/AutomaticLinkManager.php
new file mode 100644
index 00000000..7500ab22
--- /dev/null
+++ b/src/TextProcessing/Links/AutomaticLinkManager.php
@@ -0,0 +1,212 @@
+has($this->normalizeLink($link->link_target))) {
+            return false;
+        }
+
+        return ! TextSimilarity::similarToAny($link->link_text, $existingLinkText);
+    }
+
+    protected function filterAutomaticLinks(Collection $automaticLinks, Collection $existingLinks): Collection
+    {
+        $existingLinkTargets = $existingLinks
+            ->pluck('href')
+            ->map(fn ($target) => $this->normalizeLink($target))
+            ->unique()
+            ->flip();
+
+        $existingLinkText = $existingLinks
+            ->pluck('text')
+            ->map(fn ($text) => mb_strtolower($text))
+            ->unique()
+            ->all();
+
+        return $automaticLinks
+            ->filter(fn ($link) => $this->shouldKeepLink($link, $existingLinkTargets, $existingLinkText));
+    }
+
+    protected function exceedsLinkThreshold(int $linkCount, int $threshold): bool
+    {
+        if ($threshold <= 0) {
+            return false;
+        }
+
+        return $linkCount > $threshold;
+    }
+
+    protected function positionIsInRange(array $range, int $pos): bool
+    {
+        return $pos >= $range['start'] && $pos <= $range['end'];
+    }
+
+    protected function isWithinRange(Collection $ranges, int $start, int $length): bool
+    {
+        $end = $start + $length;
+
+        foreach ($ranges as $range) {
+            if ($this->positionIsInRange($range, $start) || $this->positionIsInRange($range, $end)) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    protected function getFreshLinkRanges(string $content): Collection
+    {
+        return $this->getTextRanges(
+            collect(LinkCrawler::getLinksInContent($content))->pluck('content')->all(),
+            $content,
+            $this->encoding
+        );
+    }
+
+    protected function insertLinks(string $content, Collection $initialRanges, Collection $links, int &$currentLinkCount, int $threshold): string
+    {
+        $ranges = $initialRanges;
+
+        foreach ($links as $link) {
+            $normalizedLink = $this->normalizeLink($link->link_target);
+
+            if (array_key_exists($normalizedLink, $this->insertedLinks)) {
+                continue;
+            }
+
+            $linkPos = mb_stripos($content, $link->link_text);
+
+            if (! $linkPos || $this->isWithinRange($ranges, $linkPos, str($link->link_text)->length())) {
+                continue;
+            }
+
+            $content = $this->insertLink($link, $content, $linkPos);
+            $this->insertedLinks[$normalizedLink] = true;
+
+            $currentLinkCount += 1;
+
+            if ($this->exceedsLinkThreshold($currentLinkCount, $threshold)) {
+                break;
+            }
+
+            $ranges = $this->getFreshLinkRanges($content);
+        }
+
+        return $content;
+    }
+
+    protected function renderLink(AutomaticLink $link): string
+    {
+        return view('seo-pro::links.automatic', [
+            'url' => $link->link_target,
+            'text' => $link->link_text,
+        ])->render();
+    }
+
+    protected function insertLink(AutomaticLink $link, string $content, int $startPosition): string
+    {
+        return str($content)->substrReplace(
+            $this->renderLink($link),
+            $startPosition,
+            mb_strlen($link->link_text)
+        );
+    }
+
+    public function inject(string $content, string $site = 'default', ?string $encoding = null): string
+    {
+        $this->encoding = $encoding;
+        $this->insertedLinks = [];
+        $siteConfig = $this->configurationRepository->getSiteConfiguration($site);
+        $linkResults = LinkCrawler::getLinkResults($content);
+
+        $this->currentInternalLinks = count($linkResults->internalLinks());
+        $this->currentExternalLinks = count($linkResults->externalLinks());
+
+        $this->maxInternalLinks = $siteConfig->maxInternalLinks;
+        $this->maxExternalLinks = $siteConfig->maxExternalLinks;
+
+        $shouldInsertInternal = ! $this->exceedsLinkThreshold($this->currentInternalLinks, $this->maxInternalLinks);
+        $shouldInsertExternal = ! $this->exceedsLinkThreshold($this->currentExternalLinks, $this->maxExternalLinks);
+
+        if (! $shouldInsertInternal && ! $shouldInsertExternal) {
+            return $content;
+        }
+
+        $allLinks = collect($linkResults->allLinks());
+
+        $automaticLinks = $this->filterAutomaticLinks(collect($this->automaticLinks->getLinksForSite($site)), $allLinks);
+
+        if ($automaticLinks->count() === 0) {
+            return $content;
+        }
+
+        $autoInternalLinks = [];
+        $autoExternalLinks = [];
+
+        foreach ($automaticLinks as $link) {
+            if (URL::isExternal($link->link_target)) {
+                $autoExternalLinks[] = $link;
+
+                continue;
+            }
+
+            $autoInternalLinks[] = $link;
+        }
+
+        $linkRanges = $this->getTextRanges($allLinks->pluck('content')->all(), $content);
+
+        $content = $shouldInsertInternal ? $this->insertLinks($content, $linkRanges, collect($autoInternalLinks), $this->currentInternalLinks, $this->maxInternalLinks) : $content;
+
+        return $shouldInsertExternal ? $this->insertLinks($content, $linkRanges, collect($autoExternalLinks), $this->currentExternalLinks, $this->maxExternalLinks) : $content;
+    }
+
+    protected function getTextRanges(array $needles, string $content, ?string $encoding = null): Collection
+    {
+        return collect($needles)
+            ->unique()
+            ->flatMap(function ($needle) use ($content, $encoding) {
+                $searchLen = str($needle)->length($encoding);
+                $offset = 0;
+
+                return collect()->tap(function ($ranges) use ($content, $needle, $searchLen, &$offset, $encoding) {
+                    while (($pos = str($content)->position($needle, $offset, $encoding)) !== false) {
+                        $ranges->push([
+                            'start' => $pos,
+                            'end' => $pos + $searchLen,
+                            'content' => $needle,
+                        ]);
+
+                        $offset = $pos + 1;
+                    }
+                });
+            });
+    }
+}
diff --git a/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php b/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
new file mode 100644
index 00000000..cb316825
--- /dev/null
+++ b/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
@@ -0,0 +1,25 @@
+where('site', $handle)
+            ->delete();
+    }
+
+    public function getLinksForSite(string $handle): Collection
+    {
+        return AutomaticLink::query()
+            ->where('is_active', true)
+            ->where('site', $handle)
+            ->get();
+    }
+}
diff --git a/src/TextProcessing/Links/IgnoredSuggestion.php b/src/TextProcessing/Links/IgnoredSuggestion.php
new file mode 100644
index 00000000..9c623be7
--- /dev/null
+++ b/src/TextProcessing/Links/IgnoredSuggestion.php
@@ -0,0 +1,15 @@
+addedLinks;
+    }
+
+    public function removedLinks(): array
+    {
+        return $this->removedLinks;
+    }
+
+    public function entries(): Collection
+    {
+        $changedUris = array_merge($this->addedLinks, $this->removedLinks);
+
+        $entryIds = EntryLink::query()
+            ->whereIn('cached_uri', $changedUris)
+            ->whereNot('entry_id', $this->entryId)
+            ->select('entry_id')
+            ->get()
+            ->pluck('entry_id')
+            ->all();
+
+        return EntryApi::query()
+            ->whereIn('id', $entryIds)
+            ->get();
+    }
+}
diff --git a/src/TextProcessing/Links/LinkCrawler.php b/src/TextProcessing/Links/LinkCrawler.php
new file mode 100644
index 00000000..915f54a7
--- /dev/null
+++ b/src/TextProcessing/Links/LinkCrawler.php
@@ -0,0 +1,154 @@
+lazy() as $entry) {
+            $this->scanEntry($entry);
+        }
+
+        foreach (EntryQuery::query()->lazy() as $entry) {
+            $this->updateInboundInternalLinkCount($entry);
+        }
+    }
+
+    public function scanEntry(Entry $entry, ?LinkScanOptions $options = null): void
+    {
+        $this->linksRepository->scanEntry($entry, $options);
+    }
+
+    public static function getLinkResultsFromEntryLink(EntryLink $entryLink): LinkResults
+    {
+        return self::getLinkResults($entryLink->analyzed_content);
+    }
+
+    public function updateLinkStatistics(Entry $entry): void
+    {
+        $this->updateInboundInternalLinkCount($entry);
+    }
+
+    public function updateInboundInternalLinkCount(Entry $entry): void
+    {
+        $targetUri = $entry->uri;
+        $targetLink = EntryLink::query()->where('entry_id', $entry->id)->first();
+
+        if (! $targetLink) {
+            return;
+        }
+
+        /** @var EntryLink[] $entryLinks */
+        $entryLinks = EntryLink::query()->whereJsonContains('internal_links', $targetUri)->get();
+        $totalInbound = 0;
+
+        foreach ($entryLinks as $link) {
+            if (str_starts_with($link, '#')) {
+                continue;
+            }
+
+            if ($link->id === $targetLink->id) {
+                continue;
+            }
+
+            $linkCount = collect($link->internal_links)->filter(function ($internalLink) use ($targetUri) {
+                return $internalLink === $targetUri;
+            })->count();
+
+            $totalInbound += $linkCount;
+        }
+
+        $targetLink->inbound_internal_link_count = $totalInbound;
+        $targetLink->saveQuietly();
+    }
+
+    /**
+     * @return array{array{href:string, content:string}}
+     */
+    public static function getLinksInContent(string $content): array
+    {
+        $links = [];
+        $pattern = '/]*>(.*?)<\/a>/i';
+
+        preg_match_all($pattern, $content, $matches, PREG_SET_ORDER);
+
+        foreach ($matches as $match) {
+            $href = $match[1];
+
+            $links[] = [
+                'href' => $href,
+                'content' => $match[0],
+            ];
+        }
+
+        return $links;
+    }
+
+    protected static function shouldKeepLink(string $link): bool
+    {
+        // Ignore self-referencing links.
+        if (str_starts_with($link, '#')) {
+            return false;
+        }
+
+        if (str_starts_with($link, '{') && str_ends_with($link, '}')) {
+            return false;
+        }
+
+        if (str_starts_with($link, '//')) {
+            return false;
+        }
+
+        return true;
+    }
+
+    public static function getLinkResults(string $content): LinkResults
+    {
+        $results = new LinkResults;
+        $internalLinks = [];
+        $externalLinks = [];
+
+        foreach (self::getLinksInContent($content) as $link) {
+            $href = $link['href'];
+            $linkText = trim(strip_tags($link['content']));
+
+            if (! self::shouldKeepLink($href)) {
+                continue;
+            }
+
+            $result = [
+                'href' => $href,
+                'text' => $linkText,
+                'content' => $link['content'],
+            ];
+
+            if (URL::isExternal($href)) {
+                $externalLinks[] = $result;
+
+                continue;
+            }
+
+            $internalLinks[] = $result;
+        }
+
+        $results->internalLinks($internalLinks);
+        $results->externalLinks($externalLinks);
+
+        return $results;
+    }
+}
diff --git a/src/TextProcessing/Links/LinkRepository.php b/src/TextProcessing/Links/LinkRepository.php
new file mode 100644
index 00000000..bd75c285
--- /dev/null
+++ b/src/TextProcessing/Links/LinkRepository.php
@@ -0,0 +1,282 @@
+action === 'ignore_entry') {
+            $this->ignoreEntrySuggestion($suggestion);
+        } elseif ($suggestion->action === 'ignore_phrase') {
+            $this->ignorePhraseSuggestion($suggestion);
+        }
+    }
+
+    protected function whenEntryExists(string $entryId, callable $callback): void
+    {
+        $entry = EntryApi::find($entryId);
+
+        if (! $entry) {
+            return;
+        }
+
+        $callback($entry);
+    }
+
+    protected function ignorePhraseSuggestion(IgnoredSuggestion $suggestion): void
+    {
+        if ($suggestion->scope === 'all_entries') {
+            $this->addIgnoredPhraseToSite($suggestion);
+        } elseif ($suggestion->scope === 'entry') {
+            $this->whenEntryExists($suggestion->entry, fn ($entry) => $this->addIgnoredPhraseToEntry($entry, $suggestion->phrase));
+        }
+    }
+
+    protected function addIgnoredPhraseToSite(IgnoredSuggestion $suggestion): void
+    {
+        /** @var SiteLinkSetting $siteSettings */
+        $siteSettings = SiteLinkSetting::query()->firstOrNew(['site' => $suggestion->site]);
+
+        $phrase = trim($suggestion->phrase);
+
+        if (mb_strlen($phrase) === 0) {
+            return;
+        }
+
+        $phrases = $siteSettings->ignored_phrases ?? [];
+
+        if (in_array($phrase, $phrases)) {
+            return;
+        }
+
+        $phrases[] = $phrase;
+
+        $siteSettings->ignored_phrases = $phrases;
+
+        $siteSettings->saveQuietly();
+    }
+
+    protected function ignoreEntrySuggestion(IgnoredSuggestion $suggestion): void
+    {
+        if ($suggestion->scope === 'all_entries') {
+            $this->whenEntryExists($suggestion->ignoredEntry, fn ($entry) => $this->ignoreEntry($entry));
+        } elseif ($suggestion->scope === 'entry') {
+            $this->whenEntryExists($suggestion->entry, fn ($entry) => $this->addIgnoredEntryToEntry($entry, $suggestion->ignoredEntry));
+        }
+    }
+
+    protected function getEntryLink(Entry $entry): ?EntryLink
+    {
+        $entryLink = EntryLink::query()->where('entry_id', $entry->id())->first();
+
+        if (! $entryLink) {
+            $entryLink = $this->scanEntry($entry);
+        }
+
+        return $entryLink;
+    }
+
+    protected function updateEntryLink(Entry $entry, callable $callback): void
+    {
+        $entryLink = $this->getEntryLink($entry);
+
+        if (! $entryLink) {
+            return;
+        }
+
+        $callback($entryLink);
+    }
+
+    protected function addIgnoredEntryToEntry(Entry $entry, string $ignoredEntryId): void
+    {
+        $this->updateEntryLink($entry, function (EntryLink $entryLink) use ($ignoredEntryId) {
+            $ignoredEntries = $entryLink->ignored_entries ?? [];
+
+            if (in_array($ignoredEntryId, $ignoredEntries)) {
+                return;
+            }
+
+            $ignoredEntries[] = $ignoredEntryId;
+
+            $entryLink->ignored_entries = $ignoredEntries;
+
+            $entryLink->saveQuietly();
+            $entryLink->saveQuietly();
+        });
+    }
+
+    protected function addIgnoredPhraseToEntry(Entry $entry, string $phrase): void
+    {
+        $this->updateEntryLink($entry, function (EntryLink $entryLink) use ($phrase) {
+            $phrase = trim($phrase);
+
+            if (mb_strlen($phrase) === 0) {
+                return;
+            }
+
+            $ignoredPhrases = $entryLink->ignored_phrases ?? [];
+
+            if (in_array($phrase, $ignoredPhrases)) {
+                return;
+            }
+
+            $ignoredPhrases[] = $phrase;
+
+            $entryLink->ignored_phrases = $ignoredPhrases;
+
+            $entryLink->saveQuietly();
+        });
+    }
+
+    protected function ignoreEntry(Entry $entry): void
+    {
+        $entryLink = $this->getEntryLink($entry);
+
+        if (! $entryLink) {
+            return;
+        }
+
+        $entryLink->can_be_suggested = false;
+
+        $entryLink->saveQuietly();
+    }
+
+    public function scanEntry(Entry $entry, ?LinkScanOptions $options = null): ?EntryLink
+    {
+        if (! $options) {
+            $options = new LinkScanOptions;
+        }
+
+        /** @var EntryLink $entryLinks */
+        $entryLinks = EntryLink::query()->firstOrNew(['entry_id' => $entry->id()]);
+        $linkContent = $this->contentRetriever->getContent($entry, false);
+        $contentMapping = $this->contentRetriever->getContentMapping($entry);
+        $linkResults = LinkCrawler::getLinkResults($linkContent);
+        $collection = $entry->collection()->handle();
+        $site = $entry->site()->handle();
+
+        $uri = $entry->uri;
+
+        $entryLinks->cached_title = $entry->title ?? $uri ?? '';
+        $entryLinks->cached_uri = $uri ?? '';
+        $entryLinks->site = $site;
+        $entryLinks->analyzed_content = $linkContent;
+        $entryLinks->content_mapping = $contentMapping;
+        $entryLinks->collection = $collection;
+        $entryLinks->external_link_count = count($linkResults->externalLinks());
+        $entryLinks->internal_link_count = count($linkResults->internalLinks());
+        $entryLinks->content_hash = $this->contentRetriever->hashContent($linkContent);
+
+        if (! $entryLinks->exists) {
+            $entryLinks->ignored_entries = [];
+            $entryLinks->ignored_phrases = [];
+            $entryLinks->normalized_internal_links = [];
+            $entryLinks->normalized_external_links = [];
+        }
+
+        $entryLinks->inbound_internal_link_count = 0;
+
+        $externalLinks = collect($linkResults->externalLinks())->pluck('href');
+        $internalLinks = collect($linkResults->internalLinks())->pluck('href');
+
+        $entryLinks->external_links = $externalLinks->all();
+        $entryLinks->internal_links = $internalLinks->all();
+        $entryLinks->normalized_external_links = $this->normalizeLinks($externalLinks);
+        $entryLinks->normalized_internal_links = $this->normalizeLinks($internalLinks);
+
+        $linkChangeSet = null;
+
+        if ($options->withInternalChangeSets && $entryLinks->isDirty('internal_links')) {
+            $linkChangeSet = $this->makeLinkChangeSet(
+                $entryLinks->entry_id,
+                $entryLinks->getOriginal('internal_links') ?? [],
+                $entryLinks->internal_links ?? [],
+            );
+        }
+
+        $entryLinks->saveQuietly();
+
+        if ($linkChangeSet) {
+            InternalLinksUpdated::dispatch($linkChangeSet);
+        }
+
+        return $entryLinks;
+    }
+
+    protected function makeLinkChangeSet(string $entryId, array $original, array $new): LinkChangeSet
+    {
+        return new LinkChangeSet(
+            $entryId,
+            array_diff($new, $original),
+            array_diff($original, $new),
+        );
+    }
+
+    protected function normalizeLinks(Collection $links): array
+    {
+        return $links->map(fn (string $link) => $this->normalizeLink($link))->unique()->values()->all();
+    }
+
+    protected function normalizeLink(string $link): string
+    {
+        while (Str::contains($link, ['?', '#', '&'])) {
+            $link = str($link)
+                ->before('?')
+                ->before('#')
+                ->before('&')
+                ->value();
+        }
+
+        return $link;
+    }
+
+    public function isLinkingEnabledForEntry(Entry $entry): bool
+    {
+        /** @var CollectionLinkSettings $collectionSetting */
+        $collectionSetting = CollectionLinkSettings::query()->where('collection', $entry->collection()->handle())->first();
+
+        if ($collectionSetting && ! $collectionSetting->linking_enabled) {
+            return false;
+        }
+
+        /** @var EntryLink $entryLink */
+        $entryLink = EntryLink::query()->where('entry_id', $entry->id())->first();
+
+        if ($entryLink && ! $entryLink->can_be_suggested) {
+            return false;
+        }
+
+        return true;
+    }
+
+    public function deleteLinksForEntry(string $entryId): void
+    {
+        EntryLink::query()->where('entry_id', $entryId)->delete();
+    }
+
+    public function deleteLinksForSite(string $handle): void
+    {
+        EntryLink::query()->where('site', $handle)->delete();
+    }
+
+    public function deleteLinksForCollection(string $handle): void
+    {
+        EntryLink::query()->where('collection', $handle)->delete();
+    }
+}
diff --git a/src/TextProcessing/Links/LinkScanOptions.php b/src/TextProcessing/Links/LinkScanOptions.php
new file mode 100644
index 00000000..616e0192
--- /dev/null
+++ b/src/TextProcessing/Links/LinkScanOptions.php
@@ -0,0 +1,10 @@
+ 'array',
+    ];
+}
diff --git a/src/TextProcessing/Models/EntryEmbedding.php b/src/TextProcessing/Models/EntryEmbedding.php
new file mode 100644
index 00000000..0c8dfe03
--- /dev/null
+++ b/src/TextProcessing/Models/EntryEmbedding.php
@@ -0,0 +1,41 @@
+ 'array',
+    ];
+
+    public function entryLink(): HasOne
+    {
+        return $this->hasOne(EntryLink::class, 'entry_id', 'entry_id');
+    }
+}
diff --git a/src/TextProcessing/Models/EntryKeyword.php b/src/TextProcessing/Models/EntryKeyword.php
new file mode 100644
index 00000000..cc4c11e8
--- /dev/null
+++ b/src/TextProcessing/Models/EntryKeyword.php
@@ -0,0 +1,36 @@
+ 'array',
+        'content_keywords' => 'array',
+    ];
+}
diff --git a/src/TextProcessing/Models/EntryLink.php b/src/TextProcessing/Models/EntryLink.php
new file mode 100644
index 00000000..e5377fe3
--- /dev/null
+++ b/src/TextProcessing/Models/EntryLink.php
@@ -0,0 +1,65 @@
+ 'array',
+        'internal_links' => 'array',
+        'normalized_external_links' => 'array',
+        'normalized_internal_links' => 'array',
+        'content_mapping' => 'array',
+        'ignored_entries' => 'array',
+        'ignored_phrases' => 'array',
+    ];
+
+    public function collectionSettings(): HasOne
+    {
+        return $this->hasOne(CollectionLinkSettings::class, 'collection', 'collection');
+    }
+}
diff --git a/src/TextProcessing/Models/SiteLinkSetting.php b/src/TextProcessing/Models/SiteLinkSetting.php
new file mode 100644
index 00000000..80a5c8aa
--- /dev/null
+++ b/src/TextProcessing/Models/SiteLinkSetting.php
@@ -0,0 +1,31 @@
+ 'array',
+    ];
+}
diff --git a/src/TextProcessing/Queries/EntryQuery.php b/src/TextProcessing/Queries/EntryQuery.php
new file mode 100644
index 00000000..ae1f52ae
--- /dev/null
+++ b/src/TextProcessing/Queries/EntryQuery.php
@@ -0,0 +1,21 @@
+getDisabledCollections();
+
+        return EntryApi::query()
+            ->whereStatus('published')
+            ->whereNotIn('collection', $disabledCollections);
+    }
+}
diff --git a/src/TextProcessing/Similarity/CosineSimilarity.php b/src/TextProcessing/Similarity/CosineSimilarity.php
new file mode 100644
index 00000000..087279c2
--- /dev/null
+++ b/src/TextProcessing/Similarity/CosineSimilarity.php
@@ -0,0 +1,33 @@
+fluentlyGetOrSet('score')
+            ->args(func_get_args());
+    }
+
+    public function vector(?Vector $vector = null)
+    {
+        return $this->fluentlyGetOrSet('vector')
+            ->args(func_get_args());
+    }
+
+    public function keywordScore(int|float|null $score = null)
+    {
+        return $this->fluentlyGetOrSet('keywordScore')
+            ->args(func_get_args());
+    }
+
+    public function entry(?Entry $entry = null)
+    {
+        return $this->fluentlyGetOrSet('entry')
+            ->args(func_get_args());
+    }
+
+    public function keywords(?array $keywords = null)
+    {
+        return $this->fluentlyGetOrSet('keywords')
+            ->args(func_get_args());
+    }
+
+    public function similarKeywords(?array $similarKeywords = null)
+    {
+        return $this->fluentlyGetOrSet('similarKeywords')
+            ->args(func_get_args());
+    }
+}
diff --git a/src/TextProcessing/Similarity/TextSimilarity.php b/src/TextProcessing/Similarity/TextSimilarity.php
new file mode 100644
index 00000000..63fbc2ff
--- /dev/null
+++ b/src/TextProcessing/Similarity/TextSimilarity.php
@@ -0,0 +1,53 @@
+= $similarityThreshold) {
+            return true;
+        }
+
+        return false;
+    }
+}
diff --git a/src/TextProcessing/Suggestions/LinkResults.php b/src/TextProcessing/Suggestions/LinkResults.php
new file mode 100644
index 00000000..6ab4b47f
--- /dev/null
+++ b/src/TextProcessing/Suggestions/LinkResults.php
@@ -0,0 +1,43 @@
+fluentlyGetOrSet('internalLinks')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @return ($links is null ? array{array{href:string,text:string,content:string}} : null)
+     */
+    public function externalLinks(?array $links = null)
+    {
+        return $this->fluentlyGetOrSet('externalLinks')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @return array{array{href:string,text:string,content:string}}
+     */
+    public function allLinks(): array
+    {
+        return array_merge(
+            $this->internalLinks(),
+            $this->externalLinks(),
+        );
+    }
+}
diff --git a/src/TextProcessing/Suggestions/PhraseContext.php b/src/TextProcessing/Suggestions/PhraseContext.php
new file mode 100644
index 00000000..0d0488f3
--- /dev/null
+++ b/src/TextProcessing/Suggestions/PhraseContext.php
@@ -0,0 +1,55 @@
+fluentlyGetOrSet('fieldHandle')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @return ($context is null ? string : null)
+     */
+    public function context(?string $context = null)
+    {
+        return $this->fluentlyGetOrSet('context')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @return ($canReplace is null ? bool : null)
+     */
+    public function canReplace(?bool $canReplace = null)
+    {
+        return $this->fluentlyGetOrSet('canReplace')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @return array{field_handle:string,context:string,can_replace:bool}
+     */
+    public function toArray()
+    {
+        return [
+            'field_handle' => $this->fieldHandle,
+            'context' => $this->context,
+            'can_replace' => $this->canReplace,
+        ];
+    }
+}
diff --git a/src/TextProcessing/Suggestions/SuggestionEngine.php b/src/TextProcessing/Suggestions/SuggestionEngine.php
new file mode 100644
index 00000000..9a0201e9
--- /dev/null
+++ b/src/TextProcessing/Suggestions/SuggestionEngine.php
@@ -0,0 +1,201 @@
+results = $results;
+
+        return $this;
+    }
+
+    private function canExtractContext(mixed $value, string $phrase): bool
+    {
+        return is_string($value) && Str::contains($value, $phrase);
+    }
+
+    protected function getPhraseContext(array $contentMapping, string $phrase): PhraseContext
+    {
+        $context = new PhraseContext;
+
+        foreach ($contentMapping as $handle => $content) {
+            if (! $this->canExtractContext($content, $phrase)) {
+                continue;
+            }
+
+            $searchText = strip_tags($content);
+            $pos = stripos($searchText, $phrase);
+
+            if ($pos === false) {
+                continue;
+            }
+
+            $pattern = '/([^.!?]*'.preg_quote($phrase, '/').'[^.!?]*[.!?])|([^.!?]*'.preg_quote($phrase, '/').'[^.!?]*$)/i';
+
+            if (! preg_match($pattern, $searchText, $matches)) {
+                continue;
+            }
+
+            $firstMatch = trim($matches[0]);
+            $matchingLine = null;
+
+            if (Str::contains($firstMatch, "\n")) {
+                $matchingLine = $this->getMatchingLine($firstMatch, $phrase);
+            } else {
+                $matchingLine = $this->getSurroundingWords($content, $phrase);
+            }
+
+            if (! $matchingLine) {
+                continue;
+            }
+
+            $context->fieldHandle($handle);
+            $context->context($matchingLine);
+            $context->canReplace(true);
+
+            break;
+        }
+
+        return $context;
+    }
+
+    protected function getMatchingLine(string $content, string $phrase)
+    {
+        return str($content)->explode("\n")
+            ->filter(function ($line) use ($phrase) {
+                return str($line)->lower()->contains($phrase) && str($line)->substrCount(' ') > 2;
+            })
+            ->sortByDesc(fn ($line) => str($line)->length())
+            ->first();
+    }
+
+    /**
+     * Attempts to locate a target phrase within a value and capture the surrounding context.
+     *
+     * @param  string  $content  The text to search within.
+     * @param  string  $phrase  The value to search for within $content.
+     * @param  int  $surroundingWords  The number of words to attempt to retrieve around the $phrase.
+     */
+    protected function getSurroundingWords(string $content, string $phrase, int $surroundingWords = 4): ?string
+    {
+        $pattern = '/(?P(?:[^\s\n]+[ \t]+){0,'.$surroundingWords.'})(?P'.preg_quote($phrase, '/').')(?P(?:[ \t]+[^\s\n]+){0,'.$surroundingWords.'})/iu';
+
+        preg_match($pattern, $content, $matches);
+
+        if (empty($matches)) {
+            return null;
+        }
+
+        return $matches['before'].$matches['phrase'].$matches['after'];
+    }
+
+    public function suggest(Entry $entry)
+    {
+        $entryLink = EntryLink::query()->where('entry_id', $entry->id())->firstOrFail();
+        $linkResults = LinkCrawler::getLinkResultsFromEntryLink($entryLink);
+        $contentMapping = $this->mapper->getContentMapping($entry);
+
+        $internalLinks = [];
+        $usedPhrases = [];
+
+        foreach ($linkResults->internalLinks() as $link) {
+            $internalLinks[] = URL::makeAbsolute($link['href']);
+            $usedPhrases[mb_strtolower($link['text'])] = 1;
+        }
+
+        $suggestions = [];
+
+        /** @var Result $result */
+        foreach ($this->results as $result) {
+            $uri = $result->entry()->uri;
+            $absoluteUri = URL::makeAbsolute($uri);
+
+            if (in_array($absoluteUri, $internalLinks)) {
+                continue;
+            }
+
+            foreach ($result->similarKeywords() as $keyword => $score) {
+                if (
+                    array_key_exists($keyword, $suggestions) ||
+                    array_key_exists($keyword, $usedPhrases)
+                ) {
+                    continue;
+                }
+
+                $context = $this->getPhraseContext($contentMapping, $keyword);
+
+                $suggestions[$keyword] = [
+                    'phrase' => $keyword,
+                    'score' => $score,
+                    'uri' => $result->entry()->uri,
+                    'context' => $context->toArray(),
+                    'entry' => $result->entry()->id(),
+                    'auto_linked' => false,
+                ];
+            }
+        }
+
+        // Resolve additional details from automatic links.
+        $keywordPhrases = array_keys($suggestions);
+
+        if (count($keywordPhrases) > 0) {
+            $automaticLinks = AutomaticLink::query()
+                ->whereIn('link_text', $keywordPhrases)
+                ->where('is_active', true)
+                ->get()
+                ->keyBy(fn (AutomaticLink $link) => mb_strtolower($link->link_text))
+                ->all();
+
+            foreach ($suggestions as $keyword => $suggestion) {
+                if (! array_key_exists($keyword, $automaticLinks)) {
+                    continue;
+                }
+
+                /** @var AutomaticLink $link */
+                $link = $automaticLinks[$keyword];
+
+                $suggestions[$keyword]['uri'] = $link->link_target;
+                $suggestions[$keyword]['auto_linked'] = true;
+
+                if ($link->entry_id) {
+                    $suggestions[$keyword]['entry'] = $link->entry_id;
+                }
+            }
+        }
+
+        return $this->filterSuggestsByReplaceable($suggestions)
+            ->sortByDesc(fn ($suggestion) => $suggestion['score'])
+            ->values();
+    }
+
+    protected function filterSuggestsByReplaceable(array $suggestions): Collection
+    {
+        $replaceable = collect($suggestions)->where('context.can_replace', true)->pluck('entry')->flip()->all();
+
+        return collect($suggestions)->filter(function ($suggestion) use ($replaceable) {
+            if (! $suggestion['context']['can_replace'] && array_key_exists($suggestion['entry'], $replaceable)) {
+                return false;
+            }
+
+            return true;
+        });
+    }
+}
diff --git a/src/TextProcessing/Vectors/Vector.php b/src/TextProcessing/Vectors/Vector.php
new file mode 100644
index 00000000..8d2d2bb0
--- /dev/null
+++ b/src/TextProcessing/Vectors/Vector.php
@@ -0,0 +1,35 @@
+fluentlyGetOrSet('entry')
+            ->args(func_get_args());
+    }
+
+    public function id(?string $id = null)
+    {
+        return $this->fluentlyGetOrSet('id')
+            ->args(func_get_args());
+    }
+
+    public function vector(?array $vector = null)
+    {
+        return $this->fluentlyGetOrSet('vector')
+            ->args(func_get_args());
+    }
+}