From 68b6ded800be51f57461e102dcd7b525f0997e82 Mon Sep 17 00:00:00 2001 From: John Koster Date: Wed, 11 Sep 2024 18:18:29 -0500 Subject: [PATCH 01/91] WIP --- composer.json | 8 +- config/seo-pro.php | 42 ++ ...5_create_seopro_entry_embeddings_table.php | 34 ++ ...154109_create_seopro_entry_links_table.php | 51 ++ ...712_create_seopro_entry_keywords_table.php | 34 ++ ...create_seopro_site_link_settings_table.php | 34 ++ ...te_seopro_global_automatic_links_table.php | 32 ++ ..._seopro_collection_link_settings_table.php | 33 ++ resources/dist/build/assets/cp-56146771.css | 1 - resources/dist/build/assets/cp-7025c2cd.css | 1 - resources/dist/build/assets/cp-c6df7fbd.js | 1 - resources/dist/build/manifest.json | 19 - resources/dist/hot | 1 + .../components/links/AutomaticLinkEditor.vue | 131 +++++ .../links/AutomaticLinksListing.vue | 169 ++++++ .../components/links/ExternalLinkListing.vue | 69 +++ .../js/components/links/FakesResources.vue | 39 ++ .../components/links/InboundInternalLinks.vue | 71 +++ .../components/links/InternalLinkListing.vue | 70 +++ .../js/components/links/LinkDashboard.vue | 130 +++++ resources/js/components/links/Listing.vue | 165 ++++++ .../js/components/links/NavContainer.vue | 37 ++ resources/js/components/links/Overview.vue | 72 +++ .../js/components/links/RelatedContent.vue | 74 +++ .../components/links/SuggestionsListing.vue | 139 +++++ .../links/config/CollectionBehaviorEditor.vue | 102 ++++ .../config/CollectionBehaviorListing.vue | 136 +++++ .../links/config/ConfigResetter.vue | 114 ++++ .../links/config/EntryConfigEditor.vue | 107 ++++ .../links/config/SiteConfigEditor.vue | 105 ++++ .../links/config/SiteConfigListing.vue | 133 +++++ .../links/suggestions/IgnoreConfirmation.vue | 128 +++++ .../links/suggestions/SuggestionEditor.vue | 505 ++++++++++++++++++ resources/js/cp.js | 15 + resources/lang/en/messages.php | 2 + .../views/config/link_collections.blade.php | 12 + resources/views/config/sites.blade.php | 11 + resources/views/index.blade.php | 11 + resources/views/linking/automatic.blade.php | 13 + resources/views/linking/dashboard.blade.php | 14 + resources/views/linking/index.blade.php | 14 + resources/views/links/html.antlers.html | 1 + resources/views/links/markdown.antlers.html | 1 + routes/cp.php | 49 ++ src/Actions/ViewLinkSuggestions.php | 33 ++ src/Commands/GenerateEmbeddingsCommand.php | 40 ++ src/Commands/GenerateKeywordsCommand.php | 40 ++ src/Commands/ScanLinksCommand.php | 26 + src/Commands/StartTheEnginesCommand.php | 34 ++ .../ConfigurationRepository.php | 32 ++ .../Content/ContentRetriever.php | 20 + .../Content/FieldtypeContentMapper.php | 24 + .../TextProcessing/Content/Tokenizer.php | 10 + .../Embeddings/EntryEmbeddingsRepository.php | 30 ++ .../TextProcessing/Embeddings/Extractor.php | 9 + .../Keywords/KeywordRetriever.php | 16 + .../Keywords/KeywordsRepository.php | 22 + .../Links/FieldtypeLinkReplacer.php | 14 + .../Links/GlobalAutomaticLinksRepository.php | 9 + .../TextProcessing/Links/LinkCrawler.php | 15 + .../TextProcessing/Links/LinksRepository.php | 23 + src/Hooks/CP/EntryLinksIndexQuery.php | 24 + src/Hooks/Keywords/StopWordsHook.php | 24 + src/Http/Concerns/MergesBlueprintFields.php | 25 + .../CollectionLinkSettingsController.php | 60 +++ .../GlobalAutomaticLinksController.php | 87 +++ .../Linking/IgnoredSuggestionsController.php | 36 ++ .../Controllers/Linking/LinksController.php | 305 +++++++++++ .../Linking/SiteLinkSettingsController.php | 63 +++ src/Http/Requests/AutomaticLinkRequest.php | 18 + src/Http/Requests/IgnoreSuggestionRequest.php | 27 + src/Http/Requests/InsertLinkRequest.php | 19 + .../UpdateCollectionBehaviorRequest.php | 17 + src/Http/Requests/UpdateEntryLinkRequest.php | 16 + src/Http/Requests/UpdateSiteConfigRequest.php | 20 + src/Http/Resources/BaseResourceCollection.php | 56 ++ src/Http/Resources/Links/AutomaticLinks.php | 30 ++ src/Http/Resources/Links/EntryLinks.php | 35 ++ .../Resources/Links/ListedAutomaticLink.php | 33 ++ src/Http/Resources/Links/ListedEntryLink.php | 34 ++ src/Jobs/CleanupCollectionLinks.php | 37 ++ src/Jobs/CleanupEntryLinks.php | 30 ++ src/Jobs/CleanupSiteLinks.php | 33 ++ src/Jobs/Concerns/DispatchesSeoProJobs.php | 28 + src/Jobs/ScanEntryLinks.php | 42 ++ src/Listeners/CollectionDeletedListener.php | 14 + src/Listeners/EntryDeletedListener.php | 14 + src/Listeners/EntrySavedListener.php | 14 + src/Listeners/SiteDeletedListener.php | 14 + src/Query/Scopes/Filters/Collection.php | 13 + src/Query/Scopes/Filters/Fields.php | 21 + src/Query/Scopes/Filters/Site.php | 13 + src/Reporting/Linking/BaseLinkReport.php | 110 ++++ .../Linking/Concerns/ResolvesSimilarItems.php | 128 +++++ src/Reporting/Linking/ExternalLinksReport.php | 30 ++ src/Reporting/Linking/InternalLinksReport.php | 31 ++ .../Linking/RelatedContentReport.php | 36 ++ src/Reporting/Linking/ReportBuilder.php | 215 ++++++++ src/Reporting/Linking/SuggestionsReport.php | 21 + src/SeoPro.php | 24 + src/ServiceProvider.php | 150 +++++- src/Tags/SeoProTags.php | 10 + .../Concerns/ChecksForContentChanges.php | 18 + .../Config/CollectionConfig.php | 27 + .../Config/CollectionConfigBlueprint.php | 53 ++ .../Config/ConfigurationRepository.php | 220 ++++++++ .../Config/EntryConfigBlueprint.php | 34 ++ src/TextProcessing/Config/SiteConfig.php | 31 ++ .../Config/SiteConfigBlueprint.php | 79 +++ src/TextProcessing/Content/ContentMapper.php | 375 +++++++++++++ .../Content/ContentMatching.php | 24 + src/TextProcessing/Content/ContentRemoval.php | 35 ++ .../Content/ContentRetriever.php | 137 +++++ src/TextProcessing/Content/FieldIndex.php | 64 +++ .../Content/LinkReplacement.php | 43 ++ src/TextProcessing/Content/LinkReplacer.php | 91 ++++ .../Content/LinkReplacers/BardReplacer.php | 27 + .../LinkReplacers/MarkdownReplacer.php | 37 ++ .../Content/LinkReplacers/TextReplacer.php | 37 ++ .../LinkReplacers/TextareaReplacer.php | 13 + .../Content/Mappers/AbstractFieldMapper.php | 52 ++ .../Content/Mappers/BardFieldMapper.php | 109 ++++ .../Content/Mappers/Concerns/GetsSets.php | 28 + .../Content/Mappers/GridFieldMapper.php | 62 +++ .../Content/Mappers/MarkdownFieldMapper.php | 13 + .../Content/Mappers/ReplicatorFieldMapper.php | 81 +++ .../Content/Mappers/TextFieldMapper.php | 19 + .../Content/Mappers/TextareaFieldMapper.php | 13 + .../Content/Paths/ContentPath.php | 39 ++ .../Content/Paths/ContentPathParser.php | 125 +++++ .../Content/Paths/ContentPathPart.php | 49 ++ .../Content/ReplacementContext.php | 30 ++ .../Content/RetrievedConfig.php | 19 + src/TextProcessing/Content/RetrievedField.php | 119 +++++ src/TextProcessing/Content/Tokenizer.php | 51 ++ .../Embeddings/EmbeddingsRepository.php | 255 +++++++++ .../Embeddings/OpenAiEmbeddings.php | 57 ++ src/TextProcessing/EntryQuery.php | 21 + .../Keywords/KeywordComparator.php | 121 +++++ .../Keywords/KeywordsRepository.php | 201 +++++++ src/TextProcessing/Keywords/Rake.php | 115 ++++ src/TextProcessing/Keywords/StopWordsBag.php | 16 + .../Links/GlobalAutomaticLinksBlueprint.php | 41 ++ .../Links/GlobalAutomaticLinksRepository.php | 14 + .../Links/IgnoredSuggestion.php | 15 + src/TextProcessing/Links/LinkBlueprint.php | 41 ++ src/TextProcessing/Links/LinkCrawler.php | 148 +++++ src/TextProcessing/Links/LinkRepository.php | 254 +++++++++ src/TextProcessing/Models/AutomaticLink.php | 28 + .../Models/CollectionLinkSettings.php | 28 + src/TextProcessing/Models/EntryEmbedding.php | 41 ++ src/TextProcessing/Models/EntryKeyword.php | 35 ++ src/TextProcessing/Models/EntryLink.php | 64 +++ src/TextProcessing/Models/SiteLinkSetting.php | 30 ++ .../Similarity/CosineSimilarity.php | 33 ++ .../Similarity/ResolverOptions.php | 11 + src/TextProcessing/Similarity/Result.php | 60 +++ .../Suggestions/LinkResults.php | 34 ++ .../Suggestions/PhraseContext.php | 58 ++ .../Suggestions/SuggestionEngine.php | 165 ++++++ src/TextProcessing/Vectors/Vector.php | 35 ++ 161 files changed, 9334 insertions(+), 25 deletions(-) create mode 100644 database/migrations/2024_07_26_184745_create_seopro_entry_embeddings_table.php create mode 100644 database/migrations/2024_08_10_154109_create_seopro_entry_links_table.php create mode 100644 database/migrations/2024_08_17_123712_create_seopro_entry_keywords_table.php create mode 100644 database/migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php create mode 100644 database/migrations/2024_09_02_135056_create_seopro_global_automatic_links_table.php create mode 100644 database/migrations/2024_09_03_102233_create_seopro_collection_link_settings_table.php delete mode 100644 resources/dist/build/assets/cp-56146771.css delete mode 100644 resources/dist/build/assets/cp-7025c2cd.css delete mode 100644 resources/dist/build/assets/cp-c6df7fbd.js delete mode 100644 resources/dist/build/manifest.json create mode 100644 resources/dist/hot create mode 100644 resources/js/components/links/AutomaticLinkEditor.vue create mode 100644 resources/js/components/links/AutomaticLinksListing.vue create mode 100644 resources/js/components/links/ExternalLinkListing.vue create mode 100644 resources/js/components/links/FakesResources.vue create mode 100644 resources/js/components/links/InboundInternalLinks.vue create mode 100644 resources/js/components/links/InternalLinkListing.vue create mode 100644 resources/js/components/links/LinkDashboard.vue create mode 100644 resources/js/components/links/Listing.vue create mode 100644 resources/js/components/links/NavContainer.vue create mode 100644 resources/js/components/links/Overview.vue create mode 100644 resources/js/components/links/RelatedContent.vue create mode 100644 resources/js/components/links/SuggestionsListing.vue create mode 100644 resources/js/components/links/config/CollectionBehaviorEditor.vue create mode 100644 resources/js/components/links/config/CollectionBehaviorListing.vue create mode 100644 resources/js/components/links/config/ConfigResetter.vue create mode 100644 resources/js/components/links/config/EntryConfigEditor.vue create mode 100644 resources/js/components/links/config/SiteConfigEditor.vue create mode 100644 resources/js/components/links/config/SiteConfigListing.vue create mode 100644 resources/js/components/links/suggestions/IgnoreConfirmation.vue create mode 100644 resources/js/components/links/suggestions/SuggestionEditor.vue create mode 100644 resources/views/config/link_collections.blade.php create mode 100644 resources/views/config/sites.blade.php create mode 100644 resources/views/linking/automatic.blade.php create mode 100644 resources/views/linking/dashboard.blade.php create mode 100644 resources/views/linking/index.blade.php create mode 100644 resources/views/links/html.antlers.html create mode 100644 resources/views/links/markdown.antlers.html create mode 100644 src/Actions/ViewLinkSuggestions.php create mode 100644 src/Commands/GenerateEmbeddingsCommand.php create mode 100644 src/Commands/GenerateKeywordsCommand.php create mode 100644 src/Commands/ScanLinksCommand.php create mode 100644 src/Commands/StartTheEnginesCommand.php create mode 100644 src/Contracts/TextProcessing/ConfigurationRepository.php create mode 100644 src/Contracts/TextProcessing/Content/ContentRetriever.php create mode 100644 src/Contracts/TextProcessing/Content/FieldtypeContentMapper.php create mode 100644 src/Contracts/TextProcessing/Content/Tokenizer.php create mode 100644 src/Contracts/TextProcessing/Embeddings/EntryEmbeddingsRepository.php create mode 100644 src/Contracts/TextProcessing/Embeddings/Extractor.php create mode 100644 src/Contracts/TextProcessing/Keywords/KeywordRetriever.php create mode 100644 src/Contracts/TextProcessing/Keywords/KeywordsRepository.php create mode 100644 src/Contracts/TextProcessing/Links/FieldtypeLinkReplacer.php create mode 100644 src/Contracts/TextProcessing/Links/GlobalAutomaticLinksRepository.php create mode 100644 src/Contracts/TextProcessing/Links/LinkCrawler.php create mode 100644 src/Contracts/TextProcessing/Links/LinksRepository.php create mode 100644 src/Hooks/CP/EntryLinksIndexQuery.php create mode 100644 src/Hooks/Keywords/StopWordsHook.php create mode 100644 src/Http/Concerns/MergesBlueprintFields.php create mode 100644 src/Http/Controllers/Linking/CollectionLinkSettingsController.php create mode 100644 src/Http/Controllers/Linking/GlobalAutomaticLinksController.php create mode 100644 src/Http/Controllers/Linking/IgnoredSuggestionsController.php create mode 100644 src/Http/Controllers/Linking/LinksController.php create mode 100644 src/Http/Controllers/Linking/SiteLinkSettingsController.php create mode 100644 src/Http/Requests/AutomaticLinkRequest.php create mode 100644 src/Http/Requests/IgnoreSuggestionRequest.php create mode 100644 src/Http/Requests/InsertLinkRequest.php create mode 100644 src/Http/Requests/UpdateCollectionBehaviorRequest.php create mode 100644 src/Http/Requests/UpdateEntryLinkRequest.php create mode 100644 src/Http/Requests/UpdateSiteConfigRequest.php create mode 100644 src/Http/Resources/BaseResourceCollection.php create mode 100644 src/Http/Resources/Links/AutomaticLinks.php create mode 100644 src/Http/Resources/Links/EntryLinks.php create mode 100644 src/Http/Resources/Links/ListedAutomaticLink.php create mode 100644 src/Http/Resources/Links/ListedEntryLink.php create mode 100644 src/Jobs/CleanupCollectionLinks.php create mode 100644 src/Jobs/CleanupEntryLinks.php create mode 100644 src/Jobs/CleanupSiteLinks.php create mode 100644 src/Jobs/Concerns/DispatchesSeoProJobs.php create mode 100644 src/Jobs/ScanEntryLinks.php create mode 100644 src/Listeners/CollectionDeletedListener.php create mode 100644 src/Listeners/EntryDeletedListener.php create mode 100644 src/Listeners/EntrySavedListener.php create mode 100644 src/Listeners/SiteDeletedListener.php create mode 100644 src/Query/Scopes/Filters/Collection.php create mode 100644 src/Query/Scopes/Filters/Fields.php create mode 100644 src/Query/Scopes/Filters/Site.php create mode 100644 src/Reporting/Linking/BaseLinkReport.php create mode 100644 src/Reporting/Linking/Concerns/ResolvesSimilarItems.php create mode 100644 src/Reporting/Linking/ExternalLinksReport.php create mode 100644 src/Reporting/Linking/InternalLinksReport.php create mode 100644 src/Reporting/Linking/RelatedContentReport.php create mode 100644 src/Reporting/Linking/ReportBuilder.php create mode 100644 src/Reporting/Linking/SuggestionsReport.php create mode 100644 src/SeoPro.php create mode 100644 src/TextProcessing/Concerns/ChecksForContentChanges.php create mode 100644 src/TextProcessing/Config/CollectionConfig.php create mode 100644 src/TextProcessing/Config/CollectionConfigBlueprint.php create mode 100644 src/TextProcessing/Config/ConfigurationRepository.php create mode 100644 src/TextProcessing/Config/EntryConfigBlueprint.php create mode 100644 src/TextProcessing/Config/SiteConfig.php create mode 100644 src/TextProcessing/Config/SiteConfigBlueprint.php create mode 100644 src/TextProcessing/Content/ContentMapper.php create mode 100644 src/TextProcessing/Content/ContentMatching.php create mode 100644 src/TextProcessing/Content/ContentRemoval.php create mode 100644 src/TextProcessing/Content/ContentRetriever.php create mode 100644 src/TextProcessing/Content/FieldIndex.php create mode 100644 src/TextProcessing/Content/LinkReplacement.php create mode 100644 src/TextProcessing/Content/LinkReplacer.php create mode 100644 src/TextProcessing/Content/LinkReplacers/BardReplacer.php create mode 100644 src/TextProcessing/Content/LinkReplacers/MarkdownReplacer.php create mode 100644 src/TextProcessing/Content/LinkReplacers/TextReplacer.php create mode 100644 src/TextProcessing/Content/LinkReplacers/TextareaReplacer.php create mode 100644 src/TextProcessing/Content/Mappers/AbstractFieldMapper.php create mode 100644 src/TextProcessing/Content/Mappers/BardFieldMapper.php create mode 100644 src/TextProcessing/Content/Mappers/Concerns/GetsSets.php create mode 100644 src/TextProcessing/Content/Mappers/GridFieldMapper.php create mode 100644 src/TextProcessing/Content/Mappers/MarkdownFieldMapper.php create mode 100644 src/TextProcessing/Content/Mappers/ReplicatorFieldMapper.php create mode 100644 src/TextProcessing/Content/Mappers/TextFieldMapper.php create mode 100644 src/TextProcessing/Content/Mappers/TextareaFieldMapper.php create mode 100644 src/TextProcessing/Content/Paths/ContentPath.php create mode 100644 src/TextProcessing/Content/Paths/ContentPathParser.php create mode 100644 src/TextProcessing/Content/Paths/ContentPathPart.php create mode 100644 src/TextProcessing/Content/ReplacementContext.php create mode 100644 src/TextProcessing/Content/RetrievedConfig.php create mode 100644 src/TextProcessing/Content/RetrievedField.php create mode 100644 src/TextProcessing/Content/Tokenizer.php create mode 100644 src/TextProcessing/Embeddings/EmbeddingsRepository.php create mode 100644 src/TextProcessing/Embeddings/OpenAiEmbeddings.php create mode 100644 src/TextProcessing/EntryQuery.php create mode 100644 src/TextProcessing/Keywords/KeywordComparator.php create mode 100644 src/TextProcessing/Keywords/KeywordsRepository.php create mode 100644 src/TextProcessing/Keywords/Rake.php create mode 100644 src/TextProcessing/Keywords/StopWordsBag.php create mode 100644 src/TextProcessing/Links/GlobalAutomaticLinksBlueprint.php create mode 100644 src/TextProcessing/Links/GlobalAutomaticLinksRepository.php create mode 100644 src/TextProcessing/Links/IgnoredSuggestion.php create mode 100644 src/TextProcessing/Links/LinkBlueprint.php create mode 100644 src/TextProcessing/Links/LinkCrawler.php create mode 100644 src/TextProcessing/Links/LinkRepository.php create mode 100644 src/TextProcessing/Models/AutomaticLink.php create mode 100644 src/TextProcessing/Models/CollectionLinkSettings.php create mode 100644 src/TextProcessing/Models/EntryEmbedding.php create mode 100644 src/TextProcessing/Models/EntryKeyword.php create mode 100644 src/TextProcessing/Models/EntryLink.php create mode 100644 src/TextProcessing/Models/SiteLinkSetting.php create mode 100644 src/TextProcessing/Similarity/CosineSimilarity.php create mode 100644 src/TextProcessing/Similarity/ResolverOptions.php create mode 100644 src/TextProcessing/Similarity/Result.php create mode 100644 src/TextProcessing/Suggestions/LinkResults.php create mode 100644 src/TextProcessing/Suggestions/PhraseContext.php create mode 100644 src/TextProcessing/Suggestions/SuggestionEngine.php create mode 100644 src/TextProcessing/Vectors/Vector.php diff --git a/composer.json b/composer.json index 547dc0a7..1602bd41 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", @@ -30,7 +33,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 fc443d03..0b492e4b 100644 --- a/config/seo-pro.php +++ b/config/seo-pro.php @@ -47,4 +47,46 @@ 'queue_chunk_size' => 1000, ], + 'jobs' => [ + 'connection' => env('SEO_PRO_JOB_CONNECTION'), + 'queue' => env('SEO_PRO_JOB_QUEUE'), + ], + + 'text_analysis' => [ + + 'openai' => [ + 'api_key' => env('SEO_PRO_OPENAI_API_KEY'), + 'model' => 'text-embedding-3-small', + 'token_limit' => 8000, + ], + + 'keyword_threshold' => 65, + + 'internal_links' => [ + 'min_desired' => 3, + 'max_desired' => 6, + ], + + 'external_links' => [ + 'min_desired' => 0, + 'max_desired' => 3, + ], + + '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\TextProcessing\Content\Tokenizer::class, + 'content' => \Statamic\SeoPro\TextProcessing\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..49560a5f --- /dev/null +++ b/database/migrations/2024_09_02_135012_create_seopro_site_link_settings_table.php @@ -0,0 +1,34 @@ +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->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/dist/hot b/resources/dist/hot new file mode 100644 index 00000000..f762bcfc --- /dev/null +++ b/resources/dist/hot @@ -0,0 +1 @@ +http://[::1]:5173 \ 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..6c72c894 --- /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..466ba91e --- /dev/null +++ b/resources/js/components/links/InternalLinkListing.vue @@ -0,0 +1,70 @@ + + + \ 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..8b6f27c1 --- /dev/null +++ b/resources/js/components/links/Listing.vue @@ -0,0 +1,165 @@ + + + \ 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/RelatedContent.vue b/resources/js/components/links/RelatedContent.vue new file mode 100644 index 00000000..d8d1d0c4 --- /dev/null +++ b/resources/js/components/links/RelatedContent.vue @@ -0,0 +1,74 @@ + + + \ 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..4404fb9b --- /dev/null +++ b/resources/js/components/links/SuggestionsListing.vue @@ -0,0 +1,139 @@ + + + \ 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..f3b44a45 --- /dev/null +++ b/resources/js/components/links/config/SiteConfigEditor.vue @@ -0,0 +1,105 @@ + + + \ 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..86e61b40 --- /dev/null +++ b/resources/js/components/links/suggestions/IgnoreConfirmation.vue @@ -0,0 +1,128 @@ + + + \ 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..65768946 --- /dev/null +++ b/resources/js/components/links/suggestions/SuggestionEditor.vue @@ -0,0 +1,505 @@ + + + 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..db6a0393 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -42,6 +42,17 @@ @endcan + @can('view seo links') + +
+ @cp_svg('icons/light/link') +
+
+

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

