diff --git a/@theme/components/Amendments.tsx b/@theme/components/Amendments.tsx new file mode 100644 index 0000000000..50ac61bfd3 --- /dev/null +++ b/@theme/components/Amendments.tsx @@ -0,0 +1,391 @@ +import * as React from 'react'; +import { Link } from '@redocly/theme/components/Link/Link'; +import { useThemeHooks } from '@redocly/theme/core/hooks'; + +type Amendment = { + name: string; + rippled_version: string; + tx_hash?: string; + consensus?: string; + date?: string; + id: string; + eta?: string; +}; + +type AmendmentsResponse = { + amendments: Amendment[]; +}; + +type AmendmentsCachePayload = { + timestamp: number; + amendments: Amendment[]; +} + +// API data caching +const amendmentsEndpoint = 'https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/'; +const amendmentsCacheKey = 'xrpl.amendments.mainnet.cache'; +const amendmentsTTL = 12 * 60 * 60 * 1000; // 12 hours in milliseconds + +function readAmendmentsCache(): Amendment[] | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(amendmentsCacheKey); + if (!raw) return null; + const parsed: AmendmentsCachePayload = JSON.parse(raw); + if (!parsed || !Array.isArray(parsed.amendments)) return null; + const fresh = Date.now() - parsed.timestamp < amendmentsTTL; + return fresh ? parsed.amendments : null; + } catch { + return null; + } +} + +function writeAmendmentsCache(amendments: Amendment[]) { + if (typeof window === 'undefined') return; + try { + const payload: AmendmentsCachePayload = { timestamp: Date.now(), amendments }; + window.localStorage.setItem(amendmentsCacheKey, JSON.stringify(payload)); + } catch { + // Ignore quota or serialization errors + } +} + +// Sort amendments table +function sortAmendments(list: Amendment[]): Amendment[] { + const getStatusPriority = (amendment: Amendment): number => { + if (amendment.eta) return 0; // Expected + if (amendment.consensus) return 1; // Open for Voting + if (amendment.tx_hash) return 2; // Enabled + return 3; // Fallback + }; + return [...list].sort((a, b) => { + const priorityA = getStatusPriority(a); + const priorityB = getStatusPriority(b); + if (priorityA !== priorityB) return priorityA - priorityB; + if (a.rippled_version !== b.rippled_version) { + return b.rippled_version.localeCompare(a.rippled_version, undefined, { numeric: true }); + } + return a.name.localeCompare(b.name); + }); +} + +// 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(); + + React.useEffect(() => { + const fetchAmendments = async () => { + try { + setLoading(true); + // 1. Try cache first + const cached = readAmendmentsCache(); + + if (cached) { + setAmendments(sortAmendments(cached)); + return; // Use current cache (fresh) + } + // 2. Fetch new data if cache is stale + const response = await fetch(amendmentsEndpoint); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: AmendmentsResponse = await response.json(); + writeAmendmentsCache(data.amendments); + + setAmendments(sortAmendments(data.amendments)); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch amendments'); + } finally { + setLoading(false); + } + }; + + fetchAmendments(); + }, []); + + // 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} + +
+
+ ); +} + +function AmendmentBadge(props: { amendment: Amendment }) { + 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"); + + 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}`); + } + // Check if expected activation is provided (has eta field) + else if (amendment.eta) { + let etaDate = new Date(amendment.eta).toISOString().split('T')[0]; + setStatus(`${etaLabel}: ${etaDate}`); + setColor('blue'); + setHref(undefined); + } + // Check if amendment is currently being voted on (has consensus field) + else if (amendment.consensus) { + setStatus(`${votingLabel}: ${amendment.consensus}`); + setColor('80d0e0'); + setHref(undefined); // No link for voting amendments + } + }, [props.amendment, enabledLabel, etaLabel, votingLabel]); + + // 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 +}) { + 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(() => { + const loadAmendment = async () => { + try { + setLoading(true); + // 1. Try cache first + const cached = readAmendmentsCache(); + + if (cached) { + const found = cached.find(a => a.name === props.name); + if (found) { + setStatus(found); + return; // amendment successfully found in cache + } + } + // 2. New API request for stale/missing cache. + // Also catches edge case of new amendment appearing + // on mainnet within cache TTL window. + const response = await fetch(amendmentsEndpoint); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data: AmendmentsResponse = await response.json(); + writeAmendmentsCache(data.amendments); + + const found = data.amendments.find(a => a.name === props.name); + if (!found) { + throw new Error(`Couldn't find ${props.name} amendment in status table.`); + } + + setStatus(found); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch amendments'); + } finally { + setLoading(false); + } + }; + loadAmendment(); + }, [props.name]); + + if (loading) { + return ( +

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

+ ) + } + + if (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.compact) { + return ( + <> + {link()} + {" "} + + + ) + } + + 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", ".")} + {" "} + + + ) + } + )

