// Components related to XRPL Amendment previews and statuses import * as React from 'react' import type { Node } from '@markdoc/markdoc' import { Link } from '@redocly/theme/components/Link/Link' import { useThemeHooks } from '@redocly/theme/core/hooks' import amendmentsSnapshot from '../data/amendments-snapshot.json' import obsoleteAmendments from '../data/obsolete-amendments.json' type Amendment = { name: string rippled_version: string tx_hash?: string consensus?: string date?: string id: string eta?: string deprecated: boolean } type ObsoleteAmendment = { id: string, name: string, rippled_version: string, status: string href?: string } type AmendmentsResponse = { amendments: Amendment[] } type AmendmentsCache = { timestamp: number vote: Amendment[] info: Amendment[] } // API data caching const amendmentsVoteEndpoint = 'https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/' const amendmentsInfoEndpoint = 'https://vhs.prod.ripplex.io/v1/network/amendments/info/main/' const amendmentsCacheKey = 'xrpl.amendments.mainnet.cache' const amendmentsTTL = 15 * 60 * 1000 // 15 minutes in milliseconds function readCache(): AmendmentsCache | null { if (typeof window === 'undefined') return null try { const raw = window.localStorage.getItem(amendmentsCacheKey) if (!raw) return null const parsed: AmendmentsCache = JSON.parse(raw) if (!parsed || !Array.isArray(parsed.vote) || !Array.isArray(parsed.info)) return null const fresh = Date.now() - parsed.timestamp < amendmentsTTL return fresh ? parsed : null } catch { return null } } function writeCache(cache: AmendmentsCache) { if (typeof window === 'undefined') return try { window.localStorage.setItem(amendmentsCacheKey, JSON.stringify(cache)) } catch { // Ignore quota or serialization errors } } // Fetch a single amendments endpoint (vote or info). async function fetchAmendmentList(endpoint: string): Promise { const response = await fetch(endpoint) if (!response.ok) { throw new Error(`HTTP error ${response.status} from ${endpoint}`) } const data: AmendmentsResponse = await response.json() return data.amendments } // Deduplicated fetching for the amendment data; concurrent callers share the same promise. let inFlight: Promise | null = null // Cache-first: return the fresh combined cache, or fetch both endpoints in parallel, // stamp them with a single timestamp, and store them under one cache key. function getAmendmentsCache(): Promise { const cached = readCache() if (cached) return Promise.resolve(cached) if (inFlight) return inFlight const request = (async () => { const [vote, info] = await Promise.all([ fetchAmendmentList(amendmentsVoteEndpoint), fetchAmendmentList(amendmentsInfoEndpoint), ]) const cache: AmendmentsCache = { timestamp: Date.now(), vote, info } writeCache(cache) return cache })() inFlight = request // Release the slot once settled so a later cold load can refresh. const release = () => { if (inFlight === request) inFlight = null } request.then(release, release) return request } // Vote list for the amendments table (the caller filters out obsolete amendments). function getAmendments(): Promise { return getAmendmentsCache().then(cache => cache.vote) } // Look up a single amendment for a disclaimer: the vote list first, then the info // list, then the hard-coded list of obsolete amendments. async function findAmendment(name: string): Promise { const obsolete = isObsolete(name) if (obsolete) return obsolete const { vote, info } = await getAmendmentsCache() const found = vote.find(a => a.name === name) ?? info.find(a => a.name === name) if (found) return found throw new Error(`Couldn't find ${name} amendment in status tables.`) } // Amendments ultimately become either obsolete (removed & never enabled) // or retired (enabled & unconditionalized). A "deprecated" status in the // VHS data can refer to either one, depending on if it has a tx_hash indicating // that it was enabled on Mainnet. // But since VHS data does not include everything, we rely on a manually // maintained list of obsolete amendments with status info. function isObsolete(amendment_name: string): ObsoleteAmendment|undefined { return obsoleteAmendments.find( (a: ObsoleteAmendment) => a.name == amendment_name ) } // Sort amendments table by status, then chronologically, then alphabetically function sortAmendments(list: Amendment[]): Amendment[] { const getStatusPriority = (amendment: Amendment): number => { if (amendment.consensus) return 0 // Open for Voting if (amendment.eta) return 1 // Expected if (amendment.tx_hash) return 2 // Enabled return 3 // Fallback } const getChronoKey = (amendment: Amendment): number => { const raw = amendment.date || amendment.eta if (!raw) return 0 const t = Date.parse(raw) return isNaN(t) ? 0 : t } return [...list].sort((a, b) => { // 1. Status const statusDiff = getStatusPriority(a) - getStatusPriority(b) if (statusDiff !== 0) return statusDiff // 2. Chronological (most recent first) const chronoA = getChronoKey(a) const chronoB = getChronoKey(b) if (chronoA !== chronoB) return chronoB - chronoA // 3. Alphabetical return a.name.localeCompare(b.name) }) } // Get amendment status from snapshot data. function amendmentStatusForLlms(a: Amendment|ObsoleteAmendment): string { const obsolete = isObsolete(a.name) if (a.tx_hash) { const date = a.date ? new Date(a.date).toISOString().split('T')[0] : '' return date ? `Enabled: ${date}` : 'Enabled' } if (obsolete) { // Note: update to use translate() when Redocly supports it in renderForLlms // const obsLabel = translate("amendment.status.obsolete", "Obsolete") const obsLabel = "Obsolete" return `${obsLabel}: ${obsolete.status}` } if (a.eta) return `Expected: ${new Date(a.eta).toISOString().split('T')[0]}` if (a.consensus) return `Open for Voting: ${a.consensus}` return 'Error' } // Build-time Markdown rendering of the amendments table for LLM export. export function amendmentsTableForLlms(): string { const { fetched_at, amendments } = amendmentsSnapshot as unknown as { fetched_at?: string amendments?: Amendment[] } const asOf = fetched_at || 'unknown' if (!Array.isArray(amendments) || amendments.length === 0) { return `Note: Amendment status table is missing or malformed. For live status, visit https://xrpl.org/resources/known-amendments in a browser.\n` } const rows = sortAmendments(amendments.filter(a => !isObsolete(a.name))) const lines = [ `Note: The amendment status table is a snapshot from ${asOf}. For live status, visit https://xrpl.org/resources/known-amendments in a browser.`, '', '| Name | Introduced | Status |', '|:-----|:-----------|:-------|', ...rows.map( a => `| [${a.name}](#${a.name.toLowerCase()}) | ${a.rippled_version} | ${amendmentStatusForLlms(a)} |`, ), '', ] return lines.join('\n') } // Calculate expected height of amendment table so there's little to no // page-height jump when the actual table loads in. This ensures that anchor // links go to the correct scroll position even before the amendment statuses // have loaded. function expectedTableHeight() { const numAmendmentsCached = amendmentsSnapshot.amendments.filter( a => !isObsolete(a.name)).length const rowHeight = 39 // pixel height per row, including border, based on CSS const headerHeight = 38 // pixel height of table header const wrapperMargin = 41 // extra margin added by the md-table-wrapper element return (numAmendmentsCached * rowHeight) + headerHeight + wrapperMargin } // Generate amendments table with live mainnet data export function AmendmentsTable() { const [amendments, setAmendments] = React.useState([]) const [loading, setLoading] = React.useState(true) const [error, setError] = React.useState(null) const { useTranslate } = useThemeHooks() const { translate } = useTranslate() const estimatedHeight = `${expectedTableHeight()}px` React.useEffect(() => { let cancelled = false getAmendments() .then(list => { if (!cancelled) setAmendments(sortAmendments(list.filter(a => !isObsolete(a.name)))) }) .catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to fetch amendments') }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, []) // Fancy schmancy loading icon if (loading) { return (
{translate("amendment.loading", "Loading amendments...")}
{translate("amendment.loading", "Loading amendments...")}
) } if (error) { return (
{translate("amendment.error", "Error loading amendments:")}: {error}
) } // Render amendment table return (
{amendments.map((amendment) => ( ))}
{translate("amendment.table.name", "Name")} {translate("amendment.table.introduced", "Introduced")} {translate("amendment.table.status", "Status")}
{amendment.name} {amendment.rippled_version}
) } export function ObsoleteAmendmentsTable() { const { useTranslate } = useThemeHooks() const { translate } = useTranslate() return (
{obsoleteAmendments.map((amendment) => ( ))}
{translate("amendment.table.name", "Name")} {translate("amendment.table.introduced", "Introduced")} {translate("amendment.table.status", "Status")}
{amendment.name} {amendment.rippled_version}
) } export function obsoleteAmendmentsTableForLlms() { const lines = [ '| Name | Introduced | Status |', '|:-----|:-----------|:-------|', ...obsoleteAmendments.map( a => `| [${a.name}](#${a.name.toLowerCase()}) | ${a.rippled_version} | ${amendmentStatusForLlms(a)} |`, ), '', ] return lines.join('\n') } function AmendmentBadge(props: { amendment: Amendment|ObsoleteAmendment}) { const [status, setStatus] = React.useState('Loading...') const [href, setHref] = React.useState(undefined) const [color, setColor] = React.useState('blue') const { useTranslate } = useThemeHooks() const { translate } = useTranslate() const enabledLabel = translate("amendment.status.enabled", "Enabled") const votingLabel = translate("amendment.status.openForVoting", "Open for Voting") const etaLabel = translate("amendment.status.eta", "Expected") const unknownLabel = translate("amendment.status.unknown", "Unknown") const unknownButton = translate("amendment.status.unknownButton", "Get details") const obsLabel = translate("amendment.status.obsolete", "Obsolete") React.useEffect(() => { const amendment = props.amendment // Check if amendment is enabled (has tx_hash) if (amendment.tx_hash) { const enabledDate = new Date(amendment.date).toISOString().split('T')[0] setStatus(`${enabledLabel}: ${enabledDate}`) setColor('green') setHref(`https://livenet.xrpl.org/transactions/${amendment.tx_hash}`) } else if (isObsolete(amendment.name)) { const obsAmendment = isObsolete(amendment.name) setStatus(`${obsLabel}: ${obsAmendment.status}`) setColor('red') setHref(obsAmendment.href) } else if (amendment.eta) { // Expected activation is provided (has eta field) let etaDate = new Date(amendment.eta).toISOString().split('T')[0] setStatus(`${etaLabel}: ${etaDate}`) setColor('blue') setHref(undefined) } else if (amendment.consensus) { // Amendment is currently being voted on (has consensus field) setStatus(`${votingLabel}: ${amendment.consensus}`) setColor('80d0e0') setHref(undefined) } else { // Fallback: status is unknown setStatus(`${unknownLabel}: ${unknownButton}`) setColor('lightgrey') setHref(`/resources/known-amendments#${amendment.name.toLowerCase()}`) } }, [props.amendment, enabledLabel, obsLabel, etaLabel, votingLabel, unknownLabel]) // Split the status at the colon to create two-color badge const parts = status.split(':') const label = shieldsIoEscape(parts[0]) const message = shieldsIoEscape(parts.slice(1).join(':')) const badgeUrl = `https://img.shields.io/badge/${label}-${message}-${color}` if (href) { return ( {status} ) } return {status} } export function AmendmentDisclaimer(props: { name: string, compact: boolean, statusOnly: boolean, mode: string }) { const [amendmentStatus, setStatus] = React.useState(null) const [loading, setLoading] = React.useState(true) const [error, setError] = React.useState(null) const { useTranslate } = useThemeHooks() const { translate } = useTranslate() const link = () => {props.name}{ props.compact ? "" : " amendment"} React.useEffect(() => { let cancelled = false setLoading(true) setError(null) findAmendment(props.name) .then(found => { if (!cancelled) setStatus(found) }) .catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to fetch amendments') }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [props.name]) if (loading) { if (props.statusOnly) { return <> {translate("amendment.loading_status", "Loading...")} } return (

{translate("component.amendment-status.requires.1", "Requires the ")}{link()}{translate("component.amendment-status.requires.2", ".")} {" "} {translate("amendment.loading_status", "Loading...")}

) } if (error) { if (props.statusOnly) { return <> {translate("amendment.error_status", "Error loading amendment status")}: {error} } return (

{translate("component.amendment-status.requires.1", "Requires the ")}{link()}{translate("component.amendment-status.requires.2", ".")} {" "} {translate("amendment.error_status", "Error loading amendment status")}: {error}

) } if (props.statusOnly) { return( ) } if (props.compact) { return ( <> {link()} {" "} ) } if (props.mode === "updated") { return (

( { amendmentStatus.date ? ( <> {translate("component.amendment-status.updated.1", "Updated by the ")}{link()} {translate("component.amendment-status.updated.2", ".")} {" "} ) : ( <> {translate("component.amendment-status.updates.1", "The ")}{link()} {translate("component.amendment-status.updates.2", " updates this.")} {" "} ) } )

) } return (

( { amendmentStatus.date ? ( <> {translate("component.amendment-status.added.1", "Added by the ")}{link()} {translate("component.amendment-status.added.2", ".")} {" "} ) : ( <> {translate("component.amendment-status.requires.1", "Requires the ")}{link()} {translate("component.amendment-status.requires.2", ".")} {" "} ) } )

) } export function amendmentDisclaimerForLlms(node: Node): string { const { name, compact, statusOnly, mode } = node.attributes // Note: useThemeHooks() does not work here because it's not a React // function component body. However, we would like to use translation. // For now, shim translate with a placeholder function that does nothing // but matches the syntax of the real translate function, in preparation for // when we can actually use it. // const { useTranslate } = useThemeHooks() // const { translate } = useTranslate() const translate = (key:string, s:string) => s; // Shim version of translate let amendment : Amendment | ObsoleteAmendment | any = ( isObsolete(name) || amendmentsSnapshot.amendments.find( (a) => a.name === name ) ) let status = "Status Unknown" if (!amendment) { status = translate("amendment.status.inactive", "Inactive") amendment = { name: name } } else { status = amendmentStatusForLlms(amendment) } if (statusOnly) { return status } const link = `/resources/known-amendments#${name.toLowerCase()}` if (compact) { return `[${name}](${link}) (${status})` } let text1, text2 if (mode === "updated") { if (amendment.date) { text1 = translate("component.amendment-status.updated.1", "Updated by the ") text2 = translate("component.amendment-status.updated.2", ".") } else { text1 = translate("component.amendment-status.updates.1", "The ") text2 = translate("component.amendment-status.updates.2", " updates this.") } } else { if (amendment.date) { text1 = translate("component.amendment-status.added.1", "Added by the ") text2 = translate("component.amendment-status.added.2", ".") } else { text1 = translate("component.amendment-status.requires.1", "Requires the ") text2 = translate("component.amendment-status.requires.2", ".") } } return `_${text1}[${name} amendment](${link})${text2} (${status})_` } function shieldsIoEscape(s: string) { return s.trim() .replace(/-/g, '--') .replace(/_/g, '__') .replace(/%/g, '%25') } export function Badge(props: { children: React.ReactNode color: string href: string }) { const DEFAULT_COLORS = { "open for voting": "80d0e0", "投票中": "80d0e0", // ja: open for voting "expected": "blue", "予定": "blue", // ja: expected "enabled": "green", "有効": "green", // ja: enabled "obsolete": "red", "removed in": "red", "削除": "red", // ja: removed in "廃止": "red", // ja: obsolete "撤回": "red", // ja: withdrawn/removed/vetoed "new in": "blue", "新規": "blue", // ja: new in "updated in": "blue", "更新": "blue", // ja: updated in "in development": "lightgrey", "開発中": "lightgrey", // ja: in development "inactive": "lightgrey", "無効": "lightgrey" // ja: inactive } let childstrings = "" React.Children.forEach(props.children, (child, index) => { if (typeof child == "string") { childstrings += child } }) const parts = childstrings.split(":") const left : string = shieldsIoEscape(parts[0]) const right : string = shieldsIoEscape(parts.slice(1).join(":")) let color = props.color if (!color) { if (DEFAULT_COLORS.hasOwnProperty(left.toLowerCase())) { color = DEFAULT_COLORS[left.toLowerCase()] } else { color = "lightgrey" } } let badge_url = `https://img.shields.io/badge/${left}-${right}-${color}.svg` if (props.href) { return ( {childstrings} ) } else { return ( {childstrings} ) } }