+

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

+
+
+ @endcan 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/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..991231ba 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'); + }); +}); \ No newline at end of file diff --git a/src/Actions/ViewLinkSuggestions.php b/src/Actions/ViewLinkSuggestions.php new file mode 100644 index 00000000..918fbd24 --- /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; + } +} \ No newline at end of file diff --git a/src/Commands/GenerateEmbeddingsCommand.php b/src/Commands/GenerateEmbeddingsCommand.php new file mode 100644 index 00000000..3b00e190 --- /dev/null +++ b/src/Commands/GenerateEmbeddingsCommand.php @@ -0,0 +1,40 @@ +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..ab9e0796 --- /dev/null +++ b/src/Commands/GenerateKeywordsCommand.php @@ -0,0 +1,40 @@ +line('Generating...'); + + $keywords->generateKeywordsForAllEntries(); + + $this->info('Keywords generated.'); + } +} \ No newline at end of file diff --git a/src/Commands/ScanLinksCommand.php b/src/Commands/ScanLinksCommand.php new file mode 100644 index 00000000..ae038ec3 --- /dev/null +++ b/src/Commands/ScanLinksCommand.php @@ -0,0 +1,26 @@ +line('Scanning links...'); + + $crawler->scanAllEntries(); + + $this->info('Links scanned.'); + } + +} \ No newline at end of file diff --git a/src/Commands/StartTheEnginesCommand.php b/src/Commands/StartTheEnginesCommand.php new file mode 100644 index 00000000..66ad11e5 --- /dev/null +++ b/src/Commands/StartTheEnginesCommand.php @@ -0,0 +1,34 @@ +line('Getting things ready...'); + + $crawler->scanAllEntries(); + $keywordsRepository->generateKeywordsForAllEntries(); + $entryEmbeddingsRepository->generateEmbeddingsForAllEntries(); + + $this->info('Vroom vroom.'); + } + +} \ No newline at end of file diff --git a/src/Contracts/TextProcessing/ConfigurationRepository.php b/src/Contracts/TextProcessing/ConfigurationRepository.php new file mode 100644 index 00000000..ed8b7987 --- /dev/null +++ b/src/Contracts/TextProcessing/ConfigurationRepository.php @@ -0,0 +1,32 @@ +runHooksWith('query', [ + 'query' => $this->query, + ]); + + + return $payload->query->paginate($perPage); + } +} \ No newline at end of file diff --git a/src/Hooks/Keywords/StopWordsHook.php b/src/Hooks/Keywords/StopWordsHook.php new file mode 100644 index 00000000..fc4f53b7 --- /dev/null +++ b/src/Hooks/Keywords/StopWordsHook.php @@ -0,0 +1,24 @@ +runHooksWith('loading', [ + 'stopWords' => $this->stopWords, + ]); + + return $payload->stopWords->toArray(); + } +} \ No newline at end of file diff --git a/src/Http/Concerns/MergesBlueprintFields.php b/src/Http/Concerns/MergesBlueprintFields.php new file mode 100644 index 00000000..ae2a342e --- /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, + ]); + } +} \ No newline at end of file diff --git a/src/Http/Controllers/Linking/CollectionLinkSettingsController.php b/src/Http/Controllers/Linking/CollectionLinkSettingsController.php new file mode 100644 index 00000000..e8ba6c35 --- /dev/null +++ b/src/Http/Controllers/Linking/CollectionLinkSettingsController.php @@ -0,0 +1,60 @@ +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); + } +} \ No newline at end of file diff --git a/src/Http/Controllers/Linking/GlobalAutomaticLinksController.php b/src/Http/Controllers/Linking/GlobalAutomaticLinksController.php new file mode 100644 index 00000000..031bf7f9 --- /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(); + } +} \ No newline at end of file diff --git a/src/Http/Controllers/Linking/IgnoredSuggestionsController.php b/src/Http/Controllers/Linking/IgnoredSuggestionsController.php new file mode 100644 index 00000000..541e59c8 --- /dev/null +++ b/src/Http/Controllers/Linking/IgnoredSuggestionsController.php @@ -0,0 +1,36 @@ +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'] + )); + } +} \ No newline at end of file diff --git a/src/Http/Controllers/Linking/LinksController.php b/src/Http/Controllers/Linking/LinksController.php new file mode 100644 index 00000000..c9f4781d --- /dev/null +++ b/src/Http/Controllers/Linking/LinksController.php @@ -0,0 +1,305 @@ + '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').'%'); + }); + } + + $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))->suggestions(); + } + + return $this->makeDashboardResponse($entryId, 'suggestions', 'Link Suggestions'); + } + + public function getLinkFieldDetails($entryId, $fieldPath) + { + $entry = Entry::findOrFail($entryId); + + return $this->contentMapper->getFieldConfigForEntry($entry, $fieldPath)?->toArray() ?? []; + } + + public function getRelatedContent($entryId) + { + if (request()->ajax()) { + return $this->reportBuilder->getRelatedContentReport(Entry::findOrFail($entryId))->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, + ]; + } +} \ No newline at end of file diff --git a/src/Http/Controllers/Linking/SiteLinkSettingsController.php b/src/Http/Controllers/Linking/SiteLinkSettingsController.php new file mode 100644 index 00000000..d6c691d4 --- /dev/null +++ b/src/Http/Controllers/Linking/SiteLinkSettingsController.php @@ -0,0 +1,63 @@ +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'), + ) + ); + } + + public function resetConfig($site) + { + abort_unless($site, 404); + + $this->configurationRepository->resetSiteConfiguration($site); + } +} \ No newline at end of file diff --git a/src/Http/Requests/AutomaticLinkRequest.php b/src/Http/Requests/AutomaticLinkRequest.php new file mode 100644 index 00000000..4ef508bd --- /dev/null +++ b/src/Http/Requests/AutomaticLinkRequest.php @@ -0,0 +1,18 @@ + 'required', + 'is_active' => 'boolean', + 'link_text' => 'required', + 'link_target' => 'required', + ]; + } +} \ No newline at end of file diff --git a/src/Http/Requests/IgnoreSuggestionRequest.php b/src/Http/Requests/IgnoreSuggestionRequest.php new file mode 100644 index 00000000..bf07ad99 --- /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', + ]; + } +} \ No newline at end of file diff --git a/src/Http/Requests/InsertLinkRequest.php b/src/Http/Requests/InsertLinkRequest.php new file mode 100644 index 00000000..246e6efd --- /dev/null +++ b/src/Http/Requests/InsertLinkRequest.php @@ -0,0 +1,19 @@ + 'required', + 'phrase' => 'required', + 'target' => 'required', + 'field' => 'required', + 'auto_link' => 'boolean', + ]; + } +} \ No newline at end of file diff --git a/src/Http/Requests/UpdateCollectionBehaviorRequest.php b/src/Http/Requests/UpdateCollectionBehaviorRequest.php new file mode 100644 index 00000000..ad0c60f6 --- /dev/null +++ b/src/Http/Requests/UpdateCollectionBehaviorRequest.php @@ -0,0 +1,17 @@ + 'required|boolean', + 'allow_cross_collection_suggestions' => 'required|boolean', + 'allowed_collections' => 'array' + ]; + } +} \ No newline at end of file diff --git a/src/Http/Requests/UpdateEntryLinkRequest.php b/src/Http/Requests/UpdateEntryLinkRequest.php new file mode 100644 index 00000000..e245912c --- /dev/null +++ b/src/Http/Requests/UpdateEntryLinkRequest.php @@ -0,0 +1,16 @@ + 'required|boolean', + 'include_in_reporting' => 'required|boolean', + ]; + } +} \ No newline at end of file diff --git a/src/Http/Requests/UpdateSiteConfigRequest.php b/src/Http/Requests/UpdateSiteConfigRequest.php new file mode 100644 index 00000000..37304a76 --- /dev/null +++ b/src/Http/Requests/UpdateSiteConfigRequest.php @@ -0,0 +1,20 @@ + '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', + ]; + } +} \ No newline at end of file diff --git a/src/Http/Resources/BaseResourceCollection.php b/src/Http/Resources/BaseResourceCollection.php new file mode 100644 index 00000000..e2054a76 --- /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(), + ], + ]; + } +} \ No newline at end of file diff --git a/src/Http/Resources/Links/AutomaticLinks.php b/src/Http/Resources/Links/AutomaticLinks.php new file mode 100644 index 00000000..3cbae65d --- /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(); + } +} \ No newline at end of file diff --git a/src/Http/Resources/Links/EntryLinks.php b/src/Http/Resources/Links/EntryLinks.php new file mode 100644 index 00000000..e1922414 --- /dev/null +++ b/src/Http/Resources/Links/EntryLinks.php @@ -0,0 +1,35 @@ +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(); + } +} \ No newline at end of file diff --git a/src/Http/Resources/Links/ListedAutomaticLink.php b/src/Http/Resources/Links/ListedAutomaticLink.php new file mode 100644 index 00000000..bbbe4790 --- /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, + ]; + } +} \ No newline at end of file diff --git a/src/Http/Resources/Links/ListedEntryLink.php b/src/Http/Resources/Links/ListedEntryLink.php new file mode 100644 index 00000000..e775b96f --- /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, + ]; + } +} \ No newline at end of file diff --git a/src/Jobs/CleanupCollectionLinks.php b/src/Jobs/CleanupCollectionLinks.php new file mode 100644 index 00000000..440b1b4a --- /dev/null +++ b/src/Jobs/CleanupCollectionLinks.php @@ -0,0 +1,37 @@ +handle) { + return; + } + + $configurationRepository->deleteCollectionConfiguration($this->handle); + $linksRepository->deleteLinksForCollection($this->handle); + $keywordsRepository->deleteKeywordsForCollection($this->handle); + $entryEmbeddingsRepository->deleteEmbeddingsForCollection($this->handle); + } +} \ No newline at end of file diff --git a/src/Jobs/CleanupEntryLinks.php b/src/Jobs/CleanupEntryLinks.php new file mode 100644 index 00000000..592243a9 --- /dev/null +++ b/src/Jobs/CleanupEntryLinks.php @@ -0,0 +1,30 @@ +deleteLinksForEntry($this->entryId); + $keywordsRepository->deleteKeywordsForEntry($this->entryId); + $entryEmbeddingsRepository->deleteEmbeddingsForEntry($this->entryId); + } +} \ No newline at end of file diff --git a/src/Jobs/CleanupSiteLinks.php b/src/Jobs/CleanupSiteLinks.php new file mode 100644 index 00000000..039d0c0f --- /dev/null +++ b/src/Jobs/CleanupSiteLinks.php @@ -0,0 +1,33 @@ +deleteSiteConfiguration($this->handle); + $linksRepository->deleteLinksForSite($this->handle); + $keywordsRepository->deleteKeywordsForSite($this->handle); + $entryEmbeddingsRepository->deleteEmbeddingsForSite($this->handle); + } +} \ No newline at end of file diff --git a/src/Jobs/Concerns/DispatchesSeoProJobs.php b/src/Jobs/Concerns/DispatchesSeoProJobs.php new file mode 100644 index 00000000..9696dc3c --- /dev/null +++ b/src/Jobs/Concerns/DispatchesSeoProJobs.php @@ -0,0 +1,28 @@ +onConnection($connection) + ->onQueue($queue ?? 'default'); + } +} \ No newline at end of file diff --git a/src/Jobs/ScanEntryLinks.php b/src/Jobs/ScanEntryLinks.php new file mode 100644 index 00000000..c0c48c70 --- /dev/null +++ b/src/Jobs/ScanEntryLinks.php @@ -0,0 +1,42 @@ +entryId); + + if (! $entry) { + return; + } + + $linkCrawler->scanEntry($entry); + $linkCrawler->updateInboundInternalLinkCount($entry); + + if ($linksRepository->isLinkingEnabledForEntry($entry)) { + $keywordsRepository->generateKeywordsForEntry($entry); + $entryEmbeddingsRepository->generateEmbeddingsForEntry($entry); + } + } +} \ No newline at end of file diff --git a/src/Listeners/CollectionDeletedListener.php b/src/Listeners/CollectionDeletedListener.php new file mode 100644 index 00000000..a2b10cac --- /dev/null +++ b/src/Listeners/CollectionDeletedListener.php @@ -0,0 +1,14 @@ +collection->handle()); + } +} \ No newline at end of file diff --git a/src/Listeners/EntryDeletedListener.php b/src/Listeners/EntryDeletedListener.php new file mode 100644 index 00000000..9ff9ed57 --- /dev/null +++ b/src/Listeners/EntryDeletedListener.php @@ -0,0 +1,14 @@ +entry->id()); + } +} \ No newline at end of file diff --git a/src/Listeners/EntrySavedListener.php b/src/Listeners/EntrySavedListener.php new file mode 100644 index 00000000..215ed736 --- /dev/null +++ b/src/Listeners/EntrySavedListener.php @@ -0,0 +1,14 @@ +entry->id()); + } +} \ No newline at end of file diff --git a/src/Listeners/SiteDeletedListener.php b/src/Listeners/SiteDeletedListener.php new file mode 100644 index 00000000..bc20f3ef --- /dev/null +++ b/src/Listeners/SiteDeletedListener.php @@ -0,0 +1,14 @@ +site->handle()); + } +} \ No newline at end of file diff --git a/src/Query/Scopes/Filters/Collection.php b/src/Query/Scopes/Filters/Collection.php new file mode 100644 index 00000000..d8e06d15 --- /dev/null +++ b/src/Query/Scopes/Filters/Collection.php @@ -0,0 +1,13 @@ +availableSites()->count() > 0; + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/BaseLinkReport.php b/src/Reporting/Linking/BaseLinkReport.php new file mode 100644 index 00000000..ac6a6c1d --- /dev/null +++ b/src/Reporting/Linking/BaseLinkReport.php @@ -0,0 +1,110 @@ +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 + { + 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()); + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/Concerns/ResolvesSimilarItems.php b/src/Reporting/Linking/Concerns/ResolvesSimilarItems.php new file mode 100644 index 00000000..44faedf4 --- /dev/null +++ b/src/Reporting/Linking/Concerns/ResolvesSimilarItems.php @@ -0,0 +1,128 @@ + [], + '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): Collection + { + $entryId = $entry->id(); + $targetVectors = $this->embeddingsRepository->getEmbeddingsForEntry($entry)?->vector() ?? []; + + $tmpMapping = []; + + /** @var Vector $vector */ + foreach ($this->embeddingsRepository->getRelatedEmbeddingsForEntryLazy($entry) 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); + unset($tmpMapping); + + return collect(array_values($results)); + } + + protected function addKeywordsToResults(Entry $entry, Collection $results, ?ResolverOptions $options = null): Collection + { + if (! $options) { + $options = new ResolverOptions(); + } + + $entryIds = array_merge([$entry->id()], $results->map(fn(Result $result) => $result->entry()->id())->all()); + $ignoredKeywords = array_flip($this->keywordsRepository->getIgnoredKeywordsForEntry($entry)); + /** @var $keywords */ + $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(); + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/ExternalLinksReport.php b/src/Reporting/Linking/ExternalLinksReport.php new file mode 100644 index 00000000..65000033 --- /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(), + ]; + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/InternalLinksReport.php b/src/Reporting/Linking/InternalLinksReport.php new file mode 100644 index 00000000..092754ca --- /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(), + ]; + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/RelatedContentReport.php b/src/Reporting/Linking/RelatedContentReport.php new file mode 100644 index 00000000..a0737d0c --- /dev/null +++ b/src/Reporting/Linking/RelatedContentReport.php @@ -0,0 +1,36 @@ +fluentlyGetOrSet('relatedContent') + ->args(func_get_args()); + } + + public function getRelated(): array + { + return collect($this->relatedContent)->map(function (Result $result) { + return [ + '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(), + ]; + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/ReportBuilder.php b/src/Reporting/Linking/ReportBuilder.php new file mode 100644 index 00000000..c025c732 --- /dev/null +++ b/src/Reporting/Linking/ReportBuilder.php @@ -0,0 +1,215 @@ +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 $limit, ?ResolverOptions $options = null): Collection + { + return $this->addKeywordsToResults( + $entry, + $this->findSimilarTo($entry, $limit), + $options + )->take($limit); + } + + public function getSuggestionsReport(Entry $entry, int $limit = 10): SuggestionsReport + { + $report = new SuggestionsReport(); + + $siteConfig = $this->configurationRepository->getSiteConfiguration($entry->site()?->handle() ?? 'default'); + + $suggestions = $this->suggestionEngine + ->withResults($this->getResolvedSimilarItems($entry, $limit, new ResolverOptions(keywordThreshold: $siteConfig->keywordThreshold / 100))) + ->suggest($entry); + + $this->fillBaseReportData($entry, $report); + + $report->suggestions($suggestions->all()); + + return $report; + } + + public function getRelatedContentReport(Entry $entry, int $limit = 10): RelatedContentReport + { + $report = new RelatedContentReport(); + + $report->relatedContent($this->getResolvedSimilarItems($entry, $limit, 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; + $entryLinks = EntryLink::whereJsonContains('internal_links', $targetUri)->get(); + $matches = []; + $lookupIds = []; + + foreach ($entryLinks as $link) { + if (str_starts_with($link, '#')) { + continue; + } + + if ($link->id === $this->lastLinks->id) { + continue; + } + + foreach ($link->internal_links as $linkUri) { + if ($linkUri === $targetUri) { + $lookupIds[$link->entry_id] = 1; + + $matches[] = [ + 'entry_id' => $link->entry_id, + 'uri' => $linkUri + ]; + + break; + } + } + } + + 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 = []; + $results = []; + + foreach ($this->lastLinks->internal_links as $link) { + if (str_starts_with($link, '#')) { + continue; + } + + $uri = Str::before($link, '#'); + + $toLookup[] = [ + 'original' => $link, + 'uri' => $uri, + ]; + + $uris[] = $uri; + } + + $entries = EntryApi::query()->whereIn('uri', $uris)->get()->keyBy('uri')->all(); + + foreach ($toLookup as $link) { + $results[] = [ + 'entry' => $entries[$link['uri']] ?? null, + 'uri' => $link['original'], + ]; + } + + $report->internalLinks($results); + + return $report; + } +} \ No newline at end of file diff --git a/src/Reporting/Linking/SuggestionsReport.php b/src/Reporting/Linking/SuggestionsReport.php new file mode 100644 index 00000000..c7337538 --- /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, + ]; + } +} \ No newline at end of file diff --git a/src/SeoPro.php b/src/SeoPro.php new file mode 100644 index 00000000..624b07e7 --- /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; + protected $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, + ], + ]; + public function bootAddon() { $this ->bootAddonConfig() + ->bootAddonMigrations() ->bootAddonViews() ->bootAddonBladeDirective() ->bootAddonPermissions() @@ -55,7 +81,108 @@ public function bootAddon() ->bootAddonSubscriber() ->bootAddonGlidePresets() ->bootAddonCommands() - ->bootAddonGraphQL(); + ->bootAddonGraphQL() + ->bootTextAnalysis(); + } + + protected function bootTextAnalysis() + { + SeoPro\Actions\ViewLinkSuggestions::register(); + + $this->app->bind( + Contracts\TextProcessing\Content\ContentRetriever::class, + config('statamic.seo-pro.text_analysis.drivers.content'), + ); + + $this->app->bind( + Contracts\TextProcessing\Content\Tokenizer::class, + config('statamic.seo-pro.text_analysis.drivers.tokenizer'), + ); + + $this->app->bind( + Contracts\TextProcessing\Embeddings\Extractor::class, + config('statamic.seo-pro.text_analysis.drivers.embeddings'), + ); + + $this->app->bind( + Contracts\TextProcessing\Keywords\KeywordRetriever::class, + config('statamic.seo-pro.text_analysis.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.text_analysis.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(SeoPro\TextProcessing\Content\ContentMapper::class, function () { + return new SeoPro\TextProcessing\Content\ContentMapper(); + }); + + $this->app->singleton(SeoPro\TextProcessing\Content\LinkReplacer::class, function () { + return new SeoPro\TextProcessing\Content\LinkReplacer( + app(SeoPro\TextProcessing\Content\ContentMapper::class), + ); + }); + + return $this->registerDefaultFieldtypeReplacers() + ->registerDefaultContentMappers(); + } + + protected function registerDefaultFieldtypeReplacers(): static + { + /** @var SeoPro\TextProcessing\Content\LinkReplacer $linkReplacer */ + $linkReplacer = $this->app->make(SeoPro\TextProcessing\Content\LinkReplacer::class); + + $linkReplacer->registerReplacers([ + SeoPro\TextProcessing\Content\LinkReplacers\MarkdownReplacer::class, + SeoPro\TextProcessing\Content\LinkReplacers\TextReplacer::class, + SeoPro\TextProcessing\Content\LinkReplacers\TextareaReplacer::class, + ]); + + return $this; + } + + protected function registerDefaultContentMappers(): static + { + /** @var SeoPro\TextProcessing\Content\ContentMapper $contentMapper */ + $contentMapper = $this->app->make(SeoPro\TextProcessing\Content\ContentMapper::class); + + $contentMapper->registerMappers([ + SeoPro\TextProcessing\Content\Mappers\TextFieldMapper::class, + SeoPro\TextProcessing\Content\Mappers\TextareaFieldMapper::class, + SeoPro\TextProcessing\Content\Mappers\MarkdownFieldMapper::class, + SeoPro\TextProcessing\Content\Mappers\GridFieldMapper::class, + SeoPro\TextProcessing\Content\Mappers\ReplicatorFieldMapper::class, + SeoPro\TextProcessing\Content\Mappers\BardFieldMapper::class, + ]); + + return $this; } protected function bootAddonConfig() @@ -69,12 +196,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; @@ -116,6 +259,7 @@ protected function bootAddonNav() $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'), + $nav->item(__('seo-pro::messages.link_manager'))->route('seo-pro.internal-links.index')->can('view seo links'), ]; }); } @@ -155,6 +299,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..73baab03 100755 --- a/src/Tags/SeoProTags.php +++ b/src/Tags/SeoProTags.php @@ -5,6 +5,7 @@ use Statamic\SeoPro\Cascade; use Statamic\SeoPro\GetsSectionDefaults; use Statamic\SeoPro\RendersMetaHtml; +use Statamic\SeoPro\SeoPro; use Statamic\SeoPro\SiteDefaults; use Statamic\Tags\Tags; @@ -62,6 +63,15 @@ public function dumpMetaData() return dd($this->metaData()); } + public function content() + { + if (! SeoPro::isSeoProProcess()) { + return $this->parse(); + } + + return ''.$this->parse().''; + } + /** * 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..6f950b7a --- /dev/null +++ b/src/TextProcessing/Concerns/ChecksForContentChanges.php @@ -0,0 +1,18 @@ +exists) { + return false; + } + + return $this->contentRetriever->hashContent($content) === $model->content_hash; + } +} \ No newline at end of file diff --git a/src/TextProcessing/Config/CollectionConfig.php b/src/TextProcessing/Config/CollectionConfig.php new file mode 100644 index 00000000..647465fa --- /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, + ]; + } +} \ No newline at end of file diff --git a/src/TextProcessing/Config/CollectionConfigBlueprint.php b/src/TextProcessing/Config/CollectionConfigBlueprint.php new file mode 100644 index 00000000..eede1456 --- /dev/null +++ b/src/TextProcessing/Config/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', + ], + ], + ], + ], + ], + ], + ]); + } +} \ No newline at end of file diff --git a/src/TextProcessing/Config/ConfigurationRepository.php b/src/TextProcessing/Config/ConfigurationRepository.php new file mode 100644 index 00000000..e0ad6028 --- /dev/null +++ b/src/TextProcessing/Config/ConfigurationRepository.php @@ -0,0 +1,220 @@ +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::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) + { + /** @var CollectionLinkSettings $collectionSettings */ + $collectionSettings = CollectionLinkSettings::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, + ); + } + + protected function makeDefaultSiteConfig(string $handle, string $name): SiteConfig + { + return new SiteConfig( + $handle, + $name, + [], + config('statamic.seo-pro.text_analysis.keyword_threshold', 65), + config('statamic.seo-pro.text_analysis.internal_links.min_desired', 3), + config('statamic.seo-pro.text_analysis.internal_links.max_desired', 6), + config('statamic.seo-pro.text_analysis.external_links.min_desired', 0), + config('statamic.seo-pro.text_analysis.external_links.max_desired', 0), + ); + } + + public function getSiteConfiguration(string $handle): ?SiteConfig + { + $config = SiteLinkSetting::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) + { + /** @var SiteLinkSetting $siteSettings */ + $siteSettings = SiteLinkSetting::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->saveQuietly(); + } + + public function getDisabledCollections(): array + { + $disabled = CollectionLinkSettings::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) + { + SiteLinkSetting::where('site', $handle)->delete(); + } + + public function deleteCollectionConfiguration(string $handle) + { + CollectionLinkSettings::where('collection', $handle)->delete(); + } + + public function resetSiteConfiguration(string $handle) + { + /** @var SiteLinkSetting $settings */ + $settings = SiteLinkSetting::where('site', $handle)->first(); + + if (! $settings) { + return; + } + + $settings->keyword_threshold = config('statamic.seo-pro.text_analysis.keyword_threshold', 65) / 100; + $settings->min_internal_links = config('statamic.seo-pro.text_analysis.internal_links.min_desired', 3); + $settings->max_internal_links = config('statamic.seo-pro.text_analysis.internal_links.max_desired', 6); + $settings->min_external_links = config('statamic.seo-pro.text_analysis.external_links.min_desired', 0); + $settings->max_external_links = config('statamic.seo-pro.text_analysis.external_links.max_desired', 0); + + $settings->ignored_phrases = []; + + $settings->save(); + } + + public function resetCollectionConfiguration(string $handle) + { + /** @var CollectionLinkSettings $settings */ + $settings = CollectionLinkSettings::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(); + } +} \ No newline at end of file diff --git a/src/TextProcessing/Config/EntryConfigBlueprint.php b/src/TextProcessing/Config/EntryConfigBlueprint.php new file mode 100644 index 00000000..dbb29316 --- /dev/null +++ b/src/TextProcessing/Config/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, + ], + ], + ], + ], + ], + ]); + } +} \ No newline at end of file diff --git a/src/TextProcessing/Config/SiteConfig.php b/src/TextProcessing/Config/SiteConfig.php new file mode 100644 index 00000000..f3bfa73a --- /dev/null +++ b/src/TextProcessing/Config/SiteConfig.php @@ -0,0 +1,31 @@ + $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, + ]; + } +} \ No newline at end of file diff --git a/src/TextProcessing/Config/SiteConfigBlueprint.php b/src/TextProcessing/Config/SiteConfigBlueprint.php new file mode 100644 index 00000000..36d21f33 --- /dev/null +++ b/src/TextProcessing/Config/SiteConfigBlueprint.php @@ -0,0 +1,79 @@ +setContents([ + 'sections' => [ + 'thresholds' => [ + 'fields' => [ + [ + 'handle' => 'keyword_threshold', + 'field' => [ + 'display' => 'Keyword Threshold', + 'type' => 'range', + 'default' => config('statamic.seo-pro.text_analysis.keyword_threshold', 65), + ], + ], + ], + ], + 'settings' => [ + 'fields' => [ + [ + 'handle' => 'min_internal_links', + 'field' => [ + 'display' => 'Min. Internal Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.text_analysis.internal_links.min_desired', 3), + 'width' => 50, + ], + ], + [ + 'handle' => 'max_internal_links', + 'field' => [ + 'display' => 'Max. Internal Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.text_analysis.internal_links.max_desired', 6), + 'width' => 50, + ], + ], + [ + 'handle' => 'min_external_links', + 'field' => [ + 'display' => 'Min. External Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.text_analysis.external_links.min_desired', 0), + 'width' => 50, + ], + ], + [ + 'handle' => 'max_external_links', + 'field' => [ + 'display' => 'Max. External Links', + 'type' => 'integer', + 'default' => config('statamic.seo-pro.text_analysis.external_links.max_desired', 0), + 'width' => 50, + ], + ], + ], + ], + 'phrases' => [ + 'fields' => [ + [ + 'handle' => 'ignored_phrases', + 'field' => [ + 'display' => 'Ignored Phrases', + 'type' => 'list', + ] + ], + ], + ], + ], + ]); + } +} \ No newline at end of file diff --git a/src/TextProcessing/Content/ContentMapper.php b/src/TextProcessing/Content/ContentMapper.php new file mode 100644 index 00000000..e0b37df9 --- /dev/null +++ b/src/TextProcessing/Content/ContentMapper.php @@ -0,0 +1,375 @@ +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 appendFieldType(string $type): static + { + $this->path[] = '{type:'.$type.'}'; + + return $this; + } + + 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): FieldtypeContentMapper + { + /** @var 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 append(string $handle): static + { + $this->path[] = $handle; + + return $this; + } + + 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): void + { + $valuePath = implode('', $this->path); + + if (count($this->pushedIndexStack) > 0) { + $this->contentMapping[$valuePath] = $value; + + return; + } + + $this->contentMapping[$valuePath] = $value; + $this->path = []; + } + + public function newMapper(): ContentMapper + { + $mapper = new ContentMapper(); + + $mapper->registerMappers($this->fieldtypeMappers); + + return $mapper; + } + + 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 (! array_key_exists('field', $field)) { + continue; + } + + $field = $field['field']; + $type = $field['type'] ?? null; + + if (! $type) { + continue; + } + + if (! array_key_exists($type, $this->fieldtypeMappers)) { + continue; + } + + $this->startFieldPath($handle); + + $this->getFieldtypeMapper($type) + ->withFieldConfig($field) + ->withValue($this->values[$handle] ?? null) + ->getContent(); + } + + return $this->contentMapping; + } + + public function getFieldConfigForEntry(Entry $entry, string $path): ?RetrievedConfig + { + $parsedPath = (new ContentPathParser())->parse($path); + $fields = $entry->blueprint()->fields()->all(); + $field = $fields[$parsedPath->root->name] ?? null; + + if (! $field) { + return null; + } + + return $this->getFieldConfig($field, $parsedPath); + } + + public function getFieldConfig(Field $field, ContentPath $path): RetrievedConfig + { + $config = $field->config(); + $names = []; + + // TODO: This can get refactored/cleaned up a bit. + // TODO: Groups/how to handle third-party fieldtypes/etc. + + if (array_key_exists('display', $config)) { + $names[] = $config['display']; + } + + /** @var ContentPathPart $part */ + foreach ($path->parts as $part) { + if ($part->isIndex()) { + continue; + } + + if ($part->isSet()) { + if (! array_key_exists('sets', $config)) { + break; + } + + $sets = $config['sets']; + $set = $part->getAttribute('set'); + + foreach ($sets as $group) { + foreach ($group['sets'] as $setName => $setConfig) { + if ($setName != $set) { + continue; + } + + $matchingField = collect($setConfig['fields'])->where('handle', $part->name)->first(); + + if (! $matchingField) { + $config = null; + break 3; + } + + $config = $matchingField['field']; + + if (array_key_exists('display', $config)) { + $names[] = $config['display']; + } + + break 2; + } + } + + continue; + } + + if (array_key_exists('fields', $config)) { + $matchingField = collect($config['fields'])->where('handle', $part->name)->first(); + + if (! $matchingField) { + $config = null; + break; + } + + $config = $matchingField['field']; + + if (array_key_exists('display', $config)) { + $names[] = $config['display']; + } + + continue; + } + } + + return new RetrievedConfig( + $config, + $names + ); + } + + public function getContentMapping(Entry $entry): array + { + $this->reset(); + + $this->indexValues($entry); + + $fields = $entry->blueprint()->fields()->all(); + + /** @var Field $field */ + foreach ($fields as $field) { + $type = $field->type(); + + if (! array_key_exists($type, $this->fieldtypeMappers)) { + continue; + } + + $this->startFieldPath($field->handle()); + + $this->getFieldtypeMapper($type) + ->withEntry($entry) + ->withFieldConfig($field->config()) + ->withValue($this->values[$field->handle()]?->raw() ?? null) + ->getContent(); + } + + return $this->contentMapping; + } + + public function getContentMappingIndexArray(Entry $entry) + { + return collect($this->getContentFields($entry)) + ->map(fn(RetrievedField $field) => $field->toArray()) + ->values() + ->all(); + } + + public function getContentFields(Entry $entry): Collection + { + return collect($this->getContentMapping($entry)) + ->map(fn($_, $path) => $this->retrieveField($entry, $path)); + } + + public function findFields(Entry $entry, string $pattern): Collection + { + $pattern = '/'.$pattern.'/'; + $results = []; + $mapping = $this->getContentMapping($entry); + + foreach ($mapping as $path => $value) { + if (! preg_match($pattern, $path)) { + continue; + } + + $results[] = $this->retrieveField($entry, $path); + } + + return collect($results); + } + + 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(), + ); + } +} \ No newline at end of file diff --git a/src/TextProcessing/Content/ContentMatching.php b/src/TextProcessing/Content/ContentMatching.php new file mode 100644 index 00000000..d2bc6d2c --- /dev/null +++ b/src/TextProcessing/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);
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/ContentRetriever.php b/src/TextProcessing/Content/ContentRetriever.php
new file mode 100644
index 00000000..4a8740dd
--- /dev/null
+++ b/src/TextProcessing/Content/ContentRetriever.php
@@ -0,0 +1,137 @@
+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);
+    }
+
+    public function getContentMappingIndexArray(Entry $entry): array
+    {
+        return $this->mapper->getContentMappingIndexArray($entry);
+    }
+
+    /**
+     * @param Entry $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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/FieldIndex.php b/src/TextProcessing/Content/FieldIndex.php
new file mode 100644
index 00000000..7da6ec12
--- /dev/null
+++ b/src/TextProcessing/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();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacement.php b/src/TextProcessing/Content/LinkReplacement.php
new file mode 100644
index 00000000..f308360a
--- /dev/null
+++ b/src/TextProcessing/Content/LinkReplacement.php
@@ -0,0 +1,43 @@
+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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacer.php b/src/TextProcessing/Content/LinkReplacer.php
new file mode 100644
index 00000000..9e8bd3b8
--- /dev/null
+++ b/src/TextProcessing/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));
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacers/BardReplacer.php b/src/TextProcessing/Content/LinkReplacers/BardReplacer.php
new file mode 100644
index 00000000..58f38a41
--- /dev/null
+++ b/src/TextProcessing/Content/LinkReplacers/BardReplacer.php
@@ -0,0 +1,27 @@
+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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacers/TextReplacer.php b/src/TextProcessing/Content/LinkReplacers/TextReplacer.php
new file mode 100644
index 00000000..f6fbbb84
--- /dev/null
+++ b/src/TextProcessing/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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacers/TextareaReplacer.php b/src/TextProcessing/Content/LinkReplacers/TextareaReplacer.php
new file mode 100644
index 00000000..e73325fe
--- /dev/null
+++ b/src/TextProcessing/Content/LinkReplacers/TextareaReplacer.php
@@ -0,0 +1,13 @@
+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 [];
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Mappers/BardFieldMapper.php b/src/TextProcessing/Content/Mappers/BardFieldMapper.php
new file mode 100644
index 00000000..51a0ede5
--- /dev/null
+++ b/src/TextProcessing/Content/Mappers/BardFieldMapper.php
@@ -0,0 +1,109 @@
+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;
+            }
+            $this->mapper->pushIndex($index);
+
+
+            if ($value['type'] === 'paragraph') {
+                $this->mapper->append('{node:paragraph}');
+                $this->mapper->finish($this->getParagraphContent($value));
+                $this->mapper->popIndex();
+            } else if ($value['type'] === 'set') {
+                $setValues = $value['attrs']['values'] ?? null;
+
+                if (! $setValues) {
+                    continue;
+                }
+
+                if (! array_key_exists('type', $setValues)) {
+                    continue;
+                }
+
+                if (! array_key_exists($setValues['type'], $sets)) {
+                    continue;
+                }
+
+
+                $this->mapper->append('{set:'.$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();
+            }
+        }
+    }
+
+    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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Mappers/Concerns/GetsSets.php b/src/TextProcessing/Content/Mappers/Concerns/GetsSets.php
new file mode 100644
index 00000000..d6d20904
--- /dev/null
+++ b/src/TextProcessing/Content/Mappers/Concerns/GetsSets.php
@@ -0,0 +1,28 @@
+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;
+    }
+
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Mappers/GridFieldMapper.php b/src/TextProcessing/Content/Mappers/GridFieldMapper.php
new file mode 100644
index 00000000..a87613e2
--- /dev/null
+++ b/src/TextProcessing/Content/Mappers/GridFieldMapper.php
@@ -0,0 +1,62 @@
+value)) {
+            return [];
+        }
+
+        return $this->value;
+    }
+
+    public function getContent(): void
+    {
+        $fields = collect($this->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)
+                    ->getFieldtypeMapper($fieldType)
+                    ->withFieldConfig($fields[$handle]['field'])
+                    ->withValue($value)
+                    ->getContent();
+
+                $this->mapper->dropNestingLevel();
+            }
+
+            $this->mapper->popIndex();
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Mappers/MarkdownFieldMapper.php b/src/TextProcessing/Content/Mappers/MarkdownFieldMapper.php
new file mode 100644
index 00000000..432ec6a1
--- /dev/null
+++ b/src/TextProcessing/Content/Mappers/MarkdownFieldMapper.php
@@ -0,0 +1,13 @@
+value)) {
+            return [];
+        }
+
+        return $this->value;
+    }
+
+    public function getContent(): void
+    {
+        $sets = $this->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);
+
+            $setFields = collect($set['fields'])->keyBy('handle')->all();
+            $values = collect($values)->except(['id', 'type', 'enabled'])->all();
+
+            foreach ($values as $fieldName => $fieldValue) {
+                if (! array_key_exists($fieldName, $setFields)) {
+                    continue;
+                }
+
+                $field = $setFields[$fieldName];
+                $type = $field['field']['type'] ?? null;
+
+                if (! $type) {
+                    continue;
+                }
+
+                $this->mapper
+                    ->append($fieldName)
+                    ->getFieldtypeMapper($type)
+                    ->withFieldConfig($field['field'])
+                    ->withValue($fieldValue)
+                    ->getContent();
+
+                $this->mapper->dropNestingLevel();
+            }
+            $this->mapper->popIndex();
+
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Mappers/TextFieldMapper.php b/src/TextProcessing/Content/Mappers/TextFieldMapper.php
new file mode 100644
index 00000000..f5d111be
--- /dev/null
+++ b/src/TextProcessing/Content/Mappers/TextFieldMapper.php
@@ -0,0 +1,19 @@
+mapper->finish($this->value ?? '');
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Mappers/TextareaFieldMapper.php b/src/TextProcessing/Content/Mappers/TextareaFieldMapper.php
new file mode 100644
index 00000000..42f73422
--- /dev/null
+++ b/src/TextProcessing/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 __toString(): string
+    {
+        return str(
+                collect($this->parts)
+                ->map(fn($part) => (string)$part)
+                ->implode('.')
+            )
+            ->trim('.')
+            ->value();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Paths/ContentPathParser.php b/src/TextProcessing/Content/Paths/ContentPathParser.php
new file mode 100644
index 00000000..02e15023
--- /dev/null
+++ b/src/TextProcessing/Content/Paths/ContentPathParser.php
@@ -0,0 +1,125 @@
+ implode($buffer),
+                            'meta' => $metaData,
+                            'type' => 'named',
+                        ];
+
+                        $metaData = [];
+
+                        $parts[] = $part;
+                        $buffer = [];
+                    }
+                }
+
+                continue;
+            }
+
+            if ($cur === '{') {
+                $inMeta = true;
+                $metaBuffer[] = $cur;
+
+                continue;
+            } else if ($next === null || $next === '[') {
+                $buffer[] = $cur;
+
+                $part = [
+                    'name' => implode($buffer),
+                    'meta' => $metaData,
+                    'type' => 'named',
+                ];
+
+                $metaData = [];
+
+                $parts[] = $part;
+                $buffer = [];
+
+                continue;
+            } else if ($cur === ']') {
+                array_shift($buffer);
+
+                $parts[] = [
+                    'name' => (int) 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']
+            ),
+        );
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Paths/ContentPathPart.php b/src/TextProcessing/Content/Paths/ContentPathPart.php
new file mode 100644
index 00000000..734a1752
--- /dev/null
+++ b/src/TextProcessing/Content/Paths/ContentPathPart.php
@@ -0,0 +1,49 @@
+metaData as $metaDatum) {
+            $item = mb_substr($metaDatum, 1, -1);
+            [$key, $val] = explode(':', $item, 2);
+
+            $this->attributes[$key] = $val;
+        }
+    }
+
+    public function isIndex(): bool
+    {
+        return $this->type === 'index';
+    }
+
+    public function isSet(): bool
+    {
+        return array_key_exists('set', $this->attributes);
+    }
+
+    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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/ReplacementContext.php b/src/TextProcessing/Content/ReplacementContext.php
new file mode 100644
index 00000000..09e610be
--- /dev/null
+++ b/src/TextProcessing/Content/ReplacementContext.php
@@ -0,0 +1,30 @@
+ $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());
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/RetrievedConfig.php b/src/TextProcessing/Content/RetrievedConfig.php
new file mode 100644
index 00000000..53bbb73b
--- /dev/null
+++ b/src/TextProcessing/Content/RetrievedConfig.php
@@ -0,0 +1,19 @@
+ $this->config,
+            'field_names' => $this->fieldNames,
+        ];
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/RetrievedField.php b/src/TextProcessing/Content/RetrievedField.php
new file mode 100644
index 00000000..fb20a138
--- /dev/null
+++ b/src/TextProcessing/Content/RetrievedField.php
@@ -0,0 +1,119 @@
+ $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)) {
+            Arr::set($data, $this->path, $newValue);
+        } else if (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();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/Tokenizer.php b/src/TextProcessing/Content/Tokenizer.php
new file mode 100644
index 00000000..300ffb6c
--- /dev/null
+++ b/src/TextProcessing/Content/Tokenizer.php
@@ -0,0 +1,51 @@
+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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Embeddings/EmbeddingsRepository.php b/src/TextProcessing/Embeddings/EmbeddingsRepository.php
new file mode 100644
index 00000000..e9c547ca
--- /dev/null
+++ b/src/TextProcessing/Embeddings/EmbeddingsRepository.php
@@ -0,0 +1,255 @@
+ */
+    protected array $embeddingInstanceCache = [];
+
+    protected string $configurationHash;
+
+    function __construct(
+        protected readonly Extractor $embeddingsExtractor,
+        protected readonly ContentRetriever $contentRetriever,
+        protected readonly ConfigurationRepository $configurationRepository,
+    ) {
+        $this->configurationHash = self::getConfigurationHash();
+    }
+
+    protected function relatedEmbeddingsQuery(Entry $entry): Builder
+    {
+        $site = $entry->site()?->handle() ?? 'default';
+        $entryLink = EntryLink::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) {
+                return $query->where('can_be_suggested', true);
+            })
+            ->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.text_analysis.openai.token_limit', 8000),
+            config('statamic.seo-pro.text_analysis.openai.model', 'text-embeddings-3-small')
+        ]));
+    }
+
+    public function getRelatedEmbeddingsForEntryLazy(Entry $entry)
+    {
+        /** @var EntryEmbedding $embedding */
+        foreach ($this->relatedEmbeddingsQuery($entry)->lazy(200) as $embedding) {
+            yield $this->makeVector(
+                $embedding->entry_id,
+                null,
+                $embedding
+            );
+        }
+    }
+
+    public function getRelatedEmbeddingsForEntry(Entry $entry): Collection
+    {
+        return $this->makeVectorCollection(
+            $this->relatedEmbeddingsQuery($entry)->get()
+        );
+    }
+
+    public function generateEmbeddingsForAllEntries(): void
+    {
+        EntryQuery::query()->chunk(100, function ($entries) {
+            $entryIds = $entries->pluck('id')->all();
+            $this->fillEmbeddingInstanceCache($entryIds);
+
+            /** @var array $entryLinks */
+            $entryLinks = EntryLink::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::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::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::where('entry_id', $entryId)->delete();
+    }
+
+    public function deleteEmbeddingsForCollection(string $handle): void
+    {
+        EntryEmbedding::where('collection', $handle)->delete();
+    }
+
+    public function deleteEmbeddingsForSite(string $handle): void
+    {
+        EntryEmbedding::where('site', $handle)->delete();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Embeddings/OpenAiEmbeddings.php b/src/TextProcessing/Embeddings/OpenAiEmbeddings.php
new file mode 100644
index 00000000..a1b1a2eb
--- /dev/null
+++ b/src/TextProcessing/Embeddings/OpenAiEmbeddings.php
@@ -0,0 +1,57 @@
+tokenizer = $tokenizer;
+        $this->tokenLimit = config('statamic.seo-pro.text_analysis.openai.token_limit', 8000);
+        $this->embeddingsModel = config('statamic.seo-pro.text_analysis.openai.model', 'text-embeddings-3-small');
+    }
+
+    protected function makeClient(): OpenAI\Client
+    {
+        return OpenAI::factory()
+            ->withApiKey(config('statamic.seo-pro.text_analysis.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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/EntryQuery.php b/src/TextProcessing/EntryQuery.php
new file mode 100644
index 00000000..9382a9c6
--- /dev/null
+++ b/src/TextProcessing/EntryQuery.php
@@ -0,0 +1,21 @@
+getDisabledCollections();
+
+        return EntryApi::query()
+            ->whereStatus('published')
+            ->whereNotIn('collection', $disabledCollections);
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Keywords/KeywordComparator.php b/src/TextProcessing/Keywords/KeywordComparator.php
new file mode 100644
index 00000000..42635d75
--- /dev/null
+++ b/src/TextProcessing/Keywords/KeywordComparator.php
@@ -0,0 +1,121 @@
+ 3,
+        'uri' => 3,
+        'content' => 1,
+    ];
+
+    protected int $keywordThreshold = 70;
+
+    protected array $primaryKeywords = [];
+
+    protected function keywordMatch(string $keywordA, string $keywordB): bool
+    {
+        similar_text($keywordA, $keywordB, $percent);
+
+        return $percent >= $this->keywordThreshold;
+    }
+
+    protected function getAdjustedScores(array $contentKeywords): array
+    {
+        $base = 0;
+
+        if (count($contentKeywords) > 0) {
+            $base = max($contentKeywords);
+        }
+
+        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
+    {
+        $scoreValues = $this->getAdjustedScores($keywordsB);
+        $score = 0;
+        $keywords = [];
+
+        foreach ($keywordsA as $keywordA => $scoreA) {
+            foreach ($keywordsB as $keywordB => $scoreB) {
+                if ($this->keywordMatch($keywordA, $keywordB)) {
+                    $source = ($scoreB == $scoreValues['title']) ? 'title' : (($scoreB == $scoreValues['uri']) ? 'uri' : 'content');
+                    $weightedScore = $this->weights[$source] * $scoreA;
+
+                    $score += $weightedScore;
+                    $keywords[] = [
+                        'keyword' => $keywordA,
+                        'score' => $weightedScore,
+                        'source' => $source,
+                    ];
+                }
+            }
+        }
+
+        return [
+            'keywords' => $keywords,
+            'score' => $score,
+        ];
+    }
+
+    /**
+     * @param Result[] $results
+     * @return array
+     */
+    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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Keywords/KeywordsRepository.php b/src/TextProcessing/Keywords/KeywordsRepository.php
new file mode 100644
index 00000000..84423bbf
--- /dev/null
+++ b/src/TextProcessing/Keywords/KeywordsRepository.php
@@ -0,0 +1,201 @@
+ */
+    protected array $keywordInstanceCache = [];
+
+    function __construct(
+        protected readonly KeywordRetriever $keywordRetriever,
+        protected readonly ContentRetriever $contentRetriever,
+    ) {}
+
+    public function generateKeywordsForAllEntries()
+    {
+        EntryQuery::query()->chunk(100, function ($entries) {
+            $entryIds = $entries->pluck('id')->all();
+            $this->fillKeywordInstanceCache($entryIds);
+
+            /** @var array $entryLinks */
+            $entryLinks = EntryLink::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::whereIn('entry_id', $entryIds)->get()->keyBy('entry_id')->all();
+    }
+
+    protected function clearKeywordInstanceCache(): void
+    {
+        $this->keywordInstanceCache = [];
+    }
+
+    protected function expandKeywords(array $keywords, $stopWords = []): array
+    {
+        $returnKeywords = [];
+
+        foreach ($keywords as $keyword) {
+            $returnKeywords[] = $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;
+                }
+
+                $returnKeywords[] = $newKeyword;
+            }
+        }
+
+        return $returnKeywords;
+    }
+
+    protected function getMetaKeywords(Entry $entry, $stopWords = [])
+    {
+        $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::firstOrNew(['entry_id' => $entryId]);
+    }
+
+    public function generateKeywordsForEntry(Entry $entry)
+    {
+        $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)
+    {
+        EntryKeyword::where('entry_id', $entryId)->delete();
+    }
+
+    /**
+     * @param array $entryIds
+     * @return array
+     */
+    public function getKeywordsForEntries(array $entryIds): array
+    {
+        return EntryKeyword::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::where('entry_id', $entry->id())->first();
+
+        /** @var SiteLinkSetting $siteSettings */
+        $siteSettings = SiteLinkSetting::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::where('site', $handle)->delete();
+    }
+
+    public function deleteKeywordsForCollection(string $handle): void
+    {
+        EntryKeyword::where('collection', $handle)->delete();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Keywords/Rake.php b/src/TextProcessing/Keywords/Rake.php
new file mode 100644
index 00000000..7931f821
--- /dev/null
+++ b/src/TextProcessing/Keywords/Rake.php
@@ -0,0 +1,115 @@
+getStopWords(),
+            phrase_min_length: config('statamic.seo-pro.text_analysis.rake.phrase_min_length', 0),
+            filter_numerics: config('statamic.seo-pro.text_analysis.rake.filter_numerics', true)
+        );
+    }
+
+    public function getStopWords(): array
+    {
+        $path = base_path('vendor/donatello-za/rake-php-plus/lang/'.$this->locale.'.php');
+
+        if (! 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..3cb21890
--- /dev/null
+++ b/src/TextProcessing/Keywords/StopWordsBag.php
@@ -0,0 +1,16 @@
+stopWords;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/GlobalAutomaticLinksBlueprint.php b/src/TextProcessing/Links/GlobalAutomaticLinksBlueprint.php
new file mode 100644
index 00000000..b44b73e0
--- /dev/null
+++ b/src/TextProcessing/Links/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'
+                            ]
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php b/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
new file mode 100644
index 00000000..6e028566
--- /dev/null
+++ b/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
@@ -0,0 +1,14 @@
+delete();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/IgnoredSuggestion.php b/src/TextProcessing/Links/IgnoredSuggestion.php
new file mode 100644
index 00000000..3d375a55
--- /dev/null
+++ b/src/TextProcessing/Links/IgnoredSuggestion.php
@@ -0,0 +1,15 @@
+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',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/LinkCrawler.php b/src/TextProcessing/Links/LinkCrawler.php
new file mode 100644
index 00000000..5d54bd4d
--- /dev/null
+++ b/src/TextProcessing/Links/LinkCrawler.php
@@ -0,0 +1,148 @@
+lazy() as $entry) {
+            $this->scanEntry($entry);
+        }
+
+        foreach (EntryQuery::query()->lazy() as $entry) {
+            $this->updateInboundInternalLinkCount($entry);
+        }
+    }
+
+    public function scanEntry(Entry $entry): void
+    {
+        $this->linksRepository->scanEntry($entry);
+    }
+
+    public static function getLinkResultsFromEntryLink(EntryLink $entryLink): LinkResults
+    {
+        return self::getLinkResults($entryLink->analyzed_content);
+    }
+
+    public function updateInboundInternalLinkCount(Entry $entry): void
+    {
+        $targetUri = $entry->uri;
+        $targetLink = EntryLink::query()->where('entry_id', $entry->id)->first();
+
+        if (! $targetLink) {
+            return;
+        }
+
+        $entryLinks = EntryLink::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();
+    }
+
+    /**
+     * @param string $content
+     * @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,
+            ];
+
+            if (URL::isExternal($href)) {
+                $externalLinks[] = $result;
+
+                continue;
+            }
+
+            $internalLinks[] = $result;
+        }
+
+        $results->internalLinks($internalLinks);
+        $results->externalLinks($externalLinks);
+
+        return $results;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/LinkRepository.php b/src/TextProcessing/Links/LinkRepository.php
new file mode 100644
index 00000000..f400e76a
--- /dev/null
+++ b/src/TextProcessing/Links/LinkRepository.php
@@ -0,0 +1,254 @@
+action === 'ignore_entry') {
+            $this->ignoreEntrySuggestion($suggestion);
+        } else if ($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);
+        } else if ($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::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));
+        } else if ($suggestion->scope === 'entry') {
+            $this->whenEntryExists($suggestion->entry, fn($entry) => $this->addIgnoredEntryToEntry($entry, $suggestion->ignoredEntry));
+        }
+    }
+
+    protected function getEntryLink(Entry $entry): ?EntryLink
+    {
+        $entryLink = EntryLink::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): ?EntryLink
+    {
+        /** @var EntryLink $entryLinks */
+        $entryLinks = EntryLink::firstOrNew(['entry_id' => $entry->id()]);
+        $linkContent = $this->contentRetriever->getContent($entry, false);
+        $contentMapping = $this->contentRetriever->getContentMappingIndexArray($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);
+
+        $entryLinks->saveQuietly();
+
+        return $entryLinks;
+    }
+
+    protected function normalizeLinks(Collection $links): array
+    {
+        return $links->map(fn(string $link) => $this->normalizeLink($link))->unique()->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::where('collection', $entry->collection()->handle())->first();
+
+        if ($collectionSetting && ! $collectionSetting->linking_enabled) {
+            return false;
+        }
+
+        /** @var EntryLink $entryLink */
+        $entryLink = EntryLink::where('entry_id', $entry->id())->first();
+
+        if ($entryLink && ! $entryLink->can_be_suggested) {
+            return false;
+        }
+
+        return true;
+    }
+
+    public function deleteLinksForEntry(string $entryId): void
+    {
+        EntryLink::where('entry_id', $entryId)->delete();
+    }
+
+    public function deleteLinksForSite(string $handle): void
+    {
+        EntryLink::where('site', $handle)->delete();
+    }
+
+    public function deleteLinksForCollection(string $handle): void
+    {
+        EntryLink::where('collection', $handle)->delete();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Models/AutomaticLink.php b/src/TextProcessing/Models/AutomaticLink.php
new file mode 100644
index 00000000..9b7ddd37
--- /dev/null
+++ b/src/TextProcessing/Models/AutomaticLink.php
@@ -0,0 +1,28 @@
+ 'array',
+    ];
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Models/EntryEmbedding.php b/src/TextProcessing/Models/EntryEmbedding.php
new file mode 100644
index 00000000..6acb1334
--- /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');
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Models/EntryKeyword.php b/src/TextProcessing/Models/EntryKeyword.php
new file mode 100644
index 00000000..1d454405
--- /dev/null
+++ b/src/TextProcessing/Models/EntryKeyword.php
@@ -0,0 +1,35 @@
+ 'array',
+        'content_keywords' => 'array',
+    ];
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Models/EntryLink.php b/src/TextProcessing/Models/EntryLink.php
new file mode 100644
index 00000000..0bfbd30f
--- /dev/null
+++ b/src/TextProcessing/Models/EntryLink.php
@@ -0,0 +1,64 @@
+ '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');
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Models/SiteLinkSetting.php b/src/TextProcessing/Models/SiteLinkSetting.php
new file mode 100644
index 00000000..66f44a3c
--- /dev/null
+++ b/src/TextProcessing/Models/SiteLinkSetting.php
@@ -0,0 +1,30 @@
+ 'array',
+    ];
+}
\ No newline at end of file
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/Suggestions/LinkResults.php b/src/TextProcessing/Suggestions/LinkResults.php
new file mode 100644
index 00000000..10c9eac5
--- /dev/null
+++ b/src/TextProcessing/Suggestions/LinkResults.php
@@ -0,0 +1,34 @@
+fluentlyGetOrSet('internalLinks')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @param array|null $links
+     * @return ($links is null ? array{array{href:string,text:string}} : null)
+     */
+    public function externalLinks(?array $links = null)
+    {
+        return $this->fluentlyGetOrSet('externalLinks')
+            ->args(func_get_args());
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Suggestions/PhraseContext.php b/src/TextProcessing/Suggestions/PhraseContext.php
new file mode 100644
index 00000000..6ca95fd3
--- /dev/null
+++ b/src/TextProcessing/Suggestions/PhraseContext.php
@@ -0,0 +1,58 @@
+fluentlyGetOrSet('fieldHandle')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @param string|null $context
+     * @return ($context is null ? string : null)
+     */
+    public function context(?string $context = null)
+    {
+        return $this->fluentlyGetOrSet('context')
+            ->args(func_get_args());
+    }
+
+    /**
+     * @param bool|null $canReplace
+     * @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,
+        ];
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Suggestions/SuggestionEngine.php b/src/TextProcessing/Suggestions/SuggestionEngine.php
new file mode 100644
index 00000000..d9ff2e21
--- /dev/null
+++ b/src/TextProcessing/Suggestions/SuggestionEngine.php
@@ -0,0 +1,165 @@
+results = $results;
+
+        return $this;
+    }
+
+    protected function getPhraseContext(array $contentMapping, string $phrase): PhraseContext
+    {
+        $context = new PhraseContext();
+
+        foreach ($contentMapping as $handle => $content) {
+            if (! is_string($content)) {
+                continue;
+            }
+
+            if (Str::contains($content, $phrase)) {
+                $searchText = strip_tags($content);
+                $pos = stripos($searchText, $phrase);
+
+                if ($pos === false) {
+                    continue;
+                }
+
+                $regex = '/([^.!?]*' . preg_quote($phrase, '/') . '[^.!?]*[.!?])|([^.!?]*' . preg_quote($phrase, '/') . '[^.!?]*$)/i';
+
+                if (preg_match($regex, $searchText, $matches)) {
+                    $firstMatch = trim($matches[0]);
+
+                    if (Str::contains($firstMatch, "\n")) {
+                        $lines = explode("\n", $firstMatch);
+
+                        $curLine = '';
+
+                        foreach ($lines as $line) {
+
+                            if (mb_strlen($line) > mb_strlen($curLine)) {
+                                $curLine = $line;
+                            }
+
+                            if (count(explode(' ', trim($line))) <= 2) {
+                                continue;
+                            }
+
+                            if (Str::contains(mb_strtolower($line), $phrase)) {
+                                $context->fieldHandle($handle);
+                                $context->context($line);
+                                $context->canReplace(true);
+                                break 2;
+                            }
+                        }
+
+                        $context->context($curLine);
+                        break;
+                    }
+                }
+            }
+        }
+
+        return $context;
+    }
+
+    protected function contentIndexToMapping(array $index): array
+    {
+        return collect($index)
+            ->mapWithKeys(function ($item) {
+                return [$item['fqn_path'] => $item['value']];
+            })
+            ->all();
+    }
+
+    public function suggest(Entry $entry)
+    {
+        $entryLink = EntryLink::where('entry_id', $entry->id())->firstOrFail();
+        $linkResults = LinkCrawler::getLinkResultsFromEntryLink($entryLink);
+        $contentMapping = self::contentIndexToMapping($entryLink->content_mapping ?? []);
+
+        $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 collect($suggestions)->sortByDesc(fn($suggestion) => $suggestion['score'])->values();
+    }
+}
\ No newline at end of file
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());
+    }
+}

From 3a0444eaba18e49f6f1bf3f3aab57edcfaef0396 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:08:54 -0500
Subject: [PATCH 02/91] Improve context retrieval

---
 .../Suggestions/SuggestionEngine.php          | 44 ++++++++++++++-----
 1 file changed, 34 insertions(+), 10 deletions(-)

diff --git a/src/TextProcessing/Suggestions/SuggestionEngine.php b/src/TextProcessing/Suggestions/SuggestionEngine.php
index d9ff2e21..2e84f8cb 100644
--- a/src/TextProcessing/Suggestions/SuggestionEngine.php
+++ b/src/TextProcessing/Suggestions/SuggestionEngine.php
@@ -22,12 +22,17 @@ public function withResults(Collection $results): static
         return $this;
     }
 
+    private function canExtractContext(mixed $value): bool
+    {
+        return is_string($value);
+    }
+
     protected function getPhraseContext(array $contentMapping, string $phrase): PhraseContext
     {
         $context = new PhraseContext();
 
         foreach ($contentMapping as $handle => $content) {
-            if (! is_string($content)) {
+            if (! $this->canExtractContext($content)) {
                 continue;
             }
 
@@ -63,13 +68,25 @@ protected function getPhraseContext(array $contentMapping, string $phrase): Phra
                                 $context->fieldHandle($handle);
                                 $context->context($line);
                                 $context->canReplace(true);
+
                                 break 2;
                             }
                         }
 
                         $context->context($curLine);
-                        break;
+                    } else {
+                        $contextPhrase = $this->getSurroundingWords($content, $phrase);
+
+                        if (! $contextPhrase) {
+                            continue;
+                        }
+
+                        $context->fieldHandle($handle);
+                        $context->context($contextPhrase);
+                        $context->canReplace(true);
+
                     }
+                    break;
                 }
             }
         }
@@ -77,20 +94,27 @@ protected function getPhraseContext(array $contentMapping, string $phrase): Phra
         return $context;
     }
 
-    protected function contentIndexToMapping(array $index): array
+    protected function getSurroundingWords(string $content, string $phrase, int $surroundingWords = 4): ?string
     {
-        return collect($index)
-            ->mapWithKeys(function ($item) {
-                return [$item['fqn_path'] => $item['value']];
-            })
-            ->all();
+        preg_match('/^(.*?)('.preg_quote($phrase, '/').')(.*)$/iu', $content, $matches);
+
+        if (empty($matches)) {
+            return null;
+        }
+
+        $words = array_filter(array_slice(explode(' ', $matches[1]), -$surroundingWords));
+        $words[] = $phrase;
+
+        $words = array_merge($words, array_filter(array_slice(explode(' ', $matches[3]), 0, $surroundingWords)));
+
+        return implode(' ', $words);
     }
 
     public function suggest(Entry $entry)
     {
         $entryLink = EntryLink::where('entry_id', $entry->id())->firstOrFail();
         $linkResults = LinkCrawler::getLinkResultsFromEntryLink($entryLink);
-        $contentMapping = self::contentIndexToMapping($entryLink->content_mapping ?? []);
+        $contentMapping = $entryLink->content_mapping;
 
         $internalLinks = [];
         $usedPhrases = [];
@@ -116,7 +140,7 @@ public function suggest(Entry $entry)
                     array_key_exists($keyword, $suggestions) ||
                     array_key_exists($keyword, $usedPhrases)
                 ) {
-                   continue;
+                    continue;
                 }
 
                 $context = $this->getPhraseContext($contentMapping, $keyword);

From 9ca124668306bceac62d2cdeb3b475354825e34a Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:09:27 -0500
Subject: [PATCH 03/91] Be smarter about how data is updated

---
 src/TextProcessing/Content/RetrievedField.php | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/TextProcessing/Content/RetrievedField.php b/src/TextProcessing/Content/RetrievedField.php
index fb20a138..fdb078d6 100644
--- a/src/TextProcessing/Content/RetrievedField.php
+++ b/src/TextProcessing/Content/RetrievedField.php
@@ -96,7 +96,11 @@ public function update(mixed $newValue): static
         $data = $this->getRootData();
 
         if (is_array($data)) {
-            Arr::set($data, $this->path, $newValue);
+            if (strlen($this->path) > 0) {
+                Arr::set($data, $this->path, $newValue);
+            } else {
+                $data = $newValue;
+            }
         } else if (is_string($data) && is_string($newValue)) {
             $data = $newValue;
         }

From cf935aca5fdb47b4733bba11eb9c0afbb122e330 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:09:49 -0500
Subject: [PATCH 04/91] Use correct entry id
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

That was fun to find 😅
---
 resources/js/components/links/suggestions/SuggestionEditor.vue | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/resources/js/components/links/suggestions/SuggestionEditor.vue b/resources/js/components/links/suggestions/SuggestionEditor.vue
index 65768946..64071ec7 100644
--- a/resources/js/components/links/suggestions/SuggestionEditor.vue
+++ b/resources/js/components/links/suggestions/SuggestionEditor.vue
@@ -186,9 +186,8 @@ export default {
     methods: {
 
         getFieldDetails() {
-            this.$axios.get(cp_url(`seo-pro/links/field-details/${this.suggestion.entry}/${this.suggestion.context.field_handle}`)).then(response => {
+            this.$axios.get(cp_url(`seo-pro/links/field-details/${this.entryId}/${this.suggestion.context.field_handle}`)).then(response => {
                 this.fieldConfig = response.data;
-                console.log('yo', this.fieldConfig);
             });
         },
 

From d144ab732f3a094e78b02343935754449720ce66 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:10:58 -0500
Subject: [PATCH 05/91] Be smart about detecting added/removed links

---
 .../TextProcessing/Links/LinkCrawler.php      |  5 ++-
 .../TextProcessing/Links/LinksRepository.php  |  3 +-
 src/TextProcessing/Links/LinkChangeSet.php    | 43 +++++++++++++++++++
 src/TextProcessing/Links/LinkCrawler.php      |  9 +++-
 src/TextProcessing/Links/LinkRepository.php   | 32 +++++++++++++-
 src/TextProcessing/Links/LinkScanOptions.php  | 10 +++++
 6 files changed, 96 insertions(+), 6 deletions(-)
 create mode 100644 src/TextProcessing/Links/LinkChangeSet.php
 create mode 100644 src/TextProcessing/Links/LinkScanOptions.php

diff --git a/src/Contracts/TextProcessing/Links/LinkCrawler.php b/src/Contracts/TextProcessing/Links/LinkCrawler.php
index 9dcbde77..6141ab0d 100644
--- a/src/Contracts/TextProcessing/Links/LinkCrawler.php
+++ b/src/Contracts/TextProcessing/Links/LinkCrawler.php
@@ -3,13 +3,16 @@
 namespace Statamic\SeoPro\Contracts\TextProcessing\Links;
 
 use Statamic\Contracts\Entries\Entry;
+use Statamic\SeoPro\TextProcessing\Links\LinkScanOptions;
 
 interface LinkCrawler
 {
     public function scanAllEntries(): void;
 
-    public function scanEntry(Entry $entry): void;
+    public function scanEntry(Entry $entry, ?LinkScanOptions $options = null): void;
 
     public function updateInboundInternalLinkCount(Entry $entry): void;
 
+    public function updateLinkStatistics(Entry $entry): void;
+
 }
\ No newline at end of file
diff --git a/src/Contracts/TextProcessing/Links/LinksRepository.php b/src/Contracts/TextProcessing/Links/LinksRepository.php
index a7f63cac..2f89d515 100644
--- a/src/Contracts/TextProcessing/Links/LinksRepository.php
+++ b/src/Contracts/TextProcessing/Links/LinksRepository.php
@@ -4,6 +4,7 @@
 
 use Statamic\Contracts\Entries\Entry;
 use Statamic\SeoPro\TextProcessing\Links\IgnoredSuggestion;
+use Statamic\SeoPro\TextProcessing\Links\LinkScanOptions;
 use Statamic\SeoPro\TextProcessing\Models\EntryLink;
 
 interface LinksRepository
@@ -12,7 +13,7 @@ public function ignoreSuggestion(IgnoredSuggestion $suggestion): void;
 
     public function isLinkingEnabledForEntry(Entry $entry): bool;
 
-    public function scanEntry(Entry $entry): ?EntryLink;
+    public function scanEntry(Entry $entry, ?LinkScanOptions $options = null): ?EntryLink;
 
     public function deleteLinksForSite(string $handle): void;
 
diff --git a/src/TextProcessing/Links/LinkChangeSet.php b/src/TextProcessing/Links/LinkChangeSet.php
new file mode 100644
index 00000000..a2b74d89
--- /dev/null
+++ b/src/TextProcessing/Links/LinkChangeSet.php
@@ -0,0 +1,43 @@
+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();
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/LinkCrawler.php b/src/TextProcessing/Links/LinkCrawler.php
index 5d54bd4d..8b2f2fff 100644
--- a/src/TextProcessing/Links/LinkCrawler.php
+++ b/src/TextProcessing/Links/LinkCrawler.php
@@ -29,9 +29,9 @@ public function scanAllEntries(): void
         }
     }
 
-    public function scanEntry(Entry $entry): void
+    public function scanEntry(Entry $entry, ?LinkScanOptions $options = null): void
     {
-        $this->linksRepository->scanEntry($entry);
+        $this->linksRepository->scanEntry($entry, $options);
     }
 
     public static function getLinkResultsFromEntryLink(EntryLink $entryLink): LinkResults
@@ -39,6 +39,11 @@ public static function getLinkResultsFromEntryLink(EntryLink $entryLink): LinkRe
         return self::getLinkResults($entryLink->analyzed_content);
     }
 
+    public function updateLinkStatistics(Entry $entry): void
+    {
+        $this->updateInboundInternalLinkCount($entry);
+    }
+
     public function updateInboundInternalLinkCount(Entry $entry): void
     {
         $targetUri = $entry->uri;
diff --git a/src/TextProcessing/Links/LinkRepository.php b/src/TextProcessing/Links/LinkRepository.php
index f400e76a..878da9d6 100644
--- a/src/TextProcessing/Links/LinkRepository.php
+++ b/src/TextProcessing/Links/LinkRepository.php
@@ -8,6 +8,7 @@
 use Statamic\Facades\Entry as EntryApi;
 use Statamic\SeoPro\Contracts\TextProcessing\Content\ContentRetriever;
 use Statamic\SeoPro\Contracts\TextProcessing\Links\LinksRepository as LinkRepositoryContract;
+use Statamic\SeoPro\Events\InternalLinksUpdated;
 use Statamic\SeoPro\TextProcessing\Models\CollectionLinkSettings;
 use Statamic\SeoPro\TextProcessing\Models\EntryLink;
 use Statamic\SeoPro\TextProcessing\Models\SiteLinkSetting;
@@ -156,12 +157,16 @@ protected function ignoreEntry(Entry $entry): void
         $entryLink->saveQuietly();
     }
 
-    public function scanEntry(Entry $entry): ?EntryLink
+    public function scanEntry(Entry $entry, ?LinkScanOptions $options = null): ?EntryLink
     {
+        if (! $options) {
+            $options = new LinkScanOptions();
+        }
+
         /** @var EntryLink $entryLinks */
         $entryLinks = EntryLink::firstOrNew(['entry_id' => $entry->id()]);
         $linkContent = $this->contentRetriever->getContent($entry, false);
-        $contentMapping = $this->contentRetriever->getContentMappingIndexArray($entry);
+        $contentMapping = $this->contentRetriever->getContentMapping($entry);
         $linkResults = LinkCrawler::getLinkResults($linkContent);
         $collection = $entry->collection()->handle();
         $site = $entry->site()->handle();
@@ -195,11 +200,34 @@ public function scanEntry(Entry $entry): ?EntryLink
         $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()->all();
diff --git a/src/TextProcessing/Links/LinkScanOptions.php b/src/TextProcessing/Links/LinkScanOptions.php
new file mode 100644
index 00000000..187d4b64
--- /dev/null
+++ b/src/TextProcessing/Links/LinkScanOptions.php
@@ -0,0 +1,10 @@
+
Date: Sat, 14 Sep 2024 17:11:10 -0500
Subject: [PATCH 06/91] Create InternalLinksUpdated.php

---
 src/Events/InternalLinksUpdated.php | 13 +++++++++++++
 1 file changed, 13 insertions(+)
 create mode 100644 src/Events/InternalLinksUpdated.php

diff --git a/src/Events/InternalLinksUpdated.php b/src/Events/InternalLinksUpdated.php
new file mode 100644
index 00000000..190c88b8
--- /dev/null
+++ b/src/Events/InternalLinksUpdated.php
@@ -0,0 +1,13 @@
+
Date: Sat, 14 Sep 2024 17:11:23 -0500
Subject: [PATCH 07/91] Check for internal changes when updating

---
 src/Jobs/ScanEntryLinks.php | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/src/Jobs/ScanEntryLinks.php b/src/Jobs/ScanEntryLinks.php
index c0c48c70..6ccc03b3 100644
--- a/src/Jobs/ScanEntryLinks.php
+++ b/src/Jobs/ScanEntryLinks.php
@@ -4,11 +4,11 @@
 
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Statamic\Facades\Entry;
-use Statamic\SeoPro\Contracts\TextProcessing\Embeddings\EntryEmbeddingsRepository;
 use Statamic\SeoPro\Contracts\TextProcessing\Keywords\KeywordsRepository;
 use Statamic\SeoPro\Contracts\TextProcessing\Links\LinkCrawler;
 use Statamic\SeoPro\Contracts\TextProcessing\Links\LinksRepository;
 use Statamic\SeoPro\Jobs\Concerns\DispatchesSeoProJobs;
+use Statamic\SeoPro\TextProcessing\Links\LinkScanOptions;
 
 class ScanEntryLinks implements ShouldQueue
 {
@@ -21,7 +21,6 @@ public function __construct(
     public function handle(
         LinksRepository $linksRepository,
         KeywordsRepository $keywordsRepository,
-        EntryEmbeddingsRepository $entryEmbeddingsRepository,
         LinkCrawler $linkCrawler,
     ): void
     {
@@ -31,7 +30,10 @@ public function handle(
             return;
         }
 
-        $linkCrawler->scanEntry($entry);
+        $linkCrawler->scanEntry($entry, new LinkScanOptions(
+            withInternalChangeSets: true
+        ));
+
         $linkCrawler->updateInboundInternalLinkCount($entry);
 
         if ($linksRepository->isLinkingEnabledForEntry($entry)) {

From 297e9d24130925b850fb4ad50079754dc5a14684 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:11:38 -0500
Subject: [PATCH 08/91] Add cached_uri to list of fields searched

---
 src/Http/Controllers/Linking/LinksController.php | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/src/Http/Controllers/Linking/LinksController.php b/src/Http/Controllers/Linking/LinksController.php
index c9f4781d..4dca9ae4 100644
--- a/src/Http/Controllers/Linking/LinksController.php
+++ b/src/Http/Controllers/Linking/LinksController.php
@@ -122,7 +122,8 @@ public function filter(FilteredRequest $request)
         if (request('search')) {
             $query->where(function (Builder $q) {
                 $q->where('analyzed_content', 'like', '%'.request('search').'%')
-                    ->orWhere('cached_title', 'like', '%'.request('search').'%');
+                    ->orWhere('cached_title', 'like', '%'.request('search').'%')
+                    ->orWhere('cached_uri', 'like', '%'.request('search').'%');
             });
         }
 

From 6f50816d9695af77adb79d33f56ea978101fed1b Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:12:28 -0500
Subject: [PATCH 09/91] =?UTF-8?q?Adds=20support=20for=20inserting=20links?=
 =?UTF-8?q?=20into=20Bard=20fields=20=F0=9F=98=AE=E2=80=8D=F0=9F=92=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../Content/LinkReplacers/Bard/BardLink.php   |  13 +
 .../LinkReplacers/Bard/BardManipulator.php    | 262 ++++++++++++++++++
 .../LinkReplacers/Bard/BardReplacer.php       |  43 +++
 .../Content/LinkReplacers/BardReplacer.php    |  27 --
 4 files changed, 318 insertions(+), 27 deletions(-)
 create mode 100644 src/TextProcessing/Content/LinkReplacers/Bard/BardLink.php
 create mode 100644 src/TextProcessing/Content/LinkReplacers/Bard/BardManipulator.php
 create mode 100644 src/TextProcessing/Content/LinkReplacers/Bard/BardReplacer.php
 delete mode 100644 src/TextProcessing/Content/LinkReplacers/BardReplacer.php

diff --git a/src/TextProcessing/Content/LinkReplacers/Bard/BardLink.php b/src/TextProcessing/Content/LinkReplacers/Bard/BardLink.php
new file mode 100644
index 00000000..b3c364fd
--- /dev/null
+++ b/src/TextProcessing/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;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacers/Bard/BardReplacer.php b/src/TextProcessing/Content/LinkReplacers/Bard/BardReplacer.php
new file mode 100644
index 00000000..2490c0fa
--- /dev/null
+++ b/src/TextProcessing/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);
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Content/LinkReplacers/BardReplacer.php b/src/TextProcessing/Content/LinkReplacers/BardReplacer.php
deleted file mode 100644
index 58f38a41..00000000
--- a/src/TextProcessing/Content/LinkReplacers/BardReplacer.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
Date: Sat, 14 Sep 2024 17:12:33 -0500
Subject: [PATCH 10/91] Wire some stuff up!

---
 .../InternalLinksUpdatedListener.php          | 19 +++++++++++++++++++
 src/ServiceProvider.php                       |  4 ++++
 2 files changed, 23 insertions(+)
 create mode 100644 src/Listeners/InternalLinksUpdatedListener.php

diff --git a/src/Listeners/InternalLinksUpdatedListener.php b/src/Listeners/InternalLinksUpdatedListener.php
new file mode 100644
index 00000000..5de3a912
--- /dev/null
+++ b/src/Listeners/InternalLinksUpdatedListener.php
@@ -0,0 +1,19 @@
+changeSet->entries()->each(fn(Entry $entry) => $this->crawler->updateLinkStatistics($entry));
+    }
+}
\ No newline at end of file
diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
index 3a2aeb5e..5ac65cac 100644
--- a/src/ServiceProvider.php
+++ b/src/ServiceProvider.php
@@ -67,6 +67,9 @@ class ServiceProvider extends AddonServiceProvider
         CollectionDeleted::class => [
             SeoPro\Listeners\CollectionDeletedListener::class,
         ],
+        SeoPro\Events\InternalLinksUpdated::class => [
+            SeoPro\Listeners\InternalLinksUpdatedListener::class,
+        ],
     ];
 
     public function bootAddon()
@@ -163,6 +166,7 @@ protected function registerDefaultFieldtypeReplacers(): static
             SeoPro\TextProcessing\Content\LinkReplacers\MarkdownReplacer::class,
             SeoPro\TextProcessing\Content\LinkReplacers\TextReplacer::class,
             SeoPro\TextProcessing\Content\LinkReplacers\TextareaReplacer::class,
+            SeoPro\TextProcessing\Content\LinkReplacers\Bard\BardReplacer::class,
         ]);
 
         return $this;