+ ) +} + +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 + } + + 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} + ) + } +} diff --git a/@theme/markdoc/components.tsx b/@theme/markdoc/components.tsx index fcd46f4883..c9fcf93085 100644 --- a/@theme/markdoc/components.tsx +++ b/@theme/markdoc/components.tsx @@ -3,13 +3,13 @@ import { useLocation } from 'react-router-dom'; // @ts-ignore import dynamicReact from '@markdoc/markdoc/dist/react'; import { Link } from '@redocly/theme/components/Link/Link'; -import { useThemeHooks } from '@redocly/theme/core/hooks' +import { useThemeHooks } from '@redocly/theme/core/hooks'; import { idify } from '../helpers'; import { Button } from '@redocly/theme/components/Button/Button'; export {default as XRPLoader} from '../components/XRPLoader'; export { XRPLCard, CardGrid } from '../components/XRPLCard'; - +export * from '../components/Amendments'; export function IndexPageItems() { const { usePageSharedData } = useThemeHooks(); @@ -89,67 +89,6 @@ export function CodePageName(props: { ) } -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 - } - - 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} - ) - } -} - type TryItServer = 's1' | 's2' | 'xrplcluster' | 'testnet' | 'devnet' | 'testnet-clio' | 'devnet-clio' export function TryIt(props: { @@ -206,13 +145,6 @@ export function TxExample(props: { ) } -function shieldsIoEscape(s: string) { - return s.trim() - .replace(/-/g, '--') - .replace(/_/g, '__') - .replace(/%/g, '%25') -} - export function NotEnabled() { const { useTranslate } = useThemeHooks(); const { translate } = useTranslate(); @@ -220,278 +152,3 @@ export function NotEnabled() { ) } - -// Type definitions for amendments -type Amendment = { - name: string; - rippled_version: string; - tx_hash?: string; - consensus?: string; - date?: string; - id: string; - eta?: string; -}; - -type AmendmentsResponse = { - amendments: Amendment[]; -}; - -// 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(); - - React.useEffect(() => { - const fetchAmendments = async () => { - try { - setLoading(true); - const response = await fetch(`https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data: AmendmentsResponse = await response.json(); - - // Sort amendments table - const sortedAmendments = data.amendments - .sort((a: Amendment, b: Amendment) => { - // Sort by status priority (lower number = higher priority) - const getStatusPriority = (amendment: Amendment): number => { - if (amendment.eta) return 0; // Expected - if (amendment.consensus) return 1; // Open for Voting - if (amendment.tx_hash) return 2; // Enabled - }; - - const priorityA = getStatusPriority(a); - const priorityB = getStatusPriority(b); - - if (priorityA !== priorityB) { - return priorityA - priorityB; - } - - // Sort by rippled_version (descending) - if (a.rippled_version !== b.rippled_version) { - return b.rippled_version.localeCompare(a.rippled_version, undefined, { numeric: true }); - } - - // Finally sort by name (ascending) - return a.name.localeCompare(b.name); - }); - - setAmendments(sortedAmendments); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch amendments'); - } finally { - setLoading(false); - } - }; - - fetchAmendments(); - }, []); - - // 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} - -
-
- ); -} - -function AmendmentBadge(props: { amendment: Amendment }) { - 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"); - - 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}`); - } - // Check if expected activation is provided (has eta field) - else if (amendment.eta) { - let etaDate = new Date(amendment.eta).toISOString().split('T')[0]; - setStatus(`${etaLabel}: ${etaDate}`); - setColor('blue'); - setHref(undefined); - } - // Check if amendment is currently being voted on (has consensus field) - else if (amendment.consensus) { - setStatus(`${votingLabel}: ${amendment.consensus}`); - setColor('80d0e0'); - setHref(undefined); // No link for voting amendments - } - }, [props.amendment, enabledLabel, etaLabel, votingLabel]); - - // 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 -}) { - 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(() => { - const fetchAmendments = async () => { - try { - setLoading(true); - const response = await fetch(`https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data: AmendmentsResponse = await response.json() - console.log("data.amendments is:", data.amendments) - - let found_amendment = false - for (const amendment of data.amendments) { - if (amendment.name == props.name) { - setStatus(amendment) - found_amendment = true - break - } - } - if (!found_amendment) { - throw new Error(`Couldn't find ${props.name} amendment in status table.`) - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to fetch amendments'); - } finally { - setLoading(false) - } - } - fetchAmendments() - }, []) - - if (loading) { - return ( -

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

- ) - } - - if (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.compact) { - return ( - <> - {link()} - {" "} - - - ) - } - - 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", ".")} - {" "} - - - ) - } - )

- ) -}