'use client' import type React from 'react' import cx from 'clsx' import { LangLink } from '@/ui/byline/components/link/lang-link' import type { Locale } from '@/ui/byline/types/i18n' interface BaseLinkAttributes { newTab?: boolean nofollow?: boolean rel?: string | null } export interface CustomLinkAttributes extends BaseLinkAttributes { linkType?: 'custom' url?: string } /** * Internal link to a Byline document. Mirrors `DocumentRelation` — * `targetDocumentId` / `targetCollectionId` / `targetCollectionPath` * flattened onto the attributes, plus a `document` bag carrying the * canonical `{ title, path }` envelope embedded by the picker at write * time and refreshed by the server-side write-time embed walker. * * `document.path` has dual meaning during migration: * - leading `/` — composed by `CollectionDefinition.buildDocumentPath` * (or the generic `/${collectionPath}/${slug}` fallback) and treated * as authoritative by this serializer. * - no leading `/` — bare slug from `byline_document_paths`, either * legacy data or a picker-time write that hasn't been through the * walker yet. The serializer applies the generic compose fallback * using `targetCollectionPath`. * * `document._resolved === false` means the most recent walker pass * could not find the target document (deleted between picker and * save / read). The serializer strips the `` wrapper and renders * children as plain text — persisted state is preserved so an editor * can re-link later. */ export interface InternalLinkAttributes extends BaseLinkAttributes { linkType: 'internal' targetDocumentId: string targetCollectionId: string targetCollectionPath: string document?: { title?: string path?: string _resolved?: false } } export type LinkAttributes = CustomLinkAttributes | InternalLinkAttributes export type LinkType = 'internal' | 'custom' export interface LinkLexicalProps { attributes: LinkAttributes lng: Locale className?: string onMouseEnter?: () => void onMouseLeave?: () => void children?: React.ReactNode } export function manageRel(input: string, action: 'add' | 'remove', value: string): string { let result: string let mutableInput = `${input}` if (action === 'add') { // if we somehow got out of sync - clean up if (mutableInput.includes(value)) { const re = new RegExp(value, 'g') mutableInput = mutableInput.replace(re, '').trim() } mutableInput = mutableInput.trim() result = mutableInput.length === 0 ? `${value}` : `${mutableInput} ${value}` } else { const re = new RegExp(value, 'g') result = mutableInput.replace(re, '').trim() } return result } // Hrefs we treat as "stays in the page / app" — skip URL parsing, // skip the external-link icon, don't force `target="_blank"`. `#anchor` // is an intra-page jump; empty hrefs would be inert placeholders but // `LinkLexicalSerializer` short-circuits those before reaching here. function isLocalHref(href: string): boolean { if (href.length === 0) return true if (href.startsWith('#')) return true return ['tel:', 'mailto:', '/'].some((prefix) => href.startsWith(prefix)) } /** * Resolve the renderable href for a link node. Returns `''` when no * usable href can be built — the serializer treats that as the signal * to strip the `` / `` wrapper and render children plain. * * Internal-link fallback chain (see docs/04-collections/07-rich-text.md): * 1. `document._resolved === false` → strip wrapper. * 2. `document.path` starts with `/` → use as-is (canonicalised by * the server-side embed walker via `buildDocumentPath`). * 3. `document.path` is a bare slug + `targetCollectionPath` present * → generic compose `/${targetCollectionPath}/${path}`. Heal-on- * write fallback for legacy nodes and picker-time-but-not-yet- * walked sessions. * 4. Neither — strip wrapper. */ function getHref(args: LinkAttributes): string { let href = '' const publicWebsiteUrl = '/' // getPublicWebsiteUrl() if (args.linkType === 'internal') { // Step 1 — walker explicitly marked the target as missing. if (args.document?._resolved === false) return '' const path = args.document?.path if (path != null && path.length > 0) { if (path.startsWith('/')) { // Step 2 — canonical path written by the embed walker. href = path } else if (args.targetCollectionPath) { // Step 3 — bare slug, generic compose fallback. href = `/${args.targetCollectionPath}/${path}` } // else: fall through to step 4 — empty href, wrapper stripped. } } else if (args.url != null) { href = args.url } if (!isLocalHref(href)) { // Relative custom hrefs are valid but cannot be passed to URL without a base. if (/^[a-z][a-z\d+.-]*:/i.test(href)) { try { const objectURL = new URL(href) if (objectURL.origin === publicWebsiteUrl) { href = objectURL.href.replace(publicWebsiteUrl, '') } } catch (e) { console.error(`Failed to format url: ${href}`, e) } } } return href } function getAdditionalProps( args: LinkAttributes, href: string ): { rel: string | undefined target: string | undefined } { const additionalProps: { rel: string | undefined target: string | undefined } = { rel: undefined, target: undefined, } let rel = '' if (args.nofollow === true) rel = manageRel(rel, 'add', 'nofollow') if (args.newTab === true) rel = manageRel(rel, 'add', 'noopener') additionalProps.rel = rel if (args.newTab === true) { additionalProps.target = '_blank' } if (!isLocalHref(href)) { additionalProps.target = '_blank' } if (additionalProps.rel == null || additionalProps.rel.length === 0) delete additionalProps.rel if (additionalProps.target == null) delete additionalProps.target return additionalProps } export function LinkLexicalSerializer({ attributes, lng, className, onMouseEnter, onMouseLeave, children, }: LinkLexicalProps): React.JSX.Element { const href = getHref(attributes) // No usable href — render children plain (no anchor) so the public site // never carries a broken ``. Covers `_resolved: false` and // every other empty-href branch of `getHref`. The admin editor reads // `__attributes` directly via Lexical APIs, so the link node stays // visible in the editor for re-linking. if (href.length === 0) { return <>{children} } const additionalProps = getAdditionalProps(attributes, href) if (href.startsWith('/')) { return ( {children} ) } // Local but not a router path (#anchor, tel:, mailto:): plain // , no external-link affordance. if (isLocalHref(href)) { return ( {children} ) } return ( {children} ) }