diff --git a/@theme/components/Amendments.tsx b/@theme/components/Amendments.tsx new file mode 100644 index 0000000000..b7a3fc914d --- /dev/null +++ b/@theme/components/Amendments.tsx @@ -0,0 +1,403 @@ +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 = 15 * 60 * 1000 // 15 minutes 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 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) + }) +} + +// 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..ea43f122bb 100644 --- a/@theme/markdoc/components.tsx +++ b/@theme/markdoc/components.tsx @@ -1,39 +1,43 @@ -import * as React from 'react'; -import { useLocation } from 'react-router-dom'; +import * as React from 'react' +import { useLocation } from 'react-router-dom' // @ts-ignore -import dynamicReact from '@markdoc/markdoc/dist/react'; -import { Link } from '@redocly/theme/components/Link/Link'; +import dynamicReact from '@markdoc/markdoc/dist/react' +import { Link } from '@redocly/theme/components/Link/Link' 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'; +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 { AmendmentsTable, AmendmentDisclaimer, Badge } from '../components/Amendments' export function IndexPageItems() { - const { usePageSharedData } = useThemeHooks(); - const data = usePageSharedData('index-page-items') as any[]; - return ( -
-
    - {data?.map((item: any) => ( -
  • - {item.title} - { - item.status === "not_enabled" ? () : "" - } -

    {item.seo?.description}

    -
  • - ))} -
-
- ); + const { usePageSharedData } = useThemeHooks() + const data = usePageSharedData('index-page-items') as any[] + return ( +
+
    + {data?.map((item: any) => ( +
  • + {item.title} + { + item.status === "not_enabled" ? () : "" + } +

    {item.seo?.description}

    +
  • + ))} +
+
+ ) } -export function InteractiveBlock(props: { children: React.ReactNode; label: string; steps: string[] }) { - const stepId = idify(props.label); - const { pathname } = useLocation(); +export function InteractiveBlock(props: { + children: React.ReactNode + label: string + steps: string[] +}) { + const stepId = idify(props.label) + const { pathname } = useLocation() return ( // add key={pathname} to ensure old step state gets rerendered on page navigation @@ -47,109 +51,47 @@ export function InteractiveBlock(props: { children: React.ReactNode; label: stri data-stepid={stepId} > {props.steps?.map((step, idx) => { - const iterStepId = idify(step).toLowerCase(); - let className = `breadcrumb-item bc-${iterStepId}`; - if (idx > 0) className += ' disabled'; - if (iterStepId === stepId) className += ' current'; + const iterStepId = idify(step).toLowerCase() + let className = `breadcrumb-item bc-${iterStepId}` + if (idx > 0) className += ' disabled' + if (iterStepId === stepId) className += ' current' return (
  • {step}
  • - ); + ) })} -
    {dynamicReact(props.children, React, {})}
    - ); + ) } export function RepoLink(props: { - children: React.ReactNode; - path: string; - github_fork: string; - github_branch: string - }) { - const treeblob = props.path.indexOf(".") >= 0 ? "blob/" : "tree/" - const sep = props.github_fork[-1] == "/" ? "" : "/" - const href = props.github_fork+sep+treeblob+props.github_branch+"/"+props.path + children: React.ReactNode + path: string + github_fork: string + github_branch: string +}) { + const treeblob = props.path.indexOf(".") >= 0 ? "blob/" : "tree/" + const sep = props.github_fork[-1] == "/" ? "" : "/" + const href = props.github_fork+sep+treeblob+props.github_branch+"/"+props.path - return ( - {dynamicReact(props.children, React, {})} - ) + return ( + {dynamicReact(props.children, React, {})} + ) } export function CodePageName(props: { - name: string; + name: string }) { return ( {props.name} ) } -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: { @@ -158,7 +100,7 @@ export function TryIt(props: { }) { const { useTranslate } = useThemeHooks() const { translate } = useTranslate() - let use_server = ""; + let use_server = "" if (props.server == "s1") { use_server = "?server=wss%3A%2F%2Fs1.ripple.com%2F" } else if (props.server == "s2") { @@ -186,7 +128,7 @@ export function TxExample(props: { }) { const { useTranslate } = useThemeHooks() const { translate } = useTranslate() - let use_server = ""; + let use_server = "" if (props.server == "s1") { use_server = "&server=wss%3A%2F%2Fs1.ripple.com%2F" } else if (props.server == "s2") { @@ -206,292 +148,10 @@ 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(); + const { useTranslate } = useThemeHooks() + const { translate } = useTranslate() return ( ) } - -// 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", ".")} - {" "} - - - ) - } - )

    - ) -}