Skip to content

Commit c92d1f7

Browse files
committed
Improve changelog link parsing & grid icons
Refactor changelog rendering to robustly handle links and labels: introduce renderLink and labelForUrl helpers, honor explicit [label](url) markdown, heuristically link bare GitHub URLs, compare ranges, and PR numbers (including inline component detection). Replace brittle regex-based cleaning with segmented parsing and keep link styling/hover behavior consistent. Also avoid mutating intermediate strings when extracting bullet points. In ecosystem app grid, add an ArrowRightIcon and use it for site-relative (internal) integration links while keeping the ExternalLinkIcon for off-site links.
1 parent 7671222 commit c92d1f7

2 files changed

Lines changed: 144 additions & 124 deletions

File tree

‎snippets/changelog.jsx‎

Lines changed: 113 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -635,132 +635,127 @@ sei-tendermint
635635
return repoMap[compName] || 'sei-protocol/sei-chain';
636636
};
637637

638-
const cleanedContent = content
639-
.replace(/\]\(https:\/\/[^)]+\)/g, '')
640-
.replace(/\[/g, '')
641-
.replace(/\]/g, '');
638+
const renderLink = (key, href, text, marginClass = 'ml-1') => (
639+
<a
640+
key={key}
641+
href={href}
642+
target="_blank"
643+
rel="noopener noreferrer"
644+
className={`px-2 py-0.5 text-sm font-medium no-underline ${marginClass}`}
645+
style={linkBaseStyle()}
646+
onMouseEnter={(e) => applyLinkHover(e, true)}
647+
onMouseLeave={(e) => applyLinkHover(e, false)}>
648+
{text}
649+
</a>
650+
);
642651

643-
const urlPattern = /(https:\/\/github\.com\/[^\s)]+)/g;
644-
const parts = cleanedContent.split(urlPattern);
652+
// Friendly label for a bare GitHub URL that has no explicit markdown label.
653+
const labelForUrl = (url) => {
654+
if (url.includes('/compare/')) {
655+
const m = url.match(/\/compare\/([^/\s]+)/);
656+
if (m) return `Compare ${m[1].replace('...', ' → ')}`;
657+
} else if (url.includes('/releases/tag/')) {
658+
const m = url.match(/\/releases\/tag\/([^/\s]+)/);
659+
if (m) return `Release ${m[1]}`;
660+
} else if (url.includes('/pull/')) {
661+
const m = url.match(/\/pull\/(\d+)/);
662+
if (m) return `#${m[1]}`;
663+
}
664+
return url;
665+
};
645666