From 2869f0403f86180eb29e9599ff572fec25e2c29d Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sat, 14 Sep 2024 17:19:27 -0500
Subject: [PATCH 11/91] Tidy up a bit

---
 src/Commands/GenerateEmbeddingsCommand.php | 15 ---------------
 src/Commands/GenerateKeywordsCommand.php   | 15 ---------------
 2 files changed, 30 deletions(-)

diff --git a/src/Commands/GenerateEmbeddingsCommand.php b/src/Commands/GenerateEmbeddingsCommand.php
index 3b00e190..e715f366 100644
--- a/src/Commands/GenerateEmbeddingsCommand.php
+++ b/src/Commands/GenerateEmbeddingsCommand.php
@@ -10,25 +10,10 @@ class GenerateEmbeddingsCommand extends Command
 {
     use RunsInPlease;
 
-    /**
-     * The name and signature of the console command.
-     *
-     * @var string
-     */
     protected $signature = 'statamic:seo-pro:generate-embeddings';
 
-    /**
-     * The console command description.
-     *
-     * @var string
-     */
     protected $description = 'Generates embeddings for entries.';
 
-    /**
-     * Execute the console command.
-     *
-     * @return mixed
-     */
     public function handle(EntryEmbeddingsRepository $vectors)
     {
         $this->line('Generating...');
diff --git a/src/Commands/GenerateKeywordsCommand.php b/src/Commands/GenerateKeywordsCommand.php
index ab9e0796..499db007 100644
--- a/src/Commands/GenerateKeywordsCommand.php
+++ b/src/Commands/GenerateKeywordsCommand.php
@@ -10,25 +10,10 @@ class GenerateKeywordsCommand extends Command
 {
     use RunsInPlease;
 
-    /**
-     * The name and signature of the console command.
-     *
-     * @var string
-     */
     protected $signature = 'statamic:seo-pro:generate-keywords';
 
-    /**
-     * The console command description.
-     *
-     * @var string
-     */
     protected $description = 'Generates keywords for entries.';
 
-    /**
-     * Execute the console command.
-     *
-     * @return mixed
-     */
     public function handle(KeywordsRepository $keywords)
     {
         $this->line('Generating...');

From cabd1e5fafe186a4fba94eb50261ea998bd00b05 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 11:51:57 -0500
Subject: [PATCH 12/91] Remove for now

Removing these for now to not expose a public API for querying/filtering fields and get locked in to something
---
 .../Content/ContentRetriever.php              |  2 --
 src/TextProcessing/Content/ContentMapper.php  | 25 -------------------
 .../Content/ContentRetriever.php              |  5 ----
 3 files changed, 32 deletions(-)

diff --git a/src/Contracts/TextProcessing/Content/ContentRetriever.php b/src/Contracts/TextProcessing/Content/ContentRetriever.php
index d6e5f878..2f8c67a1 100644
--- a/src/Contracts/TextProcessing/Content/ContentRetriever.php
+++ b/src/Contracts/TextProcessing/Content/ContentRetriever.php
@@ -12,8 +12,6 @@ public function getContent(Entry $entry, bool $stripTags = true): string;
 
     public function getContentMapping(Entry $entry): array;
 
-    public function getContentMappingIndexArray(Entry $entry): array;
-
     public function getSections(Entry $entry): array;
 
     public function stripTags(string $content): string;
diff --git a/src/TextProcessing/Content/ContentMapper.php b/src/TextProcessing/Content/ContentMapper.php
index e0b37df9..b29da9db 100644
--- a/src/TextProcessing/Content/ContentMapper.php
+++ b/src/TextProcessing/Content/ContentMapper.php
@@ -320,37 +320,12 @@ public function getContentMapping(Entry $entry): array
         return $this->contentMapping;
     }
 
-    public function getContentMappingIndexArray(Entry $entry)
-    {
-        return collect($this->getContentFields($entry))
-            ->map(fn(RetrievedField $field) => $field->toArray())
-            ->values()
-            ->all();
-    }
-
     public function getContentFields(Entry $entry): Collection
     {
         return collect($this->getContentMapping($entry))
             ->map(fn($_, $path) => $this->retrieveField($entry, $path));
     }
 
-    public function findFields(Entry $entry, string $pattern): Collection
-    {
-        $pattern = '/'.$pattern.'/';
-        $results = [];
-        $mapping = $this->getContentMapping($entry);
-
-        foreach ($mapping as $path => $value) {
-            if (! preg_match($pattern, $path)) {
-                continue;
-            }
-
-            $results[] = $this->retrieveField($entry, $path);
-        }
-
-        return collect($results);
-    }
-
     public function retrieveField(Entry $entry, string $path): RetrievedField
     {
         $parsedPath = (new ContentPathParser)->parse($path);
diff --git a/src/TextProcessing/Content/ContentRetriever.php b/src/TextProcessing/Content/ContentRetriever.php
index 4a8740dd..361b230a 100644
--- a/src/TextProcessing/Content/ContentRetriever.php
+++ b/src/TextProcessing/Content/ContentRetriever.php
@@ -91,11 +91,6 @@ public function getContentMapping(Entry $entry): array
         return $this->mapper->getContentMapping($entry);
     }
 
-    public function getContentMappingIndexArray(Entry $entry): array
-    {
-        return $this->mapper->getContentMappingIndexArray($entry);
-    }
-
     /**
      * @param Entry $entry
      * @return array{id:string,text:string}

From f12394f9cee8905cd3624c1e9340a60a840a441f Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 11:52:20 -0500
Subject: [PATCH 13/91] Ability to disable the text linking stuff

---
 src/ServiceProvider.php | 52 +++++++++++++++++++++++++++--------------
 1 file changed, 34 insertions(+), 18 deletions(-)

diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php
index 5ac65cac..f16e9b16 100644
--- a/src/ServiceProvider.php
+++ b/src/ServiceProvider.php
@@ -54,24 +54,6 @@ class ServiceProvider extends AddonServiceProvider
 
     protected $config = false;
 
-    protected $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,
-        ],
-    ];
-
     public function bootAddon()
     {
         $this
@@ -88,8 +70,42 @@ public function bootAddon()
             ->bootTextAnalysis();
     }
 
+    protected function isLinkingEnabled(): bool
+    {
+        return config('statamic.seo-pro.text_analysis.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(

From 803cf5bf0554e1c0c9d8da06dacba766e806ac16 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 12:32:41 -0500
Subject: [PATCH 14/91] Dramatically improves success rate of suggestion
 linkability

---
 .../components/links/SuggestionsListing.vue   | 10 +++++++--
 .../Suggestions/SuggestionEngine.php          | 21 ++++++++++++-------
 2 files changed, 22 insertions(+), 9 deletions(-)

diff --git a/resources/js/components/links/SuggestionsListing.vue b/resources/js/components/links/SuggestionsListing.vue
index 4404fb9b..c5f3fb4b 100644
--- a/resources/js/components/links/SuggestionsListing.vue
+++ b/resources/js/components/links/SuggestionsListing.vue
@@ -29,6 +29,9 @@
                                 {{ suggestion.phrase }}
                             
                         
+                        
                         
@@ -37,8 +40,9 @@
                         
                         
@@ -95,8 +99,9 @@ export default {
             activeSuggestion: null,
             columns: [
                 { label: 'Phrase', field: 'phrase' },
+                { label: 'Can Auto Apply', field: 'context.can_replace' },
                 { label: 'Rank', field: 'score' },
-                { label: 'URI', field: 'uri' }
+                { label: 'Target URI', field: 'uri' }
             ],
             suggestions: [],
         };
@@ -109,6 +114,7 @@ export default {
 
             this.$axios.get(cp_url(`seo-pro/links/${this.entry}/suggestions`)).then(response => {
                 this.suggestions = response.data;
+                console.log(this.suggestions);
                 this.loading = false;
             });
         },
diff --git a/src/TextProcessing/Suggestions/SuggestionEngine.php b/src/TextProcessing/Suggestions/SuggestionEngine.php
index 2e84f8cb..3cd81bc1 100644
--- a/src/TextProcessing/Suggestions/SuggestionEngine.php
+++ b/src/TextProcessing/Suggestions/SuggestionEngine.php
@@ -47,9 +47,11 @@ protected function getPhraseContext(array $contentMapping, string $phrase): Phra
                 $regex = '/([^.!?]*' . preg_quote($phrase, '/') . '[^.!?]*[.!?])|([^.!?]*' . preg_quote($phrase, '/') . '[^.!?]*$)/i';
 
                 if (preg_match($regex, $searchText, $matches)) {
+
                     $firstMatch = trim($matches[0]);
 
                     if (Str::contains($firstMatch, "\n")) {
+
                         $lines = explode("\n", $firstMatch);
 
                         $curLine = '';
@@ -94,20 +96,25 @@ protected function getPhraseContext(array $contentMapping, string $phrase): Phra
         return $context;
     }
 
+    /**
+     * 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.
+     * @return string|null
+     */
     protected function getSurroundingWords(string $content, string $phrase, int $surroundingWords = 4): ?string
     {
-        preg_match('/^(.*?)('.preg_quote($phrase, '/').')(.*)$/iu', $content, $matches);
+        $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;
         }
 
-        $words = array_filter(array_slice(explode(' ', $matches[1]), -$surroundingWords));
-        $words[] = $phrase;
-
-        $words = array_merge($words, array_filter(array_slice(explode(' ', $matches[3]), 0, $surroundingWords)));
-
-        return implode(' ', $words);
+        return $matches['before'].$matches['phrase'].$matches['after'];
     }
 
     public function suggest(Entry $entry)

From 0dbf07088dd2565ac1c749a84156699c8a9f33e2 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 17:31:50 -0500
Subject: [PATCH 15/91] Adds support for automatically inserting "global auto"
 links into content

---
 resources/views/links/automatic.antlers.html  |   1 +
 .../Links/GlobalAutomaticLinksRepository.php  |   4 +
 src/Tags/SeoProTags.php                       |   9 +-
 .../Links/AutomaticLinkManager.php            | 212 ++++++++++++++++++
 .../Links/GlobalAutomaticLinksRepository.php  |  13 +-
 src/TextProcessing/Links/LinkCrawler.php      |   1 +
 .../Similarity/TextSimilarity.php             |  53 +++++
 .../Suggestions/LinkResults.php               |  15 +-
 8 files changed, 304 insertions(+), 4 deletions(-)
 create mode 100644 resources/views/links/automatic.antlers.html
 create mode 100644 src/TextProcessing/Links/AutomaticLinkManager.php
 create mode 100644 src/TextProcessing/Similarity/TextSimilarity.php

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/src/Contracts/TextProcessing/Links/GlobalAutomaticLinksRepository.php b/src/Contracts/TextProcessing/Links/GlobalAutomaticLinksRepository.php
index 5ebc9cb6..055bb253 100644
--- a/src/Contracts/TextProcessing/Links/GlobalAutomaticLinksRepository.php
+++ b/src/Contracts/TextProcessing/Links/GlobalAutomaticLinksRepository.php
@@ -2,8 +2,12 @@
 
 namespace Statamic\SeoPro\Contracts\TextProcessing\Links;
 
+use Illuminate\Support\Collection;
+
 interface GlobalAutomaticLinksRepository
 {
     public function deleteAutomaticLinksForSite(string $handle): void;
 
+    public function getLinksForSite(string $handle): Collection;
+
 }
\ No newline at end of file
diff --git a/src/Tags/SeoProTags.php b/src/Tags/SeoProTags.php
index 73baab03..d6d4e01e 100755
--- a/src/Tags/SeoProTags.php
+++ b/src/Tags/SeoProTags.php
@@ -7,6 +7,7 @@
 use Statamic\SeoPro\RendersMetaHtml;
 use Statamic\SeoPro\SeoPro;
 use Statamic\SeoPro\SiteDefaults;
+use Statamic\SeoPro\TextProcessing\Links\AutomaticLinkManager;
 use Statamic\Tags\Tags;
 
 class SeoProTags extends Tags
@@ -66,7 +67,13 @@ public function dumpMetaData()
     public function content()
     {
         if (! SeoPro::isSeoProProcess()) {
-            return $this->parse();
+            $content = $this->parse();
+
+            if ($this->params->get('auto_link', false)) {
+                return app(AutomaticLinkManager::class)->inject($content);
+            }
+
+            return $content;
         }
 
         return ''.$this->parse().'';
diff --git a/src/TextProcessing/Links/AutomaticLinkManager.php b/src/TextProcessing/Links/AutomaticLinkManager.php
new file mode 100644
index 00000000..4216a33a
--- /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;
+                    }
+                });
+            });
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php b/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
index 6e028566..edd663e3 100644
--- a/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
+++ b/src/TextProcessing/Links/GlobalAutomaticLinksRepository.php
@@ -2,6 +2,7 @@
 
 namespace Statamic\SeoPro\TextProcessing\Links;
 
