diff --git a/package.json b/package.json index 3616afba..a5494495 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hat-ring-components", - "version": "4.4.4", + "version": "4.5.0", "description": "Head App Template - RING components", "license": "MIT", "repository": {}, diff --git a/src/components/widgets/Story/StoryContent/StoryContentBlocks/OrderedListBlock.astro b/src/components/widgets/Story/StoryContent/StoryContentBlocks/OrderedListBlock.astro index 3857fc49..f6d83c99 100644 --- a/src/components/widgets/Story/StoryContent/StoryContentBlocks/OrderedListBlock.astro +++ b/src/components/widgets/Story/StoryContent/StoryContentBlocks/OrderedListBlock.astro @@ -3,6 +3,7 @@ const { context, blockData }: OrderedListBlockParams = Astro.props as OrderedListBlockParams; import { WidgetHelper_renderEmptyComponent } from "../../../../../helpers/WidgetHelper"; import {LinkReplacerHelper_replaceLinks} from '../../../../../helpers/LinkReplacerHelper'; +import { SeoLinkWhitelistHelper_processLinks } from "../../../../../helpers/seo/SeoLinkWhitelistHelper"; import { AppContext } from "../../../../../types/types"; export interface OrderedListBlockParams { @@ -15,14 +16,16 @@ export interface OrderedListBlockParams { startValue: number; }; } -let toRender: boolean | JSX.Element = false; -if (!blockData) { +let toRender: boolean | JSX.Element | string = false; +if (!blockData?.entries?.length) { toRender = WidgetHelper_renderEmptyComponent("OrderedListBlock"); +} else { + blockData.entries = await Promise.all(blockData.entries.map(async (entry) => { + let processed = await LinkReplacerHelper_replaceLinks(context, entry); + return SeoLinkWhitelistHelper_processLinks(context, processed); + })); } -blockData.entries = await Promise.all(blockData.entries.map(async (entry) => { - return LinkReplacerHelper_replaceLinks(context, entry); -})); --- { diff --git a/src/components/widgets/Story/StoryContent/StoryContentBlocks/ParagraphBlock.astro b/src/components/widgets/Story/StoryContent/StoryContentBlocks/ParagraphBlock.astro index 3281fea4..8be61930 100644 --- a/src/components/widgets/Story/StoryContent/StoryContentBlocks/ParagraphBlock.astro +++ b/src/components/widgets/Story/StoryContent/StoryContentBlocks/ParagraphBlock.astro @@ -2,6 +2,7 @@ const { context, blockData }: ParagraphBlockParams = Astro.props as ParagraphBlockParams; import { WidgetHelper_renderEmptyComponent } from "../../../../../helpers/WidgetHelper"; import {LinkReplacerHelper_replaceLinks} from '../../../../../helpers/LinkReplacerHelper'; +import { SeoLinkWhitelistHelper_processLinks } from "../../../../../helpers/seo/SeoLinkWhitelistHelper"; import { AppContext } from "../../../../../types/types"; export interface ParagraphBlockParams { @@ -17,6 +18,7 @@ if (!blockData) { } blockData.text = await LinkReplacerHelper_replaceLinks(context, blockData.text); +blockData.text = await SeoLinkWhitelistHelper_processLinks(context, blockData.text); --- { diff --git a/src/components/widgets/Story/StoryContent/StoryContentBlocks/UnorderedListBlock.astro b/src/components/widgets/Story/StoryContent/StoryContentBlocks/UnorderedListBlock.astro index 287c57c8..3e0bbea8 100644 --- a/src/components/widgets/Story/StoryContent/StoryContentBlocks/UnorderedListBlock.astro +++ b/src/components/widgets/Story/StoryContent/StoryContentBlocks/UnorderedListBlock.astro @@ -3,6 +3,7 @@ const { context, blockData }: UnorderedListBlockParams = Astro.props as UnorderedListBlockParams; import { WidgetHelper_renderEmptyComponent } from "../../../../../helpers/WidgetHelper"; import {LinkReplacerHelper_replaceLinks} from '../../../../../helpers/LinkReplacerHelper'; +import { SeoLinkWhitelistHelper_processLinks } from "../../../../../helpers/seo/SeoLinkWhitelistHelper"; import { AppContext } from "../../../../../types/types"; export interface UnorderedListBlockParams { @@ -14,15 +15,16 @@ export interface UnorderedListBlockParams { styleType: string; }; } -let toRender: boolean | JSX.Element = false; +let toRender: boolean | JSX.Element | string = false; -if (!blockData) { +if (!blockData?.entries?.length) { toRender = WidgetHelper_renderEmptyComponent("UnorderedListBlock"); +} else { + blockData.entries = await Promise.all(blockData.entries.map(async (entry) => { + let processed = await LinkReplacerHelper_replaceLinks(context, entry); + return SeoLinkWhitelistHelper_processLinks(context, processed); + })); } - -blockData.entries = await Promise.all(blockData.entries.map(async (entry) => { - return LinkReplacerHelper_replaceLinks(context, entry); -})); --- { diff --git a/src/configs/SEOWebsitesConfig.ts b/src/configs/SEOWebsitesConfig.ts index 86a1c244..cd0a40f7 100644 --- a/src/configs/SEOWebsitesConfig.ts +++ b/src/configs/SEOWebsitesConfig.ts @@ -68,6 +68,14 @@ export let SEOWebsitesConfig "fields": [ "metaData.customMetaTags" ] + }, + "contentLinks": { + "type": "group", + "name": "Content Link Settings", + "fields": [ + "contentLinks.enableDomainWhitelist", + "contentLinks.whitelistedDomains" + ] } }, "keys": [ @@ -76,7 +84,8 @@ export let SEOWebsitesConfig "seoLanguages", "rssDefault", "seoOpenGraph", - "metaData" + "metaData", + "contentLinks" ] } ], @@ -121,6 +130,10 @@ export let SEOWebsitesConfig }, "metaData": { "customMetaTags": [] + }, + "contentLinks": { + "enableDomainWhitelist": false, + "whitelistedDomains": [] } }, "paramsDescription": { @@ -338,6 +351,29 @@ export let SEOWebsitesConfig } ] } + }, + "contentLinks": { + "enableDomainWhitelist": { + "name": "Enable domain whitelist for content links", + "type": "checkbox" + }, + "whitelistedDomains": { + "type": "treeobject", + "name": "Whitelisted domains", + "description": "List of whitelisted domains. All links from content outside this list will have 'nofollow'.", + "properties": [ + { + "name": "domain", + "description": "Domain name (e.g., example.com, example.xy.com)", + "type": "textfield" + }, + { + "name": "relAttribute", + "description": "Rel attribute value for this domain (leave empty for default), e.g., noreferrer, noopener", + "type": "textfield" + } + ] + } } } } diff --git a/src/helpers/ConfigHelper.ts b/src/helpers/ConfigHelper.ts index 632e16b1..365d9eb7 100644 --- a/src/helpers/ConfigHelper.ts +++ b/src/helpers/ConfigHelper.ts @@ -223,3 +223,8 @@ export async function ConfigHelper_getMainCategoryUuid(context) { const developerSettings = await ConfigHelper_getDeveloperSettingsConfig(context); return developerSettings ? developerSettings.mainCategoryUuid : ''; } + +export async function ConfigHelper_getContentLinksConfig(context) { + return ConfigHelper_getConfig(context, 'contentLinks'); +} + diff --git a/src/helpers/seo/SeoLinkWhitelistHelper.ts b/src/helpers/seo/SeoLinkWhitelistHelper.ts new file mode 100644 index 00000000..b14e33a2 --- /dev/null +++ b/src/helpers/seo/SeoLinkWhitelistHelper.ts @@ -0,0 +1,98 @@ +import { AppContext } from "../../types/types"; +import { ConfigHelper_getContentLinksConfig } from "../ConfigHelper"; + +interface WhitelistedDomain { + domain: string; + relAttribute?: string; +} + +interface ContentLinksConfig { + enableDomainWhitelist?: boolean; + whitelistedDomains?: WhitelistedDomain[]; +} + +const LINK_REGEX = /]*?href=["'](https?:\/\/[^"']+)["'][^>]*?>/gi; +const REL_REGEX = /\s+rel=["']([^"']*)["']/i; + +export async function SeoLinkWhitelistHelper_processLinks(context: AppContext, text: string): Promise { + if (!text) { + return text; + } + + try { + const contentLinksConfig = await ConfigHelper_getContentLinksConfig(context) as ContentLinksConfig; + const whitelistedDomainsList = contentLinksConfig?.whitelistedDomains; + + if (!contentLinksConfig?.enableDomainWhitelist || !whitelistedDomainsList?.length) { + return text; + } + + const whitelistedDomains = whitelistedDomainsList + .filter((item) => item.domain?.trim()) + .map((item) => ({ + ...item, + domain: item.domain.trim().toLowerCase() + })); + + if (whitelistedDomains.length === 0) { + return text; + } + + return text.replace(LINK_REGEX, (matchedLink, href) => { + let domain: string; + try { + domain = new URL(href).hostname.toLowerCase(); + } catch { + return matchedLink; + } + + const matchWithWhitelist = whitelistedDomains.find((item) => + domain === item.domain || domain.endsWith(`.${item.domain}`) + ); + + const existingRelMatch = matchedLink.match(REL_REGEX); + const currentRelAttributes = existingRelMatch?.[1] + ? existingRelMatch[1].split(/\s+/).filter(Boolean) + : []; + + if (matchWithWhitelist) { + // Remove nofollow for whitelisted domains + const filtered = currentRelAttributes.filter((attr) => attr !== "nofollow"); + + // Add configured rel attributes + if (matchWithWhitelist.relAttribute) { + const configAttrs = matchWithWhitelist.relAttribute.toLowerCase().split(/\s+/).filter(Boolean); + configAttrs.forEach((attr) => { + if (!filtered.includes(attr)) { + filtered.push(attr); + } + }); + } + currentRelAttributes.length = 0; + currentRelAttributes.push(...filtered); + } else { + // Add nofollow for non-whitelisted domains + if (!currentRelAttributes.includes("nofollow")) { + currentRelAttributes.push("nofollow"); + } + } + + const newRelString = currentRelAttributes.length > 0 + ? ` rel="${currentRelAttributes.join(" ")}"` + : ""; + + if (existingRelMatch) { + return matchedLink.replace(REL_REGEX, newRelString); + } + + if (newRelString) { + return matchedLink.slice(0, -1) + newRelString + ">"; + } + + return matchedLink; + }); + } catch (error) { + console.error("SeoLinkWhitelistHelper: Error processing links", error); + return text; + } +}