From 73b21b56a7ccba0cfd24c4702168746f39ca0706 Mon Sep 17 00:00:00 2001 From: Wagner Trezub Date: Fri, 12 Sep 2025 17:01:01 +0200 Subject: [PATCH 1/2] feat: refactoring the UniversalLink and agregades --- packages/cmsui/routes/auth/login.tsx | 10 +- packages/helpers/package.json | 10 +- packages/helpers/src/index.ts | 1 + packages/helpers/src/urlUtils.ts | 139 ++++++++++++++++++ .../UniversalLink/UniversalLink.tsx | 101 +++++++++++++ packages/layout/package.json | 1 + pnpm-lock.yaml | 21 ++- 7 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 packages/helpers/src/urlUtils.ts create mode 100644 packages/layout/components/UniversalLink/UniversalLink.tsx diff --git a/packages/cmsui/routes/auth/login.tsx b/packages/cmsui/routes/auth/login.tsx index e2b7cac3791..ecb4cc23fa4 100644 --- a/packages/cmsui/routes/auth/login.tsx +++ b/packages/cmsui/routes/auth/login.tsx @@ -16,6 +16,7 @@ import ArrowRightSVG from '@plone/components/icons/arrow-right.svg?react'; import type PloneClient from '@plone/client'; import config from '@plone/registry'; +import { UniversalLink } from '@plone/layout/components/UniversalLink/UniversalLink'; export const loader = redirectIfLoggedInLoader; @@ -44,7 +45,8 @@ export default function Login() { const actionResult = useActionData(); return ( -
+
+ test
@@ -85,6 +87,12 @@ export default function Login() { > +

+ ancor +

diff --git a/packages/helpers/package.json b/packages/helpers/package.json index 1491493e4f0..4d270d7efdc 100644 --- a/packages/helpers/package.json +++ b/packages/helpers/package.json @@ -61,16 +61,20 @@ } }, "dependencies": { + "@plone/react-router": "workspace:*", "jotai": "^2.12.3", "jotai-optics": "^0.4.0", - "optics-ts": "^2.4.1" + "optics-ts": "^2.4.1", + "react-router": "catalog:", + "validator": "^13.15.15" }, "devDependencies": { - "@tanstack/react-form": "^1.3.3", - "@plone/types": "workspace:*", "@plone/registry": "workspace:*", + "@plone/types": "workspace:*", + "@tanstack/react-form": "^1.3.3", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "@types/validator": "^13.15.3", "release-it": "catalog:", "tsconfig": "workspace:*", "tsup": "catalog:", diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 3b19de8d533..3ada52f7682 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -1,3 +1,4 @@ export * from './primitives'; export * from './atoms'; export * from './flattenToAppURL'; +export * from './urlUtils'; diff --git a/packages/helpers/src/urlUtils.ts b/packages/helpers/src/urlUtils.ts new file mode 100644 index 00000000000..64f5fb2d1fe --- /dev/null +++ b/packages/helpers/src/urlUtils.ts @@ -0,0 +1,139 @@ +import { matchPath } from 'react-router'; +import config from '@plone/registry'; + +import validator from 'validator'; + +type ExternalRoute = string | { match: string }; + +interface Settings { + publicURL: string; + internalApiPath?: string; + apiPath: string; + externalRoutes?: ExternalRoute[]; +} + +export function isInternalURL(url: string): boolean { + const { settings } = config as { settings: Settings }; + + const isMatch = (settings.externalRoutes ?? []).find((route) => { + if (typeof route === 'object') { + return matchPath(url, route.match); + } + return matchPath(url, route); + }); + + const isExcluded = Boolean(isMatch && Object.keys(isMatch).length > 0); + + const internalURL = + !!url && + (url.indexOf(settings.publicURL) !== -1 || + (settings.internalApiPath && + url.indexOf(settings.internalApiPath) !== -1) || + url.indexOf(settings.apiPath) !== -1 || + url.charAt(0) === '/' || + url.charAt(0) === '.' || + url.startsWith('#')); + + if (internalURL && isExcluded) { + return false; + } + + return internalURL; +} + +export function removeProtocol( + url: string, + protocol: string = 'https://', +): string { + return url + .replace(protocol, '') + .replace(protocol === 'https://' ? 'http://' : 'https://', ''); +} + +export function isMail(text: string): boolean { + return validator.isEmail(text); +} + +export function isTelephone(text: string): boolean { + return validator.isMobilePhone(text, 'any'); // pode passar locale: 'it-IT', 'pt-BR', etc. +} + +export function normaliseMail(email: string): string { + if (email?.toLowerCase()?.startsWith('mailto:')) { + return email; + } + return `mailto:${email}`; +} + +export function normalizeTelephone(tel: string): string { + if (tel?.toLowerCase()?.startsWith('tel:')) { + return tel; + } + return `tel:${tel}`; +} + +export function normalizeUrl(url: string): string { + if (!url) return ''; + + let candidate = url.trim(); + + // If the URL does not start with a protocol, add 'https://' + if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(candidate)) { + candidate = `https://${candidate}`; + } + + // Validate the URL with validator.js + if (!validator.isURL(candidate, { require_protocol: true })) { + return ''; // Invalid URL, return empty string + } + + // Use the URL constructor to normalize the URL + return new URL(candidate).href; +} + +export function isUrl(url: string): boolean { + return validator.isURL(url, { require_protocol: true }); +} + +export function checkAndNormalizeUrl(url: string) { + const res = { + isMail: false, + isTelephone: false, + url: url, + isValid: true, + }; + + if (URLUtils.isMail(URLUtils.normaliseMail(url))) { + // Mail + res.isMail = true; + res.url = URLUtils.normaliseMail(url); + } else if (URLUtils.isTelephone(url)) { + // Phone + res.isTelephone = true; + res.url = URLUtils.normalizeTelephone(url); + } else { + // URL + if ( + res.url?.length >= 0 && + !res.url.startsWith('/') && + !res.url.startsWith('#') + ) { + res.url = URLUtils.normalizeUrl(url); + if (!URLUtils.isUrl(res.url)) { + res.isValid = false; + } + } + if (res.url === undefined || res.url === null) res.isValid = false; + } + return res; +} + +export const URLUtils = { + normalizeTelephone, + normaliseMail, + normalizeUrl, + isTelephone, + isMail, + isUrl, + checkAndNormalizeUrl, +}; diff --git a/packages/layout/components/UniversalLink/UniversalLink.tsx b/packages/layout/components/UniversalLink/UniversalLink.tsx new file mode 100644 index 00000000000..4d79fd2e5c4 --- /dev/null +++ b/packages/layout/components/UniversalLink/UniversalLink.tsx @@ -0,0 +1,101 @@ +import React, { forwardRef } from 'react'; +import { Link as RouterLink } from '@plone/components'; +import cx from 'clsx'; +import { isInternalURL, URLUtils } from '@plone/helpers'; + +export type UniversalLinkProps = { + href?: string; + item?: { + '@id'?: string; + '@type'?: string; + remoteUrl?: string; + getRemoteUrl?: string; + }; + openInNewTab?: boolean; + download?: boolean; + children: React.ReactNode; + className?: string; + title?: string; + smooth?: boolean; + token?: string; // prepared for future use, not used now +}; + +const getUrl = (href?: string, item?: UniversalLinkProps['item']): string => { + if (href) return href; + if (item) { + if (item.remoteUrl) return item.remoteUrl; + if (item.getRemoteUrl) return item.getRemoteUrl; + if (item['@id']) return item['@id']; + } + return '#'; +}; + +export const UniversalLink = forwardRef( + ( + { + href, + item, + children, + className, + title, + smooth, + openInNewTab, + download, + token, + }, + ref, + ) => { + const url = getUrl(href, item); + + const checkedURL = URLUtils.checkAndNormalizeUrl(url); + const isExternal = !isInternalURL(url); + const isDownload = download || url.includes('@@download/file'); + const isDisplayFile = url.includes('@@display-file/file'); + + if (isInternalURL(url)) { + if (isDownload) { + return ( + + {children} + + ); + } + if (isDisplayFile) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + } + + // external + return ( + + {children} + + ); + }, +); + +UniversalLink.displayName = 'UniversalLink'; diff --git a/packages/layout/package.json b/packages/layout/package.json index a0027608a20..26e2ac31ffd 100644 --- a/packages/layout/package.json +++ b/packages/layout/package.json @@ -54,6 +54,7 @@ "dependencies": { "@plone/blocks": "workspace:*", "@plone/components": "workspace:*", + "@plone/helpers": "workspace:*", "@plone/registry": "workspace:*", "lodash.sortby": "^4.7.0", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5a17b6e007..fb87b8fcaa8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,6 +741,9 @@ importers: packages/helpers: dependencies: + '@plone/react-router': + specifier: workspace:* + version: link:../react-router jotai: specifier: ^2.12.5 version: 2.14.0(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@19.1.12)(react@19.1.1) @@ -756,6 +759,12 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.1(react@19.1.1) + react-router: + specifier: 'catalog:' + version: 7.7.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + validator: + specifier: ^13.15.15 + version: 13.15.15 devDependencies: '@plone/registry': specifier: workspace:* @@ -772,6 +781,9 @@ importers: '@types/react-dom': specifier: 'catalog:' version: 19.1.9(@types/react@19.1.12) + '@types/validator': + specifier: ^13.15.3 + version: 13.15.3 release-it: specifier: 'catalog:' version: 18.1.2(@types/node@22.18.1)(typescript@5.9.2) @@ -796,6 +808,9 @@ importers: '@plone/components': specifier: workspace:* version: link:../components + '@plone/helpers': + specifier: workspace:* + version: link:../helpers '@plone/registry': specifier: workspace:* version: link:../registry @@ -4769,6 +4784,9 @@ packages: '@types/uuid@9.0.8': resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/validator@13.15.3': + resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -8070,7 +8088,6 @@ packages: resolution: {integrity: sha512-Quz3MvAwHxVYNXsOByL7xI5EB2WYOeFswqaHIA3qOK3isRWTxiplBEocmmru6XmxDB2L7jDNYtYA4FyimoAFEw==} engines: {node: '>=8.17.0'} hasBin: true - bundledDependencies: [] jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} @@ -15094,6 +15111,8 @@ snapshots: '@types/uuid@9.0.8': {} + '@types/validator@13.15.3': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': From 1f819b42a0a2f1d0b7fd65c3efa3b0da0352a177 Mon Sep 17 00:00:00 2001 From: Wagner Trezub Date: Fri, 12 Sep 2025 17:04:28 +0200 Subject: [PATCH 2/2] chore: changelog --- packages/layout/news/7356.feature | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/layout/news/7356.feature diff --git a/packages/layout/news/7356.feature b/packages/layout/news/7356.feature new file mode 100644 index 00000000000..09bb03cb720 --- /dev/null +++ b/packages/layout/news/7356.feature @@ -0,0 +1 @@ +Refactoring UniversalLink @Wagner3UB \ No newline at end of file