646-
return (
647-
<span className="text-base">
648-
{parts.map((part, i) => {
649-
if (part.match(urlPattern)) {
650-
let linkText = part;
651-
if (part.includes('/compare/')) {
652-
const compareMatch = part.match(/\/compare\/([^/\s]+)/);
653-
if (compareMatch) linkText = `Compare ${compareMatch[1].replace('...', ' → ')}`;
654-
} else if (part.includes('/releases/tag/')) {
655-
const tagMatch = part.match(/\/releases\/tag\/([^/\s]+)/);
656-
if (tagMatch) linkText = `Release ${tagMatch[1]}`;
657-
} else if (part.includes('/pull/')) {
658-
const prMatch = part.match(/\/pull\/(\d+)/);
659-
if (prMatch) linkText = `#${prMatch[1]}`;
660-
}
661-
return (
662-
<a
663-
key={i}
664-
href={part}
665-
target="_blank"
666-
rel="noopener noreferrer"
667-
className="px-2 py-0.5 text-sm font-medium no-underline ml-1"
668-
style={linkBaseStyle()}
669-
onMouseEnter={(e) => applyLinkHover(e, true)}
670-
onMouseLeave={(e) => applyLinkHover(e, false)}>
671-
{linkText}
672-
</a>
673-
);
674-
}
667+
// Render a run of text that contains no [label](url) markdown links. Bare
668+
// GitHub URLs, "Compare X → Y" strings, and bare PR numbers are still linked
669+
// heuristically so entries authored without markdown link syntax keep working.
670+
const renderPlainSegment = (text, keyPrefix) => {
671+
const stripped = text.replace(/\[/g, '').replace(/\]/g, '');
672+
const urlPattern = /(https:\/\/github\.com\/[^\s)]+)/g;
673+
const parts = stripped.split(urlPattern);
674+
675+
return parts.map((part, i) => {
676+
const key = `${keyPrefix}-${i}`;
677+
if (part.match(urlPattern)) {
678+
return renderLink(key, part, labelForUrl(part));
679+
}
675680

676-
const compareMatch = part.match(
677-
/Compare\s+((?:sei-[a-z]+-)?(v?\d+\.\d+\.\d+(?:\.\d+)?(?:beta|alpha|rc\d*)?(?:-release)?))(?:\s*[→-]\s*|\.\.\.)+(v?\d+\.\d+\.\d+(?:\.\d+)?(?:beta|alpha|rc\d*)?(?:-release)?)/i
681+
const compareMatch = part.match(
682+
/Compare\s+((?:sei-[a-z]+-)?(v?\d+\.\d+\.\d+(?:\.\d+)?(?:beta|alpha|rc\d*)?(?:-release)?))(?:\s*[→-]\s*|\.\.\.)+(v?\d+\.\d+\.\d+(?:\.\d+)?(?:beta|alpha|rc\d*)?(?:-release)?)/i
683+
);
684+
if (compareMatch) {
685+
const [fullMatch, fromVersion, , toVersion] = compareMatch;
686+
const cleanFromVersion = fromVersion.replace(/^sei-[a-z]+-/, '');
687+
const cleanToVersion = toVersion.replace(/^sei-[a-z]+-/, '');
688+
const repoPath = getRepoUrl(componentName);
689+
const compareUrl = `https://github.com/${repoPath}/compare/${cleanFromVersion}...${cleanToVersion}`;
690+
return (
691+
<span key={key}>
692+
{part.replace(fullMatch, '')}
693+
{renderLink(`${key}-cmp`, compareUrl, `Compare ${cleanFromVersion} → ${cleanToVersion}`)}
694+
</span>
678695
);
679-
if (compareMatch) {
680-
const [fullMatch, fromVersion, , toVersion] = compareMatch;
681-
const cleanFromVersion = fromVersion.replace(/^sei-[a-z]+-/, '');
682-
const cleanToVersion = toVersion.replace(/^sei-[a-z]+-/, '');
683-
const repoPath = getRepoUrl(componentName);
684-
const compareUrl = `https://github.com/${repoPath}/compare/${cleanFromVersion}...${cleanToVersion}`;
685-
return (
686-
<span key={i}>
687-
{part.replace(fullMatch, '')}
688-
<a
689-
href={compareUrl}
690-
target="_blank"
691-
rel="noopener noreferrer"
692-
className="px-2 py-0.5 text-sm font-medium no-underline ml-1"
693-
style={linkBaseStyle()}
694-
onMouseEnter={(e) => applyLinkHover(e, true)}
695-
onMouseLeave={(e) => applyLinkHover(e, false)}>
696-
Compare {cleanFromVersion} → {cleanToVersion}
697-
</a>
698-
</span>
699-
);
700-
}
696+
}
701697

702-
const prParts = part.split(/(\b\d{1,4}\b|#\d+)/g);
703-
return (
704-
<span key={i}>
705-
{prParts.map((subPart, j) => {
706-
const trimmedPart = subPart.trim();
707-
if (trimmedPart.match(/^#\d+$/)) {
708-
const num = trimmedPart.slice(1);
698+
const prParts = part.split(/(\b\d{1,4}\b|#\d+)/g);
699+
return (
700+
<span key={key}>
701+
{prParts.map((subPart, j) => {
702+
const trimmedPart = subPart.trim();
703+
if (trimmedPart.match(/^#\d+$/)) {
704+
const num = trimmedPart.slice(1);
705+
const prevParts = prParts.slice(0, j).join('');
706+
const inlineComponentMatch = prevParts.match(/\b(sei-chain|sei-tendermint|sei-cosmos|sei-db|sei-wasmd|sei-iavl|sei-ibc-go|tm-db)\s*$/i);
707+
const effectiveComponent = inlineComponentMatch ? inlineComponentMatch[1] : componentName;
708+
const repoPath = getRepoUrl(effectiveComponent);
709+
return renderLink(`${key}-${j}`, `https://github.com/${repoPath}/pull/${num}`, `#${num}`);
710+
} else if (trimmedPart.match(/^\d{1,4}$/) && parseInt(trimmedPart, 10) > 0) {
711+
const prevPart = prParts[j - 1] || '';
712+
const nextPart = prParts[j + 1] || '';
713+
const followedByNonPR = nextPart.match(/^\s*(hop|limit|version|v\d|\.\d|px|ms|s\b|mb|gb|kb)/i);
714+
const precededByNonPR = prevPart.match(/(v|version|\d\.)\s*$/i);
715+
const isLikelyPRNumber =
716+
(j < 3 || prevPart.includes('•') || prevPart.includes('\n') || prevPart.includes('#')) &&
717+
!followedByNonPR &&
718+
!precededByNonPR &&
719+
parseInt(trimmedPart, 10) >= 10;
720+
721+
if (isLikelyPRNumber) {
709722
const prevParts = prParts.slice(0, j).join('');
710723
const inlineComponentMatch = prevParts.match(/\b(sei-chain|sei-tendermint|sei-cosmos|sei-db|sei-wasmd|sei-iavl|sei-ibc-go|tm-db)\s*$/i);
711724
const effectiveComponent = inlineComponentMatch ? inlineComponentMatch[1] : componentName;
712725
const repoPath = getRepoUrl(effectiveComponent);
713-
return (
714-
<a
715-
key={j}
716-
href={`https://github.com/${repoPath}/pull/${num}`}
717-
target="_blank"
718-
rel="noopener noreferrer"
719-
className="px-2 py-0.5 text-sm font-medium no-underline ml-1"
720-
style={linkBaseStyle()}
721-
onMouseEnter={(e) => applyLinkHover(e, true)}
722-
onMouseLeave={(e) => applyLinkHover(e, false)}>
723-
#{num}
724-
</a>
725-
);
726-
} else if (trimmedPart.match(/^\d{1,4}$/) && parseInt(trimmedPart, 10) > 0) {
727-
const prevPart = prParts[j - 1] || '';
728-
const nextPart = prParts[j + 1] || '';
729-
const followedByNonPR = nextPart.match(/^\s*(hop|limit|version|v\d|\.\d|px|ms|s\b|mb|gb|kb)/i);
730-
const precededByNonPR = prevPart.match(/(v|version|\d\.)\s*$/i);
731-
const isLikelyPRNumber =
732-
(j < 3 || prevPart.includes('•') || prevPart.includes('\n') || prevPart.includes('#')) &&
733-
!followedByNonPR &&
734-
!precededByNonPR &&
735-
parseInt(trimmedPart, 10) >= 10;
736-
737-
if (isLikelyPRNumber) {
738-
const prevParts = prParts.slice(0, j).join('');
739-
const inlineComponentMatch = prevParts.match(/\b(sei-chain|sei-tendermint|sei-cosmos|sei-db|sei-wasmd|sei-iavl|sei-ibc-go|tm-db)\s*$/i);
740-
const effectiveComponent = inlineComponentMatch ? inlineComponentMatch[1] : componentName;
741-
const repoPath = getRepoUrl(effectiveComponent);
742-
return (
743-
<a
744-
key={j}
745-
href={`https://github.com/${repoPath}/pull/${trimmedPart}`}
746-
target="_blank"
747-
rel="noopener noreferrer"
748-
className="px-2 py-0.5 text-sm font-medium no-underline mr-1"
749-
style={linkBaseStyle()}
750-
onMouseEnter={(e) => applyLinkHover(e, true)}
751-
onMouseLeave={(e) => applyLinkHover(e, false)}>
752-
#{trimmedPart}
753-
</a>
754-
);
755-
}
726+
return renderLink(`${key}-${j}`, `https://github.com/${repoPath}/pull/${trimmedPart}`, `#${trimmedPart}`, 'mr-1');
756727
}
757-
return <span key={j}>{subPart}</span>;
758-
})}
759-
</span>
760-
);
761-
})}
762-
</span>
763-
);
728+
}
729+
return <span key={`${key}-${j}`}>{subPart}</span>;
730+
})}
731+
</span>
732+
);
733+
});
734+
};
735+
736+
// Honor explicit [label](url) markdown links first: the rendered href is the
737+
// real target from the source, never a number re-derived from the label.
738+
// Upstream entries sometimes label an issue (e.g. #1850) while linking the PR
739+
// that fixed it (e.g. /pull/1853), so the source URL must win.
740+
const markdownLinkPattern = /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g;
741+
const tokens = content.split(markdownLinkPattern);
742+
743+
const nodes = [];
744+
for (let i = 0; i < tokens.length; i++) {
745+
const mod = i % 3;
746+
if (mod === 0) {
747+
if (tokens[i]) {
748+
nodes.push(<span key={`seg-${i}`}>{renderPlainSegment(tokens[i], `seg-${i}`)}</span>);
749+
}
750+
} else if (mod === 1) {
751+
const label = tokens[i];
752+
const url = tokens[i + 1] || '';
753+
nodes.push(renderLink(`md-${i}`, url, label));
754+
}
755+
// mod === 2 holds the URL already consumed alongside its label above.
756+
}
757+
758+
return <span className="text-base">{nodes}</span>;
764759
};
765760

766761
const renderContent = (body) => {
@@ -779,11 +774,10 @@ sei-tendermint
779774
for (let j = 1; j < lines.length; j++) {
780775
const line = lines[j].trim();
781776
if (line.startsWith('*') || line.startsWith('-') || line.startsWith('•')) {
782-
let cleanLine = line
777+
const cleanLine = line
783778
.replace(/^\*\s*/, '')
784779
.replace(/^-\s*/, '')
785780
.replace(/^•\s*/, '');
786-
cleanLine = cleanLine.replace(/\[([^\]]+)\]\([^)^]+\)/g, '$1');
787781
bulletPoints.push(cleanLine);
788782
}
789783
}
@@ -837,11 +831,10 @@ sei-tendermint
837831
for (const line of compContent.split('\n')) {
838832
const trimmedLine = line.trim();
839833
if (trimmedLine.startsWith('*') || trimmedLine.startsWith('-') || trimmedLine.startsWith('•')) {
840-
let cleanLine = trimmedLine
834+
const cleanLine = trimmedLine
841835
.replace(/^\*\s*/, '')
842836
.replace(/^-\s*/, '')
843837
.replace(/^•\s*/, '');
844-
cleanLine = cleanLine.replace(/\[([^\]]+)\]\([^)^]+\)/g, '$1');
845838
bulletPoints.push(cleanLine);
846839
} else if (trimmedLine && !trimmedLine.match(/^[a-zA-Z][a-zA-Z0-9-]*:?\s*(?:\(.*\))?\s*$/)) {
847840
bulletPoints.push(trimmedLine);

‎snippets/ecosystem-app-grid.jsx‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,26 @@ export const EcosystemAppGrid = (props) => {
9292
</svg>
9393
);
9494

95+
// Site-relative integration guides get an in-page arrow instead of the
96+
// off-site box-arrow, so on-docs links don't masquerade as external ones.
97+
const ArrowRightIcon = ({ size = 14, style }) => (
98+
<svg
99+
xmlns='http://www.w3.org/2000/svg'
100+
width={size}
101+
height={size}
102+
viewBox='0 0 24 24'
103+
fill='none'
104+
stroke='currentColor'
105+
strokeWidth='2'
106+
strokeLinecap='round'
107+
strokeLinejoin='round'
108+
style={style}
109+
aria-hidden='true'>
110+
<path d='M5 12h14' />
111+
<path d='M12 5l7 7-7 7' />
112+
</svg>
113+
);
114+
95115
// --- Skeleton card (ported from AppCardSkeleton.tsx) ---
96116
const AppCardSkeleton = () => (
97117
<div className='animate-pulse border backdrop-blur-sm bg-neutral-50/80 dark:bg-neutral-900/80 border-neutral-200/50 dark:border-neutral-800/50 p-5 h-full flex flex-col'>
@@ -206,10 +226,17 @@ export const EcosystemAppGrid = (props) => {
206226
style={{ color: linkColor, transition: 'color 0.2s' }}
207227
className='inline-flex items-center gap-1.5 text-sm font-medium no-underline'>
208228
<span>{linkText}</span>
209-
<ExternalLinkIcon
210-
size={14}
211-
style={{ marginLeft: linkHover ? '0.25rem' : 0, transform: linkHover ? 'scale(1.1)' : 'none', transition: 'all 0.3s' }}
212-
/>
229+
{isExternalLink ? (
230+
<ExternalLinkIcon
231+
size={14}
232+
style={{ marginLeft: linkHover ? '0.25rem' : 0, transform: linkHover ? 'scale(1.1)' : 'none', transition: 'all 0.3s' }}
233+
/>
234+
) : (
235+
<ArrowRightIcon
236+
size={14}
237+
style={{ transform: linkHover ? 'translateX(0.25rem)' : 'none', transition: 'transform 0.3s' }}
238+
/>
239+
)}
213240
</a>
214241
</div>
215242
)}

0 commit comments

Comments
 (0)