+use Illuminate\Support\Collection;
 use Statamic\SeoPro\Contracts\TextProcessing\Links\GlobalAutomaticLinksRepository as GlobalAutomaticLinksContract;
 use Statamic\SeoPro\TextProcessing\Models\AutomaticLink;
 
@@ -9,6 +10,16 @@ class GlobalAutomaticLinksRepository implements GlobalAutomaticLinksContract
 {
     public function deleteAutomaticLinksForSite(string $handle): void
     {
-        AutomaticLink::where('site', $handle)->delete();
+        AutomaticLink::query()
+            ->where('site', $handle)
+            ->delete();
+    }
+
+    public function getLinksForSite(string $handle): Collection
+    {
+        return AutomaticLink::query()
+            ->where('is_active', true)
+            ->where('site', $handle)
+            ->get();
     }
 }
\ No newline at end of file
diff --git a/src/TextProcessing/Links/LinkCrawler.php b/src/TextProcessing/Links/LinkCrawler.php
index 8b2f2fff..7e1f6525 100644
--- a/src/TextProcessing/Links/LinkCrawler.php
+++ b/src/TextProcessing/Links/LinkCrawler.php
@@ -134,6 +134,7 @@ public static function getLinkResults(string $content): LinkResults
             $result = [
                 'href' => $href,
                 'text' => $linkText,
+                'content' => $link['content'],
             ];
 
             if (URL::isExternal($href)) {
diff --git a/src/TextProcessing/Similarity/TextSimilarity.php b/src/TextProcessing/Similarity/TextSimilarity.php
new file mode 100644
index 00000000..bc9dbbdb
--- /dev/null
+++ b/src/TextProcessing/Similarity/TextSimilarity.php
@@ -0,0 +1,53 @@
+= $similarityThreshold) {
+            return true;
+        }
+
+        return false;
+    }
+}
\ No newline at end of file
diff --git a/src/TextProcessing/Suggestions/LinkResults.php b/src/TextProcessing/Suggestions/LinkResults.php
index 10c9eac5..7fce5b08 100644
--- a/src/TextProcessing/Suggestions/LinkResults.php
+++ b/src/TextProcessing/Suggestions/LinkResults.php
@@ -14,7 +14,7 @@ class LinkResults
 
     /**
      * @param array|null $links
-     * @return ($links is null ? (array{array{href:string,text:string}}) : null)
+     * @return ($links is null ? (array{array{href:string,text:string,content:string}}) : null)
      */
     public function internalLinks(?array $links = null)
     {
@@ -24,11 +24,22 @@ public function internalLinks(?array $links = null)
 
     /**
      * @param array|null $links
-     * @return ($links is null ? array{array{href:string,text:string}} : null)
+     * @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(),
+        );
+    }
 }
\ No newline at end of file

From 8529300571568c5e6c308da5cf21d38cb244e9a2 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 17:49:15 -0500
Subject: [PATCH 16/91] Inject current site, if available

---
 src/Tags/SeoProTags.php | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/Tags/SeoProTags.php b/src/Tags/SeoProTags.php
index d6d4e01e..29ac980e 100755
--- a/src/Tags/SeoProTags.php
+++ b/src/Tags/SeoProTags.php
@@ -2,6 +2,7 @@
 
 namespace Statamic\SeoPro\Tags;
 
+use Statamic\Facades\Site;
 use Statamic\SeoPro\Cascade;
 use Statamic\SeoPro\GetsSectionDefaults;
 use Statamic\SeoPro\RendersMetaHtml;
@@ -70,7 +71,11 @@ public function content()
             $content = $this->parse();
 
             if ($this->params->get('auto_link', false)) {
-                return app(AutomaticLinkManager::class)->inject($content);
+                return app(AutomaticLinkManager::class)
+                    ->inject(
+                        $content,
+                        Site::current()?->handle() ?? 'default',
+                    );
             }
 
             return $content;

From 6a4589143d275842a3ff6700a30aea2b39c2335f Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 19:27:31 -0500
Subject: [PATCH 17/91] Related content tag

---
 .../Linking/RelatedContentReport.php          |  6 +--
 src/Tags/SeoProTags.php                       | 44 +++++++++++++++++++
 2 files changed, 47 insertions(+), 3 deletions(-)

diff --git a/src/Reporting/Linking/RelatedContentReport.php b/src/Reporting/Linking/RelatedContentReport.php
index a0737d0c..a36929ca 100644
--- a/src/Reporting/Linking/RelatedContentReport.php
+++ b/src/Reporting/Linking/RelatedContentReport.php
@@ -15,11 +15,11 @@ public function relatedContent(?array $relatedContent = null)
             ->args(func_get_args());
     }
 
-    public function getRelated(): array
+    public function getRelated(bool $returnFullEntry = false): array
     {
-        return collect($this->relatedContent)->map(function (Result $result) {
+        return collect($this->relatedContent)->map(function (Result $result) use ($returnFullEntry) {
             return [
-                'entry' => $this->dumpEntry($result->entry()),
+                'entry' => $returnFullEntry ? $result->entry() : $this->dumpEntry($result->entry()),
                 'score' => $result->score(),
                 'keyword_score' => $result->keywordScore(),
                 'related_keywords' => implode(', ', array_keys($result->similarKeywords()))
diff --git a/src/Tags/SeoProTags.php b/src/Tags/SeoProTags.php
index 29ac980e..9b3b9973 100755
--- a/src/Tags/SeoProTags.php
+++ b/src/Tags/SeoProTags.php
@@ -2,13 +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
@@ -84,6 +88,46 @@ public function 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.
      *

From 7e8b72b8e35cfcb18c4feb017ff504d3479f74ed Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Sun, 15 Sep 2024 19:28:25 -0500
Subject: [PATCH 18/91] Reset this for anyone who might pull it down to
 experiment

Will re-disable it later
---
 config/seo-pro.php | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/config/seo-pro.php b/config/seo-pro.php
index 6301fb70..edca426a 100644
--- a/config/seo-pro.php
+++ b/config/seo-pro.php
@@ -59,6 +59,8 @@
 
     'text_analysis' => [
 
+        'enabled' => true,
+
         'openai' => [
             'api_key' => env('SEO_PRO_OPENAI_API_KEY'),
             'model' => 'text-embedding-3-small',

From 76c0f5465eb9277957d6e80f51a1ad98aac1a9e1 Mon Sep 17 00:00:00 2001
From: John Koster 
Date: Mon, 16 Sep 2024 18:12:59 -0500
Subject: [PATCH 19/91] Add filter presets to UI

---
 resources/js/components/links/Listing.vue | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/resources/js/components/links/Listing.vue b/resources/js/components/links/Listing.vue
index 8b6f27c1..81e804ab 100644
--- a/resources/js/components/links/Listing.vue
+++ b/resources/js/components/links/Listing.vue
@@ -28,6 +28,18 @@
             
+ +
Date: Mon, 16 Sep 2024 18:36:46 -0500 Subject: [PATCH 20/91] Adds a quick way to mark an entry as "not related" Uses existing ignore suggestion mechanism behind the scenes --- .../js/components/links/RelatedContent.vue | 34 +++++++++++++++++++ .../components/links/SuggestionsListing.vue | 1 + .../links/suggestions/IgnoreConfirmation.vue | 26 +++++++++++--- .../Linking/IgnoredSuggestionsController.php | 2 +- 4 files changed, 58 insertions(+), 5 deletions(-) diff --git a/resources/js/components/links/RelatedContent.vue b/resources/js/components/links/RelatedContent.vue index d8d1d0c4..8884b64e 100644 --- a/resources/js/components/links/RelatedContent.vue +++ b/resources/js/components/links/RelatedContent.vue @@ -29,21 +29,43 @@ +
+ + \ No newline at end of file diff --git a/src/Http/Controllers/Linking/IgnoredSuggestionsController.php b/src/Http/Controllers/Linking/IgnoredSuggestionsController.php index 541e59c8..d32ec329 100644 --- a/src/Http/Controllers/Linking/IgnoredSuggestionsController.php +++ b/src/Http/Controllers/Linking/IgnoredSuggestionsController.php @@ -27,7 +27,7 @@ public function create(IgnoreSuggestionRequest $request) $this->linksRepository->ignoreSuggestion(new IgnoredSuggestion( $data['action'], $data['scope'], - $data['phrase'], + $data['phrase'] ?? '', $data['entry'], $data['ignored_entry'], $data['site'] From a5fc1c62d35d3321d8339229008c28e9368e213c Mon Sep 17 00:00:00 2001 From: John Koster Date: Mon, 16 Sep 2024 18:46:44 -0500 Subject: [PATCH 21/91] Prevent fatal errors if an entry isn't available --- src/Reporting/Linking/BaseLinkReport.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Reporting/Linking/BaseLinkReport.php b/src/Reporting/Linking/BaseLinkReport.php index ac6a6c1d..e44d012c 100644 --- a/src/Reporting/Linking/BaseLinkReport.php +++ b/src/Reporting/Linking/BaseLinkReport.php @@ -73,8 +73,12 @@ protected function extraData(): array return []; } - protected function dumpEntry(Entry $entry): ?array + protected function dumpEntry(?Entry $entry): ?array { + if (! $entry) { + return []; + } + return [ 'title' => $entry->title, 'url' => $entry->absoluteUrl(), From b9c9f3e54c555b3ff21a2a8785aecf0b59b162c0 Mon Sep 17 00:00:00 2001 From: John Koster Date: Mon, 16 Sep 2024 18:47:01 -0500 Subject: [PATCH 22/91] Improve smartness of locating related entries. --- src/Reporting/Linking/ReportBuilder.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Reporting/Linking/ReportBuilder.php b/src/Reporting/Linking/ReportBuilder.php index c025c732..0c4ff009 100644 --- a/src/Reporting/Linking/ReportBuilder.php +++ b/src/Reporting/Linking/ReportBuilder.php @@ -191,6 +191,10 @@ public function getInternalLinks(Entry $entry): InternalLinksReport $uri = Str::before($link, '#'); + if (str_ends_with($uri, '/')) { + $uri = mb_substr($uri, -1); + } + $toLookup[] = [ 'original' => $link, 'uri' => $uri, From 72fb551b6e619b6073fc5579350771ef85519924 Mon Sep 17 00:00:00 2001 From: John Koster Date: Mon, 16 Sep 2024 18:49:38 -0500 Subject: [PATCH 23/91] Provide a quick way to edit related entries --- resources/js/components/links/RelatedContent.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/js/components/links/RelatedContent.vue b/resources/js/components/links/RelatedContent.vue index 8884b64e..4529871b 100644 --- a/resources/js/components/links/RelatedContent.vue +++ b/resources/js/components/links/RelatedContent.vue @@ -31,6 +31,8 @@ From 0be9e78630933e2163a0ad3c0bf3bdb53dcc3c52 Mon Sep 17 00:00:00 2001 From: John Koster Date: Mon, 16 Sep 2024 18:51:52 -0500 Subject: [PATCH 24/91] Link to related content suggestions instead Feels more consistent and less like we're going all over the place --- resources/js/components/links/RelatedContent.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/js/components/links/RelatedContent.vue b/resources/js/components/links/RelatedContent.vue index 4529871b..ebf8f16b 100644 --- a/resources/js/components/links/RelatedContent.vue +++ b/resources/js/components/links/RelatedContent.vue @@ -22,7 +22,7 @@ :sortable="true" > @@ -80,6 +80,10 @@ export default { methods: { + makeSuggestionsUrl(entryId) { + return cp_url(`seo-pro/links/${entryId}/suggestions`); + }, + makeSuggestion(related) { return { phrase: '', From 830bd14d07fca02fdcc150b89c42bb3d06a61e3e Mon Sep 17 00:00:00 2001 From: John Koster Date: Mon, 16 Sep 2024 18:52:29 -0500 Subject: [PATCH 25/91] Consistency --- resources/js/components/links/RelatedContent.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/js/components/links/RelatedContent.vue b/resources/js/components/links/RelatedContent.vue index ebf8f16b..f87a45ac 100644 --- a/resources/js/components/links/RelatedContent.vue +++ b/resources/js/components/links/RelatedContent.vue @@ -29,11 +29,11 @@ - + + \ 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 index f87a45ac..209a0e6d 100644 --- a/resources/js/components/links/RelatedContent.vue +++ b/resources/js/components/links/RelatedContent.vue @@ -54,6 +54,7 @@