mirror of
https://github.com/XRPLF/xrpl-dev-portal.git
synced 2026-04-21 11:12:31 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b72e6c6ff |
@@ -1,19 +0,0 @@
|
||||
# XRPL Dev Portal — Claude Code Instructions
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- **Framework:** Redocly Realm
|
||||
- **Production branch:** `master`
|
||||
- **Local preview:** `npm start`
|
||||
|
||||
## Localization
|
||||
|
||||
- Default: `en-US`
|
||||
- Japanese: `ja`
|
||||
- Translations mirror `docs/` structure under `@l10n/<language-code>/`
|
||||
|
||||
## Navigation
|
||||
|
||||
- Update `sidebars.yaml` when adding new doc pages
|
||||
- Blog posts have a separate `blog/sidebars.yaml`
|
||||
- Redirects go in `redirects.yaml`
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(git push *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
---
|
||||
name: generate-release-notes
|
||||
description: Generate and sort rippled release notes from GitHub commit history
|
||||
argument-hint: --from <ref> --to <ref> [--date YYYY-MM-DD] [--output <path>]
|
||||
allowed-tools: Bash, Read, Edit, Write, Grep, Glob
|
||||
effort: max
|
||||
---
|
||||
|
||||
# Generate rippled Release Notes
|
||||
|
||||
This skill generates a draft release notes blog post for a new rippled version, then sorts the entries into the correct subsections.
|
||||
|
||||
## Execution constraints
|
||||
|
||||
- **Do NOT write scripts** to sort or process the file. Prefer the Edit tool for targeted changes. Use Write only when replacing large sections that are impractical to edit incrementally.
|
||||
- **Output progress**: Before each major step (generating raw release notes, reviewing file, processing amendments, sorting entries, reformatting, cleanup), output a brief status message so the user can see progress.
|
||||
|
||||
## Step 1: Generate the raw release notes
|
||||
|
||||
Run the Python script from the repo root. Pass through all arguments from `$ARGUMENTS`:
|
||||
|
||||
```bash
|
||||
python3 tools/generate-release-notes.py $ARGUMENTS
|
||||
```
|
||||
|
||||
If the user didn't provide `--from` or `--to`, ask them for the base and target refs (tags or branches).
|
||||
|
||||
The script will:
|
||||
- Fetch the version string from `BuildInfo.cpp`
|
||||
- Fetch all commits between the two refs
|
||||
- Fetch PR details (title, link, labels, files, description) via GraphQL
|
||||
- Compare `features.macro` between refs to identify amendment changes
|
||||
- Auto-sort amendment entries into the Amendments section
|
||||
- Output all other entries as unsorted with full context
|
||||
|
||||
## Step 2: Review the generated file
|
||||
|
||||
Read the output file (path shown in script output). Note the **Full Changelog** structure:
|
||||
- **Amendments section**: Contains auto-sorted entries and an HTML comment listing which amendments to include or remove
|
||||
- **Empty subsections**: Features, Breaking Changes, Bug Fixes, Refactors, Documentation, Testing, CI/Build
|
||||
- **Unsorted entries**: After the **Bug Bounties and Responsible Disclosures** section is an unsorted list of entries with title, link, labels, files, and description for context
|
||||
|
||||
## Step 3: Process amendments
|
||||
|
||||
Handle Amendments first, before sorting other entries.
|
||||
|
||||
**3a. Process the auto-sorted Amendments subsection:**
|
||||
The HTML comment contains three lists — follow them exactly:
|
||||
- **Include**: Keep these entries.
|
||||
- **Exclude**: Remove these entries.
|
||||
- Entries on **neither** list: Remove these entries.
|
||||
|
||||
**3b. Scan unsorted entries for unreleased amendment work:**
|
||||
Search through ALL unsorted entries for titles, labels, descriptions, or files that reference amendments on the "Exclude" or "Other amendments not part of this release" lists. Remove entries that directly implement, enable, fix, or refactor these amendments. Keep entries that are general changes that merely reference the amendment as motivation — if the code change is useful on its own regardless of whether the amendment ships, keep it.
|
||||
|
||||
**3c. If you disagree with any amendment decisions, make a note to the user but do NOT deviate from the rules.**
|
||||
|
||||
## Step 4: Sort remaining unsorted entries into subsections
|
||||
|
||||
Move each remaining unsorted entry into the appropriate subsection.
|
||||
|
||||
Use these signals to categorize:
|
||||
|
||||
**Files changed** (strongest signal):
|
||||
- Only `.github/`, `CMakeLists.txt`, `conan*`, CI config files → **CI/Build**
|
||||
- Only `src/test/`, `*_test.cpp` files → **Testing**
|
||||
- Only `*.md`, `docs/` files → **Documentation**
|
||||
|
||||
**Labels** (strong signal):
|
||||
- `Bug` label → **Bug Fixes**
|
||||
|
||||
**Title prefixes** (medium signal):
|
||||
- `fix:` → **Bug Fixes**
|
||||
- `feat:` → **Features**
|
||||
- `refactor:` → **Refactors**
|
||||
- `docs:` → **Documentation**
|
||||
- `test:` → **Testing**
|
||||
- `ci:`, `build:`, `chore:` → **CI/Build**
|
||||
|
||||
**Description content** (when other signals are ambiguous):
|
||||
- Read the PR description to understand the change's purpose
|
||||
- PRs that change API behavior, remove features, or have "Breaking change" checked in their description → **Breaking Changes**
|
||||
|
||||
Additional sorting guidance:
|
||||
- Watch for revert pairs: If a PR was committed and then reverted (or vice versa), check that the net effect is accounted for — don't include both.
|
||||
|
||||
## Step 5: Reformat sorted entries
|
||||
|
||||
After sorting, reformat each entry to match the release notes style.
|
||||
|
||||
**Amendment entries** should follow this format:
|
||||
```markdown
|
||||
- **amendmentName**: Description of what the amendment does. ([#1234](https://github.com/XRPLF/rippled/pull/1234))
|
||||
```
|
||||
- Use more detail for amendment descriptions since they are the most important. Use present tense.
|
||||
- If there are multiple entries for the same amendment, merge into one, prioritizing the entry that describes the actual amendment.
|
||||
|
||||
**Feature and Breaking Change entries** should follow this format:
|
||||
```markdown
|
||||
- Description of the change. ([#1234](https://github.com/XRPLF/rippled/pull/1234))
|
||||
```
|
||||
- Keep the description concise. Use past tense.
|
||||
|
||||
**All other entries** should follow this format:
|
||||
```markdown
|
||||
- The PR title of the entry. ([#1234](https://github.com/XRPLF/rippled/pull/1234))
|
||||
```
|
||||
- Copy the PR title as-is. Only fix capitalization, remove conventional commit prefixes (fix:, feat:, ci:, refactor:, docs:, test:, chore:, build:), and adjust to past tense if needed. Do NOT rewrite, paraphrase, or summarize.
|
||||
|
||||
## Step 6: Clean up
|
||||
|
||||
- Add a short and generic description of changes to the existing `seo.description` frontmatter, e.g., "This version introduces new amendments and bug fixes." Do not create long lists of detailed changes.
|
||||
- Add a more detailed summary of the release to the existing "Introducing XRP Ledger Version X.Y.Z" section. Include amendment names (organized in a list if more than 2), featuress, and breaking changes. Limit this to 1 paragraph.
|
||||
- Do NOT delete the **Credits** or **Bug Bounties and Responsible Disclosures** sections
|
||||
- Remove empty subsections that have no entries
|
||||
- Remove all HTML comments (sorting instructions)
|
||||
- Do a final review of the release notes. If you see anything strange, or were forced to take unintuitive actions by these instructions, notify the user, but don't make changes.
|
||||
@@ -1,15 +1,13 @@
|
||||
import { indexPages } from './plugins/index-pages.js';
|
||||
import { codeSamples } from './plugins/code-samples.js';
|
||||
import { blogPosts } from './plugins/blog-posts.js';
|
||||
import { tutorialLanguages } from './plugins/tutorial-languages.js';
|
||||
import { tutorialMetadata } from './plugins/tutorial-metadata.js';
|
||||
import { tutorialLanguages } from './plugins/tutorial-languages.js'
|
||||
|
||||
export default function customPlugin() {
|
||||
const indexPagesInst = indexPages();
|
||||
const codeSamplesInst = codeSamples();
|
||||
const blogPostsInst = blogPosts();
|
||||
const tutorialLanguagesInst = tutorialLanguages();
|
||||
const tutorialMetadataInst = tutorialMetadata();
|
||||
|
||||
|
||||
/** @type {import("@redocly/realm/dist/server/plugins/types").PluginInstance } */
|
||||
@@ -20,14 +18,12 @@ export default function customPlugin() {
|
||||
await codeSamplesInst.processContent?.(content, actions);
|
||||
await blogPostsInst.processContent?.(content, actions);
|
||||
await tutorialLanguagesInst.processContent?.(content, actions);
|
||||
await tutorialMetadataInst.processContent?.(content, actions);
|
||||
},
|
||||
afterRoutesCreated: async (content, actions) => {
|
||||
await indexPagesInst.afterRoutesCreated?.(content, actions);
|
||||
await codeSamplesInst.afterRoutesCreated?.(content, actions);
|
||||
await blogPostsInst.afterRoutesCreated?.(content, actions);
|
||||
await tutorialLanguagesInst.processContent?.(content, actions);
|
||||
await tutorialMetadataInst.processContent?.(content, actions);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
// @ts-check
|
||||
|
||||
import { getInnerText } from '@redocly/realm/dist/markdoc/helpers/get-inner-text.js'
|
||||
|
||||
/**
|
||||
* Plugin to detect languages supported in tutorial pages.
|
||||
*
|
||||
* Detection methods (in priority order):
|
||||
* 1. Tab labels in the markdown (for multi-language tutorials)
|
||||
* 2. Filename patterns like "-js.md", "-py.md" (for single-language tutorials)
|
||||
* 3. Title containing language name (for single-language tutorials)
|
||||
*
|
||||
* Plugin to detect languages supported in tutorial pages by scanning for tab labels.
|
||||
* This creates shared data that maps tutorial paths to their supported languages.
|
||||
*/
|
||||
export function tutorialLanguages() {
|
||||
@@ -29,18 +21,7 @@ export function tutorialLanguages() {
|
||||
for (const { relativePath } of tutorialFiles) {
|
||||
try {
|
||||
const { data } = await cache.load(relativePath, 'markdown-ast')
|
||||
|
||||
// Try to detect languages from tab labels first
|
||||
let languages = extractLanguagesFromAst(data.ast)
|
||||
|
||||
// Fallback: detect language from filename/title for single-language tutorials
|
||||
if (languages.length === 0) {
|
||||
const title = extractFirstHeading(data.ast) || ''
|
||||
const fallbackLang = detectLanguageFromPathAndTitle(relativePath, title)
|
||||
if (fallbackLang) {
|
||||
languages = [fallbackLang]
|
||||
}
|
||||
}
|
||||
const languages = extractLanguagesFromAst(data.ast)
|
||||
|
||||
if (languages.length > 0) {
|
||||
// Convert file path to URL path
|
||||
@@ -73,31 +54,16 @@ function extractLanguagesFromAst(ast) {
|
||||
const languages = new Set()
|
||||
|
||||
visit(ast, (node) => {
|
||||
if (!isNode(node)) return
|
||||
|
||||
// Detect languages from tab labels
|
||||
if (node.type === 'tag' && node.tag === 'tab') {
|
||||
// Look for tab nodes with a label attribute
|
||||
if (isNode(node) && node.type === 'tag' && node.tag === 'tab') {
|
||||
const label = node.attributes?.label
|
||||
if (label) {
|
||||
const normalized = normalizeLanguage(label)
|
||||
if (normalized) languages.add(normalized)
|
||||
const normalizedLang = normalizeLanguage(label)
|
||||
if (normalizedLang) {
|
||||
languages.add(normalizedLang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect languages from code-snippet language attributes
|
||||
if (node.type === 'tag' && node.tag === 'code-snippet') {
|
||||
const lang = node.attributes?.language
|
||||
if (lang) {
|
||||
const normalized = normalizeLanguage(lang)
|
||||
if (normalized) languages.add(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
// Detect languages from fenced code blocks (```js, ```python, etc.)
|
||||
if (node.type === 'fence' && node.attributes?.language) {
|
||||
const normalized = normalizeLanguage(node.attributes.language)
|
||||
if (normalized) languages.add(normalized)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(languages)
|
||||
@@ -132,70 +98,6 @@ function normalizeLanguage(label) {
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language from file path and title for single-language tutorials.
|
||||
* This is a fallback when no tab labels are found in the markdown.
|
||||
*/
|
||||
function detectLanguageFromPathAndTitle(relativePath, title) {
|
||||
const pathLower = relativePath.toLowerCase()
|
||||
const titleLower = (title || '').toLowerCase()
|
||||
|
||||
// Check filename suffixes like "-js.md", "-py.md"
|
||||
if (pathLower.endsWith('-js.md') || pathLower.includes('-javascript.md') || pathLower.includes('-in-javascript.md')) {
|
||||
return 'javascript'
|
||||
}
|
||||
if (pathLower.endsWith('-py.md') || pathLower.includes('-python.md') || pathLower.includes('-in-python.md')) {
|
||||
return 'python'
|
||||
}
|
||||
if (pathLower.endsWith('-java.md') || pathLower.includes('-in-java.md')) {
|
||||
return 'java'
|
||||
}
|
||||
if (pathLower.endsWith('-go.md') || pathLower.includes('-in-go.md') || pathLower.includes('-golang.md')) {
|
||||
return 'go'
|
||||
}
|
||||
if (pathLower.endsWith('-php.md') || pathLower.includes('-in-php.md')) {
|
||||
return 'php'
|
||||
}
|
||||
|
||||
// Check title for language indicators
|
||||
if (titleLower.includes('javascript') || titleLower.includes(' js ') || titleLower.endsWith(' js')) {
|
||||
return 'javascript'
|
||||
}
|
||||
if (titleLower.includes('python')) {
|
||||
return 'python'
|
||||
}
|
||||
if (titleLower.includes('java') && !titleLower.includes('javascript')) {
|
||||
return 'java'
|
||||
}
|
||||
if (titleLower.includes('golang') || (titleLower.includes(' go ') || titleLower.endsWith(' go') || titleLower.includes('using go'))) {
|
||||
return 'go'
|
||||
}
|
||||
if (titleLower.includes('php')) {
|
||||
return 'php'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const EXIT = Symbol('Exit visitor')
|
||||
|
||||
/**
|
||||
* Extract the first heading from the markdown AST
|
||||
*/
|
||||
function extractFirstHeading(ast) {
|
||||
let heading = null
|
||||
|
||||
visit(ast, (node) => {
|
||||
if (!isNode(node)) return
|
||||
if (node.type === 'heading') {
|
||||
heading = getInnerText([node])
|
||||
return EXIT
|
||||
}
|
||||
})
|
||||
|
||||
return heading
|
||||
}
|
||||
|
||||
function isNode(value) {
|
||||
return !!(value?.$$mdtype === 'Node')
|
||||
}
|
||||
@@ -203,16 +105,14 @@ function isNode(value) {
|
||||
function visit(node, visitor) {
|
||||
if (!node) return
|
||||
|
||||
const res = visitor(node)
|
||||
if (res === EXIT) return res
|
||||
visitor(node)
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (!child || typeof child === 'string') {
|
||||
continue
|
||||
}
|
||||
const res = visit(child, visitor)
|
||||
if (res === EXIT) return res
|
||||
visit(child, visitor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { getInnerText } from '@redocly/realm/dist/markdoc/helpers/get-inner-text.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT_ROOT = resolve(__dirname, '../..');
|
||||
|
||||
/**
|
||||
* Plugin to extract tutorial metadata including last modified dates.
|
||||
* Uses Redocly's built-in git integration for dates (same as "Last updated" display).
|
||||
* Only includes tutorials that appear in the sidebar navigation (sidebars.yaml).
|
||||
* This creates shared data for displaying "What's New" tutorials and
|
||||
* auto-generating tutorial sections on the landing page.
|
||||
*/
|
||||
export function tutorialMetadata() {
|
||||
/** @type {import("@redocly/realm/dist/server/plugins/types").PluginInstance } */
|
||||
const instance = {
|
||||
processContent: async (actions, { fs, cache }) => {
|
||||
try {
|
||||
// Extract tutorial paths and categories from sidebars.yaml.
|
||||
// Only tutorials present in the sidebar are included.
|
||||
const { pageCategory, categories } = extractSidebarData();
|
||||
|
||||
/** @type {Array<{path: string, title: string, description: string, lastModified: string, category: string}>} */
|
||||
const tutorials = [];
|
||||
const allFiles = await fs.scan();
|
||||
|
||||
// Find all markdown files in tutorials directory
|
||||
const tutorialFiles = allFiles.filter((file) =>
|
||||
file.relativePath.match(/^docs[\/\\]tutorials[\/\\].*\.md$/)
|
||||
);
|
||||
|
||||
for (const { relativePath } of tutorialFiles) {
|
||||
try {
|
||||
// Skip tutorials not present in sidebar navigation
|
||||
const category = pageCategory.get(relativePath);
|
||||
if (!category) continue;
|
||||
|
||||
const { data: { ast } } = await cache.load(relativePath, 'markdown-ast');
|
||||
const { data: { frontmatter } } = await cache.load(relativePath, 'markdown-frontmatter');
|
||||
|
||||
// Get last modified date using Redocly's built-in git integration
|
||||
const lastModified = await fs.getLastModified(relativePath);
|
||||
if (!lastModified) continue; // Skip files without dates
|
||||
|
||||
// Extract title from first heading
|
||||
const title = extractFirstHeading(ast) || '';
|
||||
if (!title) continue;
|
||||
|
||||
// Get description from frontmatter or first paragraph
|
||||
const description = frontmatter?.seo?.description || '';
|
||||
|
||||
// Convert file path to URL path
|
||||
const urlPath = '/' + relativePath
|
||||
.replace(/[\/\\]index\.md$/, '/')
|
||||
.replace(/\.md$/, '/')
|
||||
.replace(/\\/g, '/');
|
||||
|
||||
tutorials.push({
|
||||
path: urlPath,
|
||||
title,
|
||||
description,
|
||||
lastModified,
|
||||
category,
|
||||
});
|
||||
} catch (err) {
|
||||
continue; // Skip files that can't be parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by last modified date (newest first) for "What's New"
|
||||
tutorials.sort((a, b) =>
|
||||
new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime()
|
||||
);
|
||||
|
||||
// Create shared data including sidebar-derived categories
|
||||
actions.createSharedData('tutorial-metadata', { tutorials, categories });
|
||||
actions.addRouteSharedData('/docs/tutorials/', 'tutorial-metadata', 'tutorial-metadata');
|
||||
actions.addRouteSharedData('/ja/docs/tutorials/', 'tutorial-metadata', 'tutorial-metadata');
|
||||
actions.addRouteSharedData('/es-es/docs/tutorials/', 'tutorial-metadata', 'tutorial-metadata');
|
||||
} catch (e) {
|
||||
console.log('[tutorial-metadata] Error:', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
return instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the first heading from the markdown AST
|
||||
*/
|
||||
function extractFirstHeading(ast) {
|
||||
let heading = null;
|
||||
|
||||
visit(ast, (node) => {
|
||||
if (!isNode(node)) return;
|
||||
if (node.type === 'heading') {
|
||||
heading = getInnerText([node]);
|
||||
return EXIT;
|
||||
}
|
||||
});
|
||||
|
||||
return heading;
|
||||
}
|
||||
|
||||
function isNode(value) {
|
||||
return !!(value?.$$mdtype === 'Node');
|
||||
}
|
||||
|
||||
const EXIT = Symbol('Exit visitor');
|
||||
|
||||
function visit(node, visitor) {
|
||||
if (!node) return;
|
||||
|
||||
const res = visitor(node);
|
||||
if (res === EXIT) return res;
|
||||
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
if (!child || typeof child === 'string') continue;
|
||||
const res = visit(child, visitor);
|
||||
if (res === EXIT) return res;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tutorial page paths and categories from sidebars.yaml.
|
||||
*
|
||||
* Returns:
|
||||
* - pageCategory: Map of relativePath to category id (slug)
|
||||
* - categories: Array of { id, title } in sidebar display order
|
||||
*
|
||||
* Top-level groups under the tutorials section become categories.
|
||||
* Pages not inside a group (e.g. public-servers.md) are skipped.
|
||||
*/
|
||||
function extractSidebarData() {
|
||||
/** @type {Map<string, string>} */
|
||||
const pageCategory = new Map();
|
||||
/** @type {Array<{id: string, title: string}>} */
|
||||
const categories = [];
|
||||
|
||||
try {
|
||||
const content = readFileSync(resolve(PROJECT_ROOT, 'sidebars.yaml'), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let inTutorials = false;
|
||||
let entryIndent = -1; // indent of the tutorials entry itself
|
||||
let topItemIndent = -1; // indent of direct children (groups/pages)
|
||||
let currentCategory = null; // current top-level group { id, title }
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const indent = line.search(/\S/);
|
||||
|
||||
// Detect the tutorials section
|
||||
if (trimmed.includes('page: docs/tutorials/index.page.tsx')) {
|
||||
inTutorials = true;
|
||||
entryIndent = indent;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inTutorials) continue;
|
||||
|
||||
// Exit tutorials when we reach a sibling entry at the same indent
|
||||
if (indent <= entryIndent && trimmed.startsWith('- ')) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Detect the indent of top-level items (first `- ` under tutorials items)
|
||||
if (topItemIndent === -1 && trimmed.startsWith('- ')) {
|
||||
topItemIndent = indent;
|
||||
}
|
||||
|
||||
// Top-level group - start a new category
|
||||
if (indent === topItemIndent && trimmed.startsWith('- group:')) {
|
||||
const title = trimmed.replace('- group:', '').trim();
|
||||
const id = title.toLowerCase().replace(/\s+/g, '-');
|
||||
currentCategory = { id, title };
|
||||
categories.push(currentCategory);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Top-level page (no group, e.g. public-servers.md) - reset current category
|
||||
if (indent === topItemIndent && trimmed.startsWith('- page:')) {
|
||||
currentCategory = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nested page under a group - assign to current category
|
||||
if (currentCategory) {
|
||||
const pageMatch = trimmed.match(/^- page:\s+(docs\/tutorials\/\S+\.md)/);
|
||||
if (pageMatch) {
|
||||
pageCategory.set(pageMatch[1], currentCategory.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[tutorial-metadata] Warning: Could not read sidebars.yaml:', String(err));
|
||||
}
|
||||
|
||||
return { pageCategory, categories };
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
# Assign a Regular Key (Go)
|
||||
|
||||
Demonstrates how to assign a regular key pair to an XRP Ledger account. Both WebSocket (`ws/`) and JSON-RPC (`rpc/`) examples are included.
|
||||
|
||||
Quick setup and usage:
|
||||
|
||||
```sh
|
||||
go mod tidy
|
||||
go run ./ws/main.go
|
||||
```
|
||||
@@ -1,6 +1,8 @@
|
||||
module github.com/XRPLF
|
||||
|
||||
go 1.24.0
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.10
|
||||
|
||||
require github.com/Peersyst/xrpl-go v0.1.11
|
||||
|
||||
@@ -18,5 +20,5 @@ require (
|
||||
github.com/tyler-smith/go-bip32 v1.0.0 // indirect
|
||||
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.35.0 // indirect
|
||||
)
|
||||
|
||||
@@ -46,8 +46,8 @@ github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
||||
golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
|
||||
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
xrpl-py>=3.0.0
|
||||
wxPython==4.2.1
|
||||
toml==0.10.2
|
||||
requests==2.33.0
|
||||
requests==2.32.4
|
||||
|
||||
@@ -23,7 +23,7 @@ const softwallets = [
|
||||
{ href: "https://coin.space/", id: "wallet-coin", alt: "Coin Space" },
|
||||
{ href: "https://crossmark.io/", id: "wallet-crossmark", alt: "Crossmark Wallet" },
|
||||
{ href: "https://gatehub.net/", id: "wallet-gatehub", alt: "Gatehub", imgclasses: "invertible-img" },
|
||||
{ href: "https://gemwallet.com/", id: "wallet-gem", alt: "Gem Wallet" },
|
||||
{ href: "https://gemwallet.app/", id: "wallet-gem", alt: "Gem Wallet" },
|
||||
{ href: "https://joeywallet.xyz/", id: "wallet-joey", alt: "Joey Wallet" },
|
||||
{ href: "https://trustwallet.com/", id: "wallet-trust", alt: "Trust Wallet" },
|
||||
{ href: "https://xaman.app/", id: "wallet-xumm", alt: "Xaman" }
|
||||
|
||||
@@ -168,3 +168,40 @@ If Alice just signs her part of the Batch transaction, Bob can modify his transa
|
||||
An inner batch transaction is a special case. It doesn't include a signature or a fee (since those are both included in the outer transaction). Therefore, they must be handled carefully to ensure that someone can't somehow directly submit an inner `Batch` transaction without it being included in an outer transaction.
|
||||
|
||||
Inner transactions cannot be broadcast (and won't be accepted if they happen to be broadcast, for example, from a malicious node). They must be generated from the `Batch` outer transaction instead. Inner transactions cannot be directly submitted via the submit RPC.
|
||||
|
||||
## Integration Considerations
|
||||
|
||||
`Batch` transactions have some unique integration considerations:
|
||||
|
||||
- Since the outer transaction returns `tesSUCCESS` even when inner transactions fail (see [Metadata](#metadata)), you must check each inner transaction's metadata and result codes to determine its actual outcome.
|
||||
- If inner transactions are validated, they are included in the same ledger as the outer transaction. If an inner transaction appears in a different ledger, it is likely a fraud attempt.
|
||||
- Systems that don't specifically handle `Batch` transactions should be able to support them without any changes, since each inner transaction is a valid transaction on its own. All inner transactions that have a `tes` (success) or `tec` result code are accessible via standard transaction-fetching mechanisms such as [`tx`](/docs/references/http-websocket-apis/public-api-methods/transaction-methods/tx.md) and [`account_tx`](/docs/references/http-websocket-apis/public-api-methods/account-methods/account_tx.md).
|
||||
- In a multi-account `Batch` transaction, only the inner transactions and batch mode flags are signed by all parties. This means the submitter of the outer transaction can adjust the sequence number and fee of the outer transaction as needed, without coordinating with the other parties.
|
||||
|
||||
The following sections cover additional recommendations for specific types of integrations.
|
||||
|
||||
### Client Libraries
|
||||
|
||||
Client libraries that implement `Batch` transaction support should:
|
||||
|
||||
- Provide a helper method to calculate the fee for a `Batch` transaction, since the fee includes the sum of all inner transaction fees. See [XRPL Batch Transaction Fees](#xrpl-batch-transaction-fees).
|
||||
- Provide a helper method to construct and sign multi-account `Batch` transactions, where one party signs the outer transaction and the other parties sign the inner transactions.
|
||||
- Provide an auto-fill method that sets each inner transaction's `Fee` to `"0"` and the `SigningPubKey` to an empty string (`""`), while omitting the `TxnSignature` field.
|
||||
|
||||
### Wallets
|
||||
|
||||
Wallets that display or sign `Batch` transactions should:
|
||||
|
||||
- Clearly display all inner transactions to users before requesting a signature, so users understand the full scope of what they are approving.
|
||||
- For multi-account `Batch` transactions, provide a workflow for users to review and sign their portion of the batch, then export it for other parties to sign.
|
||||
- Warn users if they are signing a `Batch` transaction that includes inner transactions from other accounts, since they are approving the entire batch.
|
||||
- Display the batch mode (`ALLORNOTHING`, `ONLYONE`, `UNTILFAILURE`, `INDEPENDENT`) and explain its implications.
|
||||
- Avoid auto-incrementing sequence numbers after successes or failures, since the number of validated transactions depends on the batch mode and which inner transactions succeed.
|
||||
|
||||
### Explorers and Indexers
|
||||
|
||||
Explorers and indexers that display `Batch` transactions should:
|
||||
|
||||
- Display the relationship between outer `Batch` transactions and their inner transactions using the `ParentBatchID` field in the inner transaction metadata.
|
||||
- Show inner transactions in context with their parent `Batch` transaction, rather than as standalone transactions.
|
||||
- Consider grouping inner transactions with their outer transaction in transaction lists for clarity.
|
||||
|
||||
@@ -66,7 +66,7 @@ In addition to the [common ledger entry fields][], {% code-page-name /%} entries
|
||||
| `ManagementFeeRate` | Number | UInt16 | No | The fee charged by the lending protocol, in units of 1/10th basis points. Valid values are 0 to 100000 (inclusive), representing 0% to 100%. |
|
||||
| `OwnerCount` | Number | UInt32 | Yes | The number of active loans issued by the LoanBroker. |
|
||||
| `DebtTotal` | String | Number | Yes | The total asset amount the protocol owes the vault, including interest. |
|
||||
| `DebtMaximum` | String | Number | No | The maximum amount the protocol can owe the vault. The default value of `0` means there is no limit to the debt. |
|
||||
| `DebtMaximum` | String | Number | Yes | The maximum amount the protocol can owe the vault. The default value of `0` means there is no limit to the debt. |
|
||||
| `CoverAvailable` | String | Number | Yes | The total amount of first-loss capital deposited into the lending protocol. |
|
||||
| `CoverRateMinimum` | Number | UInt32 | Yes | The 1/10th basis point of the `DebtTotal` that the first-loss capital must cover. Valid values are 0 to 100000 (inclusive), representing 0% to 100%. |
|
||||
| `CoverRateLiquidation`| Number | UInt12 | Yes | The 1/10th basis point of minimum required first-loss capital that is moved to an asset vault to cover a loan default. Valid values are 0 to 100000 (inclusive), representing 0% to 100%. |
|
||||
|
||||
@@ -1,52 +1,5 @@
|
||||
import { useThemeHooks } from "@redocly/theme/core/hooks"
|
||||
import { Link } from "@redocly/theme/components/Link/Link"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
type TutorialLanguagesMap = Record<string, string[]>
|
||||
|
||||
interface TutorialMetadataItem {
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
lastModified: string
|
||||
category: string
|
||||
}
|
||||
|
||||
interface Tutorial {
|
||||
title: string
|
||||
description?: string
|
||||
path: string
|
||||
// External community contribution fields (optional)
|
||||
author?: { name: string; url: string }
|
||||
github?: string
|
||||
externalUrl?: string
|
||||
}
|
||||
|
||||
interface TutorialSection {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
tutorials: Tutorial[]
|
||||
showFooter?: boolean
|
||||
}
|
||||
|
||||
// External community contribution - manually curated with author/repo/demo info
|
||||
interface PinnedExternalTutorial {
|
||||
title: string
|
||||
description: string
|
||||
author: { name: string; url: string }
|
||||
github: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
// Pinned tutorial entry:
|
||||
// - string: internal path (uses frontmatter title/description)
|
||||
// - object with `path`: internal path with optional description override
|
||||
// - PinnedExternalTutorial: external community contribution with author/repo/demo
|
||||
type PinnedTutorial = string | { path: string; description?: string } | PinnedExternalTutorial
|
||||
|
||||
const MAX_WHATS_NEW = 3
|
||||
const MAX_TUTORIALS_PER_SECTION = 6
|
||||
|
||||
export const frontmatter = {
|
||||
seo: {
|
||||
@@ -66,90 +19,242 @@ const langIcons: Record<string, { src: string; alt: string }> = {
|
||||
xrpl: { src: "/img/logos/xrp-mark.svg", alt: "XRP Ledger" },
|
||||
}
|
||||
|
||||
// ── Section configuration -----------------------------------------------------------
|
||||
// Categories and their titles are auto-detected by the tutorial-metadata plugin.
|
||||
// Use the config to customize the category titles, add descriptions, change the default category order, and pin tutorials.
|
||||
const sectionConfig: Record<string, {
|
||||
title?: string
|
||||
description?: string
|
||||
pinned?: PinnedTutorial[]
|
||||
showFooter?: boolean
|
||||
}> = {
|
||||
"whats-new": {
|
||||
title: "What's New",
|
||||
description: "Recently added/updated tutorials to help you build on the XRP Ledger.",
|
||||
// Type for the tutorial languages map from the plugin
|
||||
type TutorialLanguagesMap = Record<string, string[]>
|
||||
|
||||
interface Tutorial {
|
||||
title: string
|
||||
body?: string
|
||||
path: string
|
||||
icon?: string // Single language icon (for single-language tutorials)
|
||||
}
|
||||
|
||||
interface TutorialSection {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
tutorials: Tutorial[]
|
||||
}
|
||||
|
||||
// Get Started tutorials -----------------
|
||||
const getStartedTutorials: Tutorial[] = [
|
||||
{
|
||||
title: "JavaScript",
|
||||
body: "Using the xrpl.js client library.",
|
||||
path: "/docs/tutorials/get-started/get-started-javascript/",
|
||||
icon: "javascript",
|
||||
},
|
||||
"get-started": {
|
||||
showFooter: true,
|
||||
title: "Get Started with SDKs",
|
||||
description: "These tutorials walk you through the basics of building a very simple XRP Ledger-connected application using your favorite programming language.",
|
||||
pinned: [
|
||||
{ path: "/docs/tutorials/get-started/get-started-javascript/", description: "Using the xrpl.js client library." },
|
||||
{ path: "/docs/tutorials/get-started/get-started-python/", description: "Using xrpl.py, a pure Python library." },
|
||||
{ path: "/docs/tutorials/get-started/get-started-go/", description: "Using xrpl-go, a pure Go library." },
|
||||
{ path: "/docs/tutorials/get-started/get-started-java/", description: "Using xrpl4j, a pure Java library." },
|
||||
{ path: "/docs/tutorials/get-started/get-started-php/", description: "Using the XRPL_PHP client library." },
|
||||
{ path: "/docs/tutorials/get-started/get-started-http-websocket-apis/", description: "Access the XRP Ledger directly through the APIs of its core server." },
|
||||
],
|
||||
{
|
||||
title: "Python",
|
||||
body: "Using xrpl.py, a pure Python library.",
|
||||
path: "/docs/tutorials/get-started/get-started-python/",
|
||||
icon: "python",
|
||||
},
|
||||
"tokens": {
|
||||
{
|
||||
title: "Go",
|
||||
body: "Using xrpl-go, a pure Go library.",
|
||||
path: "/docs/tutorials/get-started/get-started-go/",
|
||||
icon: "go",
|
||||
},
|
||||
{
|
||||
title: "Java",
|
||||
body: "Using xrpl4j, a pure Java library.",
|
||||
path: "/docs/tutorials/get-started/get-started-java/",
|
||||
icon: "java",
|
||||
},
|
||||
{
|
||||
title: "PHP",
|
||||
body: "Using the XRPL_PHP client library.",
|
||||
path: "/docs/tutorials/get-started/get-started-php/",
|
||||
icon: "php",
|
||||
},
|
||||
{
|
||||
title: "HTTP & WebSocket APIs",
|
||||
body: "Access the XRP Ledger directly through the APIs of its core server.",
|
||||
path: "/docs/tutorials/get-started/get-started-http-websocket-apis/",
|
||||
icon: "http",
|
||||
},
|
||||
]
|
||||
|
||||
// Other tutorial sections -----------------
|
||||
// Languages are auto-detected from the markdown files by the tutorial-languages plugin.
|
||||
// Only specify `icon` for single-language tutorials without tabs.
|
||||
const sections: TutorialSection[] = [
|
||||
{
|
||||
id: "tokens",
|
||||
title: "Tokens",
|
||||
description: "Create and manage tokens on the XRP Ledger.",
|
||||
pinned: [
|
||||
{ path: "/docs/tutorials/tokens/mpts/issue-a-multi-purpose-token/", description: "Issue new tokens using the v2 fungible token standard." },
|
||||
{ path: "/docs/tutorials/tokens/fungible-tokens/issue-a-fungible-token/", description: "Issue new tokens using the v1 fungible token standard." },
|
||||
{ path: "/docs/tutorials/tokens/nfts/mint-and-burn-nfts-js/", description: "Create new NFTs, retrieve existing tokens, and burn the ones you no longer need." },
|
||||
"/docs/tutorials/tokens/mpts/sending-mpts-in-javascript/",
|
||||
],
|
||||
},
|
||||
"payments": {
|
||||
description: "Transfer XRP and issued currencies using various payment types.",
|
||||
pinned: [
|
||||
"/docs/tutorials/payments/send-xrp/",
|
||||
"/docs/tutorials/payments/create-trust-line-send-currency-in-javascript/",
|
||||
"/docs/tutorials/payments/send-a-conditional-escrow/",
|
||||
"/docs/tutorials/payments/send-a-timed-escrow/",
|
||||
],
|
||||
},
|
||||
"defi": {
|
||||
description: "Trade, provide liquidity, and lend using native XRP Ledger DeFi features.",
|
||||
pinned: [
|
||||
"/docs/tutorials/defi/dex/create-an-automated-market-maker/",
|
||||
"/docs/tutorials/defi/dex/trade-in-the-decentralized-exchange/",
|
||||
"/docs/tutorials/defi/lending/use-the-lending-protocol/create-a-loan/",
|
||||
"/docs/tutorials/defi/lending/use-single-asset-vaults/create-a-single-asset-vault/",
|
||||
],
|
||||
},
|
||||
"best-practices": {
|
||||
description: "Learn recommended patterns for building reliable, secure applications on the XRP Ledger.",
|
||||
pinned: [
|
||||
"/docs/tutorials/best-practices/api-usage/",
|
||||
],
|
||||
},
|
||||
"compliance-features": {
|
||||
title: "Compliance",
|
||||
description: "Implement compliance controls like destination tags, credentials, and permissioned domains.",
|
||||
},
|
||||
"programmability": {
|
||||
description: "Set up cross-chain bridges and submit interoperability transactions.",
|
||||
},
|
||||
"advanced-developer-topics": {
|
||||
description: "Explore advanced topics like WebSocket monitoring and testing Devnet features.",
|
||||
},
|
||||
"sample-apps": {
|
||||
description: "Build complete, end-to-end applications like wallets and credential services.",
|
||||
pinned: [
|
||||
tutorials: [
|
||||
{
|
||||
title: "XRPL Lending Protocol Demo",
|
||||
description: "A full-stack web application that demonstrates the end-to-end flow of the Lending Protocol and Single Asset Vaults.",
|
||||
author: { name: "Aaditya-T", url: "https://github.com/Aaditya-T" },
|
||||
github: "https://github.com/Aaditya-T/lending_test",
|
||||
url: "https://lending-test-lovat.vercel.app/",
|
||||
title: "Issue a Multi-Purpose Token",
|
||||
body: "Issue new tokens using the v2 fungible token standard.",
|
||||
path: "/docs/tutorials/tokens/mpts/issue-a-multi-purpose-token/",
|
||||
},
|
||||
{
|
||||
title: "Issue a Fungible Token",
|
||||
body: "Issue new tokens using the v1 fungible token standard.",
|
||||
path: "/docs/tutorials/tokens/fungible-tokens/issue-a-fungible-token/",
|
||||
},
|
||||
{
|
||||
title: "Mint and Burn NFTs Using JavaScript",
|
||||
body: "Create new NFTs, retrieve existing tokens, and burn the ones you no longer need.",
|
||||
path: "/docs/tutorials/tokens/nfts/mint-and-burn-nfts-js/",
|
||||
icon: "javascript",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// ── Components ──────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: "payments",
|
||||
title: "Payments",
|
||||
description: "Transfer XRP and issued currencies using various payment types.",
|
||||
tutorials: [
|
||||
{
|
||||
title: "Send XRP",
|
||||
body: "Send a direct XRP payment to another account.",
|
||||
path: "/docs/tutorials/payments/send-xrp/",
|
||||
},
|
||||
{
|
||||
title: "Sending MPTs in JavaScript",
|
||||
body: "Send a Multi-Purpose Token (MPT) to another account with the JavaScript SDK.",
|
||||
path: "/docs/tutorials/tokens/mpts/sending-mpts-in-javascript/",
|
||||
icon: "javascript",
|
||||
},
|
||||
{
|
||||
title: "Create Trust Line and Send Currency in JavaScript",
|
||||
body: "Set up trust lines and send issued currencies with the JavaScript SDK.",
|
||||
path: "/docs/tutorials/payments/create-trust-line-send-currency-in-javascript/",
|
||||
icon: "javascript",
|
||||
},
|
||||
{
|
||||
title: "Create Trust Line and Send Currency in Python",
|
||||
body: "Set up trust lines and send issued currencies with the Python SDK.",
|
||||
path: "/docs/tutorials/payments/create-trust-line-send-currency-in-python/",
|
||||
icon: "python",
|
||||
},
|
||||
{
|
||||
title: "Send a Conditional Escrow",
|
||||
body: "Send an escrow that can be released when a specific crypto-condition is fulfilled.",
|
||||
path: "/docs/tutorials/payments/send-a-conditional-escrow/",
|
||||
},
|
||||
{
|
||||
title: "Send a Timed Escrow",
|
||||
body: "Send an escrow whose only condition for release is that a specific time has passed.",
|
||||
path: "/docs/tutorials/payments/send-a-timed-escrow/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "defi",
|
||||
title: "DeFi",
|
||||
description: "Trade, provide liquidity, and lend using native XRP Ledger DeFi features.",
|
||||
tutorials: [
|
||||
{
|
||||
title: "Create an Automated Market Maker",
|
||||
body: "Set up an AMM for a token pair and provide liquidity.",
|
||||
path: "/docs/tutorials/defi/dex/create-an-automated-market-maker/",
|
||||
},
|
||||
{
|
||||
title: "Trade in the Decentralized Exchange",
|
||||
body: "Buy and sell tokens in the Decentralized Exchange (DEX).",
|
||||
path: "/docs/tutorials/defi/dex/trade-in-the-decentralized-exchange/",
|
||||
},
|
||||
{
|
||||
title: "Create a Loan Broker",
|
||||
body: "Set up a loan broker to create and manage loans.",
|
||||
path: "/docs/tutorials/defi/lending/use-the-lending-protocol/create-a-loan-broker/",
|
||||
},
|
||||
{
|
||||
title: "Create a Loan",
|
||||
body: "Create a loan on the XRP Ledger.",
|
||||
path: "/docs/tutorials/defi/lending/use-the-lending-protocol/create-a-loan/",
|
||||
},
|
||||
{
|
||||
title: "Create a Single Asset Vault",
|
||||
body: "Create a single asset vault on the XRP Ledger.",
|
||||
path: "/docs/tutorials/defi/lending/use-single-asset-vaults/create-a-single-asset-vault/",
|
||||
},
|
||||
{
|
||||
title: "Deposit into a Vault",
|
||||
body: "Deposit assets into a vault and receive shares.",
|
||||
path: "/docs/tutorials/defi/lending/use-single-asset-vaults/deposit-into-a-vault/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "best-practices",
|
||||
title: "Best Practices",
|
||||
description: "Learn recommended patterns for building reliable, secure applications on the XRP Ledger.",
|
||||
tutorials: [
|
||||
{
|
||||
title: "API Usage",
|
||||
body: "Best practices for using XRP Ledger APIs.",
|
||||
path: "/docs/tutorials/best-practices/api-usage/",
|
||||
},
|
||||
{
|
||||
title: "Use Tickets",
|
||||
body: "Use tickets to send transactions out of the normal order.",
|
||||
path: "/docs/tutorials/best-practices/transaction-sending/use-tickets/",
|
||||
},
|
||||
{
|
||||
title: "Send a Single Account Batch Transaction",
|
||||
body: "Group multiple transactions together and execute them as a single atomic operation.",
|
||||
path: "/docs/tutorials/best-practices/transaction-sending/send-a-single-account-batch-transaction/",
|
||||
},
|
||||
{
|
||||
title: "Assign a Regular Key Pair",
|
||||
body: "Assign a regular key pair for signing transactions.",
|
||||
path: "/docs/tutorials/best-practices/key-management/assign-a-regular-key-pair/",
|
||||
},
|
||||
{
|
||||
title: "Set Up Multi-Signing",
|
||||
body: "Configure multi-signing for enhanced security.",
|
||||
path: "/docs/tutorials/best-practices/key-management/set-up-multi-signing/",
|
||||
},
|
||||
{
|
||||
title: "Send a Multi-Signed Transaction",
|
||||
body: "Send a transaction with multiple signatures.",
|
||||
path: "/docs/tutorials/best-practices/key-management/send-a-multi-signed-transaction/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "sample-apps",
|
||||
title: "Sample Apps",
|
||||
description: "Build complete, end-to-end applications like wallets and credential services.",
|
||||
tutorials: [
|
||||
{
|
||||
title: "Build a Browser Wallet in JavaScript",
|
||||
body: "Build a browser wallet for the XRP Ledger using JavaScript and various libraries.",
|
||||
path: "/docs/tutorials/sample-apps/build-a-browser-wallet-in-javascript/",
|
||||
icon: "javascript",
|
||||
},
|
||||
{
|
||||
title: "Build a Desktop Wallet in JavaScript",
|
||||
body: "Build a desktop wallet for the XRP Ledger using JavaScript, the Electron Framework, and various libraries.",
|
||||
path: "/docs/tutorials/sample-apps/build-a-desktop-wallet-in-javascript/",
|
||||
icon: "javascript",
|
||||
},
|
||||
{
|
||||
title: "Build a Desktop Wallet in Python",
|
||||
body: "Build a desktop wallet for the XRP Ledger using Python and various libraries.",
|
||||
path: "/docs/tutorials/sample-apps/build-a-desktop-wallet-in-python/",
|
||||
icon: "python",
|
||||
},
|
||||
{
|
||||
title: "Credential Issuing Service in JavaScript",
|
||||
body: "Build a credential issuing service using the JavaScript SDK.",
|
||||
path: "/docs/tutorials/sample-apps/credential-issuing-service-in-javascript/",
|
||||
icon: "javascript",
|
||||
},
|
||||
{
|
||||
title: "Credential Issuing Service in Python",
|
||||
body: "Build a credential issuing service using the Python SDK.",
|
||||
path: "/docs/tutorials/sample-apps/credential-issuing-service-in-python/",
|
||||
icon: "python",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function TutorialCard({
|
||||
tutorial,
|
||||
@@ -162,10 +267,12 @@ function TutorialCard({
|
||||
showFooter?: boolean
|
||||
translate: (text: string) => string
|
||||
}) {
|
||||
// Get icons from auto-detected languages, or fallback to XRPL icon.
|
||||
const icons = detectedLanguages && detectedLanguages.length > 0
|
||||
? detectedLanguages.map((lang) => langIcons[lang]).filter(Boolean)
|
||||
: [langIcons.xrpl]
|
||||
// Get icons: manual icon takes priority, then auto-detected languages, then XRPL fallback
|
||||
const icons = tutorial.icon && langIcons[tutorial.icon]
|
||||
? [langIcons[tutorial.icon]]
|
||||
: detectedLanguages && detectedLanguages.length > 0
|
||||
? detectedLanguages.map((lang) => langIcons[lang]).filter(Boolean)
|
||||
: [langIcons.xrpl]
|
||||
|
||||
return (
|
||||
<Link to={tutorial.path} className="card">
|
||||
@@ -178,220 +285,13 @@ function TutorialCard({
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<h4 className="card-title h5">{translate(tutorial.title)}</h4>
|
||||
{tutorial.description && <p className="card-text">{translate(tutorial.description)}</p>}
|
||||
{tutorial.body && <p className="card-text">{translate(tutorial.body)}</p>}
|
||||
</div>
|
||||
{showFooter && <div className="card-footer"></div>}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Inline meta link used in ContributionCard
|
||||
function MetaLink({ href, icon, label }: {
|
||||
href: string
|
||||
icon: string
|
||||
label: string
|
||||
}) {
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" className="meta-link">
|
||||
<i className={`fa fa-${icon}`} aria-hidden="true" />
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// Community Contribution Card
|
||||
function ContributionCard({
|
||||
tutorial,
|
||||
translate,
|
||||
}: {
|
||||
tutorial: Tutorial
|
||||
translate: (text: string) => string
|
||||
}) {
|
||||
const primaryUrl = tutorial.externalUrl || tutorial.github!
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".card-meta-row")) return
|
||||
window.open(primaryUrl, "_blank", "noopener,noreferrer")
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card contribution-card"
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleCardClick(e) }}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="card-header contribution-header">
|
||||
<span className="circled-logo contribution-icon">
|
||||
<i className="fa fa-users" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="card-meta-row">
|
||||
{tutorial.author && (
|
||||
<>
|
||||
<MetaLink href={tutorial.author.url} icon="user" label={tutorial.author.name} />
|
||||
<span className="meta-dot" aria-hidden="true">·</span>
|
||||
</>
|
||||
)}
|
||||
<MetaLink href={tutorial.github!} icon="github" label={translate("GitHub")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<h4 className="card-title h5">
|
||||
{translate(tutorial.title)}
|
||||
<span className="card-external-icon" aria-label={translate("External link")}>
|
||||
<i className="fa fa-external-link" aria-hidden="true" />
|
||||
</span>
|
||||
</h4>
|
||||
{tutorial.description && <p className="card-text">{translate(tutorial.description)}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Reusable section block for rendering tutorial sections
|
||||
function TutorialSectionBlock({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
tutorials,
|
||||
tutorialLanguages,
|
||||
showFooter = false,
|
||||
maxTutorials,
|
||||
className = "",
|
||||
translate,
|
||||
}: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
tutorials: Tutorial[]
|
||||
tutorialLanguages: TutorialLanguagesMap
|
||||
showFooter?: boolean
|
||||
maxTutorials?: number
|
||||
className?: string
|
||||
translate: (text: string) => string
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const sectionRef = useRef<HTMLElement>(null)
|
||||
const hasMore = maxTutorials ? tutorials.length > maxTutorials : false
|
||||
const displayTutorials = maxTutorials && !expanded ? tutorials.slice(0, maxTutorials) : tutorials
|
||||
|
||||
const handleToggle = () => {
|
||||
if (expanded && sectionRef.current) {
|
||||
const offsetTop = sectionRef.current.getBoundingClientRect().top + window.scrollY
|
||||
setExpanded(false)
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: offsetTop - 20 })
|
||||
})
|
||||
} else {
|
||||
setExpanded(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} className={`container-new pt-10 pb-14 ${className}`.trim()} id={id}>
|
||||
<div className="col-12 col-xl-8 p-0">
|
||||
<h3 className="h4 mb-3">{translate(title)}</h3>
|
||||
<p className="mb-4">{translate(description)}</p>
|
||||
</div>
|
||||
<div className="row tutorial-cards">
|
||||
{displayTutorials.map((tutorial) => (
|
||||
<div key={tutorial.path} className="col-lg-4 col-md-6 mb-5">
|
||||
{tutorial.github ? (
|
||||
<ContributionCard tutorial={tutorial} translate={translate} />
|
||||
) : (
|
||||
<TutorialCard
|
||||
tutorial={tutorial}
|
||||
detectedLanguages={tutorialLanguages[tutorial.path]}
|
||||
showFooter={showFooter}
|
||||
translate={translate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="explore-more-wrapper">
|
||||
<button
|
||||
className="explore-more-link"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{expanded ? translate("Show less") : translate("Explore more")} {expanded ? "↑" : "→"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// Copyable URL component with click-to-copy functionality
|
||||
function CopyableUrl({ url, translate }: { url: string; translate: (text: string) => string }) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error("Failed to copy:", err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`quick-ref-value-btn ${copied ? "copied" : ""}`}
|
||||
onClick={handleCopy}
|
||||
title={copied ? translate("Copied!") : translate("Click to copy")}
|
||||
>
|
||||
<code className="quick-ref-value">{url}</code>
|
||||
<span className="copy-icon">{copied ? "✓" : ""}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Quick reference card showing public server URLs and faucet link
|
||||
function QuickReferenceCard({ translate }: { translate: (text: string) => string }) {
|
||||
return (
|
||||
<div className="quick-ref-card">
|
||||
<div className="quick-ref-section">
|
||||
<span className="quick-ref-label">{translate("PUBLIC SERVERS")}</span>
|
||||
<div className="quick-ref-group">
|
||||
<span className="quick-ref-key"><strong>{translate("Mainnet")}</strong></span>
|
||||
<div className="quick-ref-urls">
|
||||
<span className="quick-ref-protocol">{translate("WebSocket")}</span>
|
||||
<CopyableUrl url="wss://xrplcluster.com" translate={translate} />
|
||||
<span className="quick-ref-protocol">{translate("JSON-RPC")}</span>
|
||||
<CopyableUrl url="https://xrplcluster.com" translate={translate} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="quick-ref-group">
|
||||
<span className="quick-ref-key"><strong>{translate("Testnet")}</strong></span>
|
||||
<div className="quick-ref-urls">
|
||||
<span className="quick-ref-protocol">{translate("WebSocket")}</span>
|
||||
<CopyableUrl url="wss://s.altnet.rippletest.net:51233" translate={translate} />
|
||||
<span className="quick-ref-protocol">{translate("JSON-RPC")}</span>
|
||||
<CopyableUrl url="https://s.altnet.rippletest.net:51234" translate={translate} />
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/docs/tutorials/public-servers/" className="quick-ref-link">
|
||||
{translate("View all servers")} →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="quick-ref-divider"></div>
|
||||
<div className="quick-ref-section">
|
||||
<Link to="/resources/dev-tools/xrp-faucets/" className="quick-ref-faucet">
|
||||
<span>{translate("Get Test XRP")}</span>
|
||||
<span className="quick-ref-arrow">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Page Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function TutorialsIndex() {
|
||||
const { useTranslate, usePageSharedData } = useThemeHooks()
|
||||
const { translate } = useTranslate()
|
||||
@@ -399,160 +299,65 @@ export default function TutorialsIndex() {
|
||||
// Get auto-detected languages from the plugin (maps tutorial paths to language arrays).
|
||||
const tutorialLanguages = usePageSharedData<TutorialLanguagesMap>("tutorial-languages") || {}
|
||||
|
||||
// Get tutorial metadata and sidebar categories from the tutorial-metadata plugin.
|
||||
const tutorialMetadata = usePageSharedData<{
|
||||
tutorials: TutorialMetadataItem[]
|
||||
categories: { id: string; title: string }[]
|
||||
}>("tutorial-metadata")
|
||||
const allTutorials = tutorialMetadata?.tutorials || []
|
||||
const sidebarCategories = tutorialMetadata?.categories || []
|
||||
|
||||
// What's New: most recently modified tutorials, excluding Get Started.
|
||||
const whatsNewConfig = sectionConfig["whats-new"]!
|
||||
const getStartedPaths = new Set(
|
||||
(sectionConfig["get-started"]?.pinned || []).map(getPinnedPath)
|
||||
)
|
||||
const whatsNewTutorials: Tutorial[] = allTutorials
|
||||
.filter((tutorial) => !getStartedPaths.has(tutorial.path))
|
||||
.slice(0, MAX_WHATS_NEW)
|
||||
.map((tutorial) => toTutorial(tutorial))
|
||||
|
||||
// Category sections (including Get Started): ordered by sectionConfig, then any new sidebar categories.
|
||||
const sections = buildCategorySections(sidebarCategories, allTutorials)
|
||||
|
||||
return (
|
||||
<main className="landing page-tutorials landing-builtin-bg">
|
||||
{/* Hero Section */}
|
||||
<section className="container-new py-20">
|
||||
<div className="row align-items-center">
|
||||
<div className="col-lg-7">
|
||||
<div className="d-flex flex-column-reverse">
|
||||
<h1 className="mb-0">
|
||||
{translate("Crypto Wallet and Blockchain Development Tutorials")}
|
||||
</h1>
|
||||
<h6 className="eyebrow mb-3">{translate("Tutorials")}</h6>
|
||||
</div>
|
||||
<nav className="mt-4">
|
||||
<ul className="page-toc no-sideline d-flex flex-wrap gap-2 mb-0">
|
||||
{whatsNewTutorials.length > 0 && (
|
||||
<li><Link to="#whats-new">{translate(whatsNewConfig.title)}</Link></li>
|
||||
)}
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}><Link to={`#${section.id}`}>{translate(section.title)}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="col-lg-5 mt-6 mt-lg-0">
|
||||
<QuickReferenceCard translate={translate} />
|
||||
<section className="container-new py-26">
|
||||
<div className="col-lg-8 mx-auto text-lg-center">
|
||||
<div className="d-flex flex-column-reverse">
|
||||
<h1 className="mb-0">
|
||||
{translate("Crypto Wallet and Blockchain Development Tutorials")}
|
||||
</h1>
|
||||
<h6 className="eyebrow mb-3">{translate("Tutorials")}</h6>
|
||||
</div>
|
||||
{/* Table of Contents */}
|
||||
<nav className="mt-4">
|
||||
<ul className="page-toc no-sideline d-flex flex-wrap justify-content-center gap-2 mb-0">
|
||||
<li><a href="#get-started">{translate("Get Started with SDKs")}</a></li>
|
||||
{sections.map((section) => (
|
||||
<li key={section.id}><a href={`#${section.id}`}>{translate(section.title)}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What's New */}
|
||||
{whatsNewTutorials.length > 0 && (
|
||||
<TutorialSectionBlock
|
||||
id="whats-new"
|
||||
title={whatsNewConfig.title!}
|
||||
description={whatsNewConfig.description!}
|
||||
tutorials={whatsNewTutorials}
|
||||
tutorialLanguages={tutorialLanguages}
|
||||
showFooter
|
||||
className="whats-new-section pb-20"
|
||||
translate={translate}
|
||||
/>
|
||||
)}
|
||||
{/* Get Started */}
|
||||
<section className="container-new pt-10 pb-20" id="get-started">
|
||||
<div className="col-12 col-xl-8 p-0">
|
||||
<h3 className="h4 mb-3">{translate("Get Started with SDKs")}</h3>
|
||||
<p className="mb-4">
|
||||
{translate("These tutorials walk you through the basics of building a very simple XRP Ledger-connected application using your favorite programming language.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="row tutorial-cards">
|
||||
{getStartedTutorials.map((tutorial, idx) => (
|
||||
<div key={idx} className="col-lg-4 col-md-6 mb-5">
|
||||
<TutorialCard tutorial={tutorial} showFooter translate={translate} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tutorial Sections */}
|
||||
{/* Other Tutorials */}
|
||||
{sections.map((section) => (
|
||||
<TutorialSectionBlock
|
||||
key={section.id}
|
||||
id={section.id}
|
||||
title={section.title}
|
||||
description={section.description}
|
||||
tutorials={section.tutorials}
|
||||
tutorialLanguages={tutorialLanguages}
|
||||
maxTutorials={section.showFooter ? undefined : MAX_TUTORIALS_PER_SECTION}
|
||||
showFooter={section.showFooter}
|
||||
className={section.showFooter ? "pb-20" : "category-section"}
|
||||
translate={translate}
|
||||
/>
|
||||
<section className="container-new pt-10 pb-10" key={section.id} id={section.id}>
|
||||
<div className="col-12 col-xl-8 p-0">
|
||||
<h3 className="h4 mb-3">{translate(section.title)}</h3>
|
||||
<p className="mb-4">{translate(section.description)}</p>
|
||||
</div>
|
||||
<div className="row tutorial-cards">
|
||||
{section.tutorials.slice(0, 6).map((tutorial, idx) => (
|
||||
<div key={idx} className="col-lg-4 col-md-6 mb-5">
|
||||
<TutorialCard
|
||||
tutorial={tutorial}
|
||||
detectedLanguages={tutorialLanguages[tutorial.path]}
|
||||
translate={translate}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Type guard for external community contributions */
|
||||
function isExternalContribution(entry: PinnedTutorial): entry is PinnedExternalTutorial {
|
||||
return typeof entry !== "string" && "github" in entry
|
||||
}
|
||||
|
||||
/** Get path from pinned tutorial entry*/
|
||||
function getPinnedPath(entry: PinnedTutorial): string {
|
||||
return typeof entry === "string" ? entry : isExternalContribution(entry) ? entry.github : entry.path
|
||||
}
|
||||
|
||||
/** Convert tutorial metadata to the common Tutorial type */
|
||||
function toTutorial(t: TutorialMetadataItem, descriptionOverride?: string): Tutorial {
|
||||
return {
|
||||
title: t.title,
|
||||
description: descriptionOverride || t.description,
|
||||
path: t.path,
|
||||
}
|
||||
}
|
||||
|
||||
/** Build Tutorial objects from pinned entries, resolving metadata for internal paths */
|
||||
function buildPinnedTutorials(entries: PinnedTutorial[], allTutorials: TutorialMetadataItem[]): Tutorial[] {
|
||||
return entries
|
||||
.map((entry): Tutorial | null => {
|
||||
if (isExternalContribution(entry)) {
|
||||
return {
|
||||
title: entry.title,
|
||||
description: entry.description,
|
||||
path: entry.url || entry.github,
|
||||
author: entry.author,
|
||||
github: entry.github,
|
||||
externalUrl: entry.url,
|
||||
}
|
||||
}
|
||||
const path = getPinnedPath(entry)
|
||||
const descOverride = typeof entry === "string" ? undefined : entry.description
|
||||
const metadata = allTutorials.find((t) => t.path === path)
|
||||
return metadata ? toTutorial(metadata, descOverride) : null
|
||||
})
|
||||
.filter((t): t is Tutorial => t !== null)
|
||||
}
|
||||
|
||||
/** Build category sections ordered by sectionConfig, with new sidebar categories appended */
|
||||
function buildCategorySections(
|
||||
sidebarCategories: { id: string; title: string }[],
|
||||
allTutorials: TutorialMetadataItem[],
|
||||
): TutorialSection[] {
|
||||
const specialIds = new Set(["whats-new"])
|
||||
const sidebarMap = new Map(sidebarCategories.map((category) => [category.id, category]))
|
||||
const allPinnedPaths = new Set(
|
||||
Object.values(sectionConfig).flatMap((config) => (config.pinned || []).map(getPinnedPath))
|
||||
)
|
||||
|
||||
// Sections follow sectionConfig key order. New sidebar categories not in sectionConfig are appended at the end.
|
||||
const configIds = Object.keys(sectionConfig).filter((id) => !specialIds.has(id))
|
||||
const newIds = sidebarCategories
|
||||
.filter((category) => !specialIds.has(category.id) && !sectionConfig[category.id])
|
||||
.map((category) => category.id)
|
||||
|
||||
return [...configIds, ...newIds]
|
||||
.filter((id) => sidebarMap.has(id))
|
||||
.map((id) => {
|
||||
const config = sectionConfig[id]
|
||||
const title = config?.title || sidebarMap.get(id)!.title
|
||||
const description = config?.description || ""
|
||||
const pinned = buildPinnedTutorials(config?.pinned || [], allTutorials)
|
||||
const remaining = allTutorials
|
||||
.filter((t) => t.category === id && !allPinnedPaths.has(t.path))
|
||||
.map((t) => toTutorial(t))
|
||||
return { id, title, description, tutorials: [...pinned, ...remaining], showFooter: config?.showFooter }
|
||||
})
|
||||
.filter((section) => section.tutorials.length > 0)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ The image files used in blog posts are located in the `blog/img` directory.
|
||||
|
||||
The blog posts are grouped by year, so all blog posts published in year 2025 are located in the `blog/2025` directory.
|
||||
|
||||
|
||||
## Steps to Create a New Blog Post
|
||||
|
||||
To create a new post, follow these steps:
|
||||
@@ -40,43 +39,4 @@ To create a new post, follow these steps:
|
||||
|
||||
6. When the draft is ready for review, save and commit your updates.
|
||||
|
||||
7. Create a new PR to merge your changes to master.
|
||||
|
||||
|
||||
### Release Notes
|
||||
|
||||
We have streamlined the release notes process with the assistance of **Claude** skills. To create new release notes, follow these steps:
|
||||
|
||||
1. Load the `generate-release-notes` skill. **Claude** _should_ auto-load it when you set the working directory to the `xrpl-dev-portal` repository. If not, you can explicitly point to the skill located at `.claude/skills/generate-release-notes/SKILL.md`.
|
||||
{% admonition type="info" name="Note" %}Although the skill is optimized for **Claude**, you can give this `SKILL.md` file to any coding agent.{% /admonition %}
|
||||
|
||||
2. Run the `generate-release-notes` skill.
|
||||
```bash
|
||||
/generate-release-notes --from <branch-or-tag> --to <branch-or-tag>
|
||||
```
|
||||
|
||||
The skill accepts these arguments:
|
||||
|
||||
| Arguments | Description |
|
||||
|:-----------|:------------|
|
||||
| `--from` | (required) Base ref. Must match the exact [tag or branch](https://github.com/XRPLF/rippled/branches) the current version of `rippled` is building from. |
|
||||
| `--to` | (required) Target ref. Must match the exact [tag or branch](https://github.com/XRPLF/rippled/branches) the upcoming version of `rippled` will be built from. |
|
||||
| `--date` | (optional) Release date in `YYYY-MM-DD` format. Defaults to current date. |
|
||||
| `--output` | (optional) Output file path. Defaults to `blog/<year>/rippled-<version>.md`. |
|
||||
|
||||
3. The AI executes these steps:
|
||||
- Runs a Python script that fetches all commits and PR details between the two refs and outputs a draft blog post.
|
||||
- Processes all amendment-related changes, keeping, removing, or merging entries based on which amendments are part of the release.
|
||||
- Sorts remaining entries into subsections (Features, Breaking Changes, Bug Fixes, etc.) based on files touched, PR labels, and descriptions.
|
||||
- Reformats each entry to match the release notes style and writes a summary of the release.
|
||||
- Cleans up empty sections and adds the post to the sidebar.
|
||||
|
||||
{% admonition type="info" name="Note" %}Depending on the size of the release, this process can take upwards of ten minutes.{% /admonition %}
|
||||
|
||||
4. Review the final output.
|
||||
- Check the descriptions of any entries in **Amendments**, **Features**, or **Breaking Changes**.
|
||||
- Verify the integrity of the package links and add in the SHA-256 hashes.
|
||||
- Verify the commit linked in the **Install / Upgrade** section points to the version bump commit.
|
||||
- If you didn't pass in the correct release date using the `--date` argument, update the `date` field in the frontmatter.
|
||||
|
||||
5. Create a new PR to merge the release notes to `master`.
|
||||
7. Create a new PR to merge your changes to master.
|
||||
|
||||
@@ -23,11 +23,6 @@ The following is a list of [amendments](../docs/concepts/networks-and-servers/am
|
||||
|:----------------------------------|:------------------------------------------|:-------------------------------|
|
||||
| [Hooks][] | {% badge %}In Development: TBD{% /badge %} | [XRPL Hooks](https://hooks.xrpl.org/) |
|
||||
| [InvariantsV1_1][] | {% badge %}In Development: TBD{% /badge %} | |
|
||||
| [DynamicMPT][] | {% badge %}In Development: TBD{% /badge %} | [XLS-94 Dynamic MPTs](https://opensource.ripple.com/docs/xls-94-dynamic-mpts) |
|
||||
| [ConfidentialTransfer][] | {% badge %}In Development: TBD{% /badge %} | [XLS-96 Confidential Transfers](https://opensource.ripple.com/docs/xls-96-confidential-transfers) |
|
||||
| [MPTokensV2][] | {% badge %}In Development: TBD{% /badge %} | [XLS-82 MPT DEX Integration](https://opensource.ripple.com/docs/xls-82-mpt-dex) |
|
||||
| [Sponsor][] | {% badge %}In Development: TBD{% /badge %} | [XLS-68 Sponsored Fees and Reserves](https://opensource.ripple.com/docs/xls-68-sponsored-fees-and-reserves) |
|
||||
| [SmartEscrow][] | {% badge %}In Development: TBD{% /badge %} | [XLS-100 Smart Escrows](https://opensource.ripple.com/docs/xls-100-smart-escrows) |
|
||||
|
||||
{% admonition type="success" name="Tip" %}
|
||||
This list is updated manually. If you're working on an amendment and have a private network to test the changes, you can edit this page to add your in-development amendment to this list. For more information on contributing to the XRP Ledger, see [Contribute Code to the XRP Ledger](contribute-code/index.md).
|
||||
@@ -208,22 +203,7 @@ Modifies an existing type of ledger entry:
|
||||
|
||||
Also extends the `deposit_authorized` API method to check for credential-based auth and extends the `ledger_entry` method to allow lookup of Credential entries.
|
||||
|
||||
For more details, see [XLS-70: Credentials specification](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0070-credentials).
|
||||
|
||||
|
||||
### ConfidentialTransfer
|
||||
[ConfidentialTransfer]: #confidentialtransfer
|
||||
|
||||
| Amendment | ConfidentialTransfer |
|
||||
|:-------------|:---------------------|
|
||||
| Amendment ID | 2110E4A19966E2EF517C0A8C56A5F35099D7665B0BB89D7B126B30D50B86AAD5 |
|
||||
| Status | In Development |
|
||||
| Default Vote (Latest stable release) | No |
|
||||
| Pre-amendment functionality retired? | No |
|
||||
|
||||
Provides institutional-grade privacy for Multi-Purpose Tokens (MPTs) using advanced cryptography (EC-ElGamal and ZKPs). Individual balances and transfer amounts remain shielded from the public ledger while maintaining compliance mechanisms for authorized parties (issuers, auditors, or designated entities) to verify total supply and meet regulatory obligations.
|
||||
|
||||
For more details, see [XLS-96: Confidential Transfers](https://opensource.ripple.com/docs/xls-96-confidential-transfers).
|
||||
For more details, see the [XLS-70: Credentials specification](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0070-credentials).
|
||||
|
||||
|
||||
### CryptoConditions
|
||||
@@ -391,21 +371,6 @@ Adds functionality to update the `URI` field of an `NFToken` ledger entry. This
|
||||
2. `tfMutable`: New flag that enables authorized accounts to modify the `URI` of an NFT. This flag must be enabled when the NFT is initially minted.
|
||||
|
||||
|
||||
### DynamicMPT
|
||||
[DynamicMPT]: #dynamicmpt
|
||||
|
||||
| Amendment | DynamicMPT |
|
||||
|:-------------|:-----------|
|
||||
| Amendment ID | 58E92F338758479C06084E1B6BA366BAD8F75E5329A7F0EEAFFFDA51E5106B7F |
|
||||
| Status | In Development |
|
||||
| Default Vote (Latest stable release) | No |
|
||||
| Pre-amendment functionality retired? | No |
|
||||
|
||||
Extends Multi-Purpose Tokens to allow issuers to designate specific properties as mutable during token creation, enabling selected attributes to be updated later as business needs change.
|
||||
|
||||
For more details, see [XLS-94: Dynamic MPTs](https://opensource.ripple.com/docs/xls-94-dynamic-mpts).
|
||||
|
||||
|
||||
### EnforceInvariants
|
||||
[EnforceInvariants]: #enforceinvariants
|
||||
|
||||
@@ -1567,21 +1532,6 @@ Implements a new type of fungible token, called a _Multi-Purpose Token_ (MPT). T
|
||||
- (Updated) `ledger_entry` method - Can look up MPToken and MPTokenIssuance ledger entry types.
|
||||
|
||||
|
||||
### MPTokensV2
|
||||
[MPTokensV2]: #mptokensv2
|
||||
|
||||
| Amendment | MPTokensV2 |
|
||||
|:-------------|:-----------|
|
||||
| Amendment ID | BE2D87DF21B690ED1497B593FDC013CC04276302380B1BD50A033DCF8DEFB2EB |
|
||||
| Status | In Development |
|
||||
| Default Vote (Latest stable release) | No |
|
||||
| Pre-amendment functionality retired? | No |
|
||||
|
||||
Extends the XRPL's Decentralized Exchange to natively support Multi-Purpose Tokens (MPTs) as a tradeable asset class. MPTs can be paired with XRP, Trust Line tokens, or other MPTs across existing DEX transactions such as OfferCreate, Payment, AMM, and Checks.
|
||||
|
||||
For more details, see [XLS-82: MPT DEX Integration](https://opensource.ripple.com/docs/xls-82-mpt-dex).
|
||||
|
||||
|
||||
### MultiSign
|
||||
[MultiSign]: #multisign
|
||||
|
||||
@@ -1876,21 +1826,6 @@ Creates a structure for aggregating assets from multiple depositors. This is int
|
||||
|
||||
Specification: [XLS-65](https://github.com/XRPLF/XRPL-Standards/tree/master/XLS-0065-single-asset-vault).
|
||||
|
||||
### SmartEscrow
|
||||
[SmartEscrow]: #smartescrow
|
||||
|
||||
| Amendment | SmartEscrow |
|
||||
|:-------------|:--------------|
|
||||
| Amendment ID | 78ECD9CE17B0BF5B83BB3B275921FB5F5E0F672E9D24BD2E848B7C6277AE296E |
|
||||
| Status | In Development |
|
||||
| Default Vote (Latest stable release) | No |
|
||||
| Pre-amendment functionality retired? | No |
|
||||
|
||||
The Smart Escrows amendment introduces a new programmability layer to the XRPL, powered by a WebAssembly (WASM) engine. Developers can write custom functions that control when an escrow can be finished.
|
||||
|
||||
For more details, see [XLS-100: Smart Escrows](https://opensource.ripple.com/docs/xls-100-smart-escrows).
|
||||
|
||||
|
||||
### SortedDirectories
|
||||
[SortedDirectories]: #sorteddirectories
|
||||
|
||||
@@ -1906,21 +1841,6 @@ Sorts the entries in [DirectoryNode ledger objects](../docs/references/protocol/
|
||||
{% admonition type="danger" name="Warning" %}Older versions of `rippled` that do not know about this amendment may crash when they find a DirectoryNode sorted by the new rules. To avoid this problem, [upgrade](../docs/infrastructure/installation/index.md) to `rippled` version 0.80.0 or later.{% /admonition %}
|
||||
|
||||
|
||||
### Sponsor
|
||||
[Sponsor]: #sponsor
|
||||
|
||||
| Amendment | Sponsor |
|
||||
|:-------------|:--------|
|
||||
| Amendment ID | BE1F90581635DBCEBFC4678C4B54FEDDC1A17B50FD02CFE765A4132A342126AC |
|
||||
| Status | In Development |
|
||||
| Default Vote (Latest stable release) | No |
|
||||
| Pre-amendment functionality retired? | No |
|
||||
|
||||
The Sponsor amendment removes onboarding friction by allowing companies, token issuers, and other entities to subsidize transaction costs and reserve requirements for end users. Sponsors can co-sign transactions or pre-fund sponsorships, covering fees and reserves, while sponsees retain full control of their accounts and keys.
|
||||
|
||||
For more details, see [XLS-68: Sponsored Fees and Reserves](https://opensource.ripple.com/docs/xls-68-sponsored-fees-and-reserves).
|
||||
|
||||
|
||||
### SusPay
|
||||
[SusPay]: #suspay
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -320,3 +320,128 @@ main article .card-grid {
|
||||
margin-bottom: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tutorial cards */
|
||||
.page-tutorials .tutorial-cards {
|
||||
> div {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 280px;
|
||||
transition: all 0.35s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: none;
|
||||
background: transparent;
|
||||
padding: 1.5rem 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.circled-logo {
|
||||
margin-left: -10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: transparent;
|
||||
border-top: none;
|
||||
padding: 0;
|
||||
height: 40px;
|
||||
background-size: cover;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply colored footers to each card
|
||||
> div:nth-child(1) .card .card-footer {
|
||||
background-image: url("../img/cards/3-col-pink.svg");
|
||||
}
|
||||
> div:nth-child(2) .card .card-footer {
|
||||
background-image: url("../img/cards/3col-blue-light-blue.svg");
|
||||
}
|
||||
> div:nth-child(3) .card .card-footer {
|
||||
background-image: url("../img/cards/3-col-light-blue.svg");
|
||||
}
|
||||
> div:nth-child(4) .card .card-footer {
|
||||
background-image: url("../img/cards/3col-blue-green.svg");
|
||||
}
|
||||
> div:nth-child(5) .card .card-footer {
|
||||
background-image: url("../img/cards/3col-magenta.svg");
|
||||
}
|
||||
> div:nth-child(6) .card .card-footer {
|
||||
background-image: url("../img/cards/3-col-orange.svg");
|
||||
}
|
||||
}
|
||||
|
||||
.light .page-tutorials .tutorial-cards .circled-logo img[alt="HTTP / WebSocket"] {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
// TOC buttons for tutorials page
|
||||
.page-tutorials .page-toc.no-sideline {
|
||||
border-left: none;
|
||||
gap: 0.75rem;
|
||||
|
||||
li {
|
||||
a {
|
||||
border-radius: 100px;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
background-color: $gray-800;
|
||||
color: $gray-200;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: $blue-purple-500;
|
||||
color: $white;
|
||||
border-left: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.light .page-tutorials .page-toc.no-sideline {
|
||||
li a {
|
||||
background-color: $gray-200;
|
||||
color: $gray-800;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: $blue-purple-500;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,489 +0,0 @@
|
||||
// Tutorials landing page styles
|
||||
|
||||
// Card footer gradient images
|
||||
$card-footers: (
|
||||
"3-col-pink",
|
||||
"3col-blue-light-blue",
|
||||
"3-col-light-blue",
|
||||
"3col-blue-green",
|
||||
"3col-magenta",
|
||||
"3-col-orange"
|
||||
);
|
||||
|
||||
$whats-new-footers: (
|
||||
"3col-green-purple",
|
||||
"3col-purple-blue-green",
|
||||
"3col-green-blue"
|
||||
);
|
||||
|
||||
// Tutorial cards
|
||||
.page-tutorials .tutorial-cards {
|
||||
> div {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
transition: all 0.35s ease-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
border-bottom: none;
|
||||
background: transparent;
|
||||
padding: 1.5rem 1.5rem 0 2rem;
|
||||
}
|
||||
|
||||
.circled-logo {
|
||||
margin-left: -10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem 1.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
background-color: transparent;
|
||||
border-top: none;
|
||||
padding: 0;
|
||||
height: 40px;
|
||||
background-size: cover;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply colored footers to each card
|
||||
@for $i from 1 through length($card-footers) {
|
||||
> div:nth-child(#{$i}) .card .card-footer {
|
||||
background-image: url("../img/cards/#{nth($card-footers, $i)}.svg");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode: invert HTTP/WebSocket icon
|
||||
.light .page-tutorials .tutorial-cards .circled-logo img[alt="HTTP / WebSocket"] {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
// Contribution Card - community contribution with meta links
|
||||
.page-tutorials .tutorial-cards .contribution-card {
|
||||
.contribution-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.contribution-icon {
|
||||
background: rgba($blue-purple-500, 0.15);
|
||||
color: $blue-purple-300;
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.card-external-icon {
|
||||
font-size: 0.85rem;
|
||||
margin-left: 0.35rem;
|
||||
color: $gray-500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.card-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
margin-left: auto;
|
||||
margin-top: -4px;
|
||||
|
||||
.meta-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: $gray-300;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
.fa {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $blue-purple-300;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
color: $gray-500;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode: Contribution Card
|
||||
.light .page-tutorials .tutorial-cards .contribution-card {
|
||||
.contribution-icon {
|
||||
background: rgba($blue-purple-500, 0.1);
|
||||
color: $blue-purple-600;
|
||||
}
|
||||
|
||||
.card-meta-row {
|
||||
.meta-link {
|
||||
color: $gray-600;
|
||||
|
||||
&:hover {
|
||||
color: $blue-purple-600;
|
||||
}
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
color: $gray-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tutorial category section spacing
|
||||
.page-tutorials .category-section + .category-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
// Explore more link
|
||||
.page-tutorials .explore-more-wrapper {
|
||||
margin-top: -1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.explore-more-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 1.05rem;
|
||||
color: $blue-purple-300;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.light .page-tutorials .explore-more-wrapper .explore-more-link {
|
||||
color: $blue-purple-600;
|
||||
}
|
||||
|
||||
// TOC navigation buttons
|
||||
.page-tutorials .page-toc.no-sideline {
|
||||
border-left: none;
|
||||
gap: 0.75rem;
|
||||
|
||||
li {
|
||||
a {
|
||||
border-radius: 100px;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
background-color: $gray-800;
|
||||
color: $gray-200;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: $blue-purple-500;
|
||||
color: $white;
|
||||
border-left: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode: TOC buttons
|
||||
.light .page-tutorials .page-toc.no-sideline {
|
||||
li a {
|
||||
background-color: $gray-200;
|
||||
color: $gray-800;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: $blue-purple-500;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What's New section
|
||||
.whats-new-section {
|
||||
// Gradient underline on section title
|
||||
h3 {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
padding-bottom: 8px;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 60px;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, $blue-purple-500, $green-400);
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
// Different footer colors for What's New cards
|
||||
.tutorial-cards {
|
||||
@for $i from 1 through length($whats-new-footers) {
|
||||
> div:nth-child(#{$i}) .card .card-footer {
|
||||
background-image: url("../img/cards/#{nth($whats-new-footers, $i)}.svg");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Reference Card
|
||||
.page-tutorials .quick-ref-card {
|
||||
background: rgba($gray-800, 0.7);
|
||||
border: 1px solid $gray-700;
|
||||
border-left: 3px solid $blue-purple-500;
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.5rem;
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
margin-left: auto;
|
||||
max-width: 480px;
|
||||
|
||||
@media (max-width: 991px) {
|
||||
margin-left: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quick-ref-section {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.quick-ref-label {
|
||||
display: block;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: $blue-purple-300;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.quick-ref-group {
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-key {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: $gray-300;
|
||||
margin-bottom: 0.35rem;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-urls {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 0.25rem 0.75rem;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
|
||||
@media (max-width: 576px) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-protocol {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: $gray-500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.quick-ref-value {
|
||||
font-size: 0.75rem;
|
||||
color: $blue-purple-300;
|
||||
background: rgba($gray-900, 0.5);
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.quick-ref-value-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
|
||||
.quick-ref-value {
|
||||
background: rgba($gray-800, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
&.copied .quick-ref-value {
|
||||
background: rgba($green-500, 0.2);
|
||||
color: $green-400;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
font-size: 0.7rem;
|
||||
color: $green-400;
|
||||
min-width: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-link {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
color: $blue-purple-300;
|
||||
margin-top: 0.35rem;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-divider {
|
||||
height: 1px;
|
||||
background: $gray-700;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.quick-ref-faucet {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: $white;
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0;
|
||||
|
||||
&:hover {
|
||||
color: $blue-purple-300;
|
||||
}
|
||||
|
||||
.quick-ref-arrow {
|
||||
color: $blue-purple-400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light mode: Quick Reference Card
|
||||
.light .page-tutorials .quick-ref-card {
|
||||
background: rgba($white, 0.95);
|
||||
border-color: $gray-300;
|
||||
border-left: 3px solid $blue-purple-500;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.quick-ref-label {
|
||||
color: $blue-purple-600;
|
||||
}
|
||||
|
||||
.quick-ref-key {
|
||||
color: $gray-700;
|
||||
|
||||
strong {
|
||||
color: $gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-protocol {
|
||||
color: $gray-500;
|
||||
}
|
||||
|
||||
.quick-ref-value {
|
||||
color: $blue-purple-600;
|
||||
background: rgba($gray-300, 0.6);
|
||||
}
|
||||
|
||||
.quick-ref-value-btn {
|
||||
&:hover .quick-ref-value {
|
||||
background: rgba($gray-400, 0.6);
|
||||
}
|
||||
|
||||
&.copied .quick-ref-value {
|
||||
background: rgba($green-500, 0.15);
|
||||
color: $green-700;
|
||||
}
|
||||
|
||||
.copy-icon {
|
||||
color: $green-600;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-ref-link {
|
||||
color: $blue-purple-600;
|
||||
}
|
||||
|
||||
.quick-ref-divider {
|
||||
background: $gray-200;
|
||||
}
|
||||
|
||||
.quick-ref-faucet {
|
||||
color: $gray-900;
|
||||
|
||||
&:hover {
|
||||
color: $blue-purple-600;
|
||||
}
|
||||
|
||||
.quick-ref-arrow {
|
||||
color: $blue-purple-500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,6 @@ $line-height-base: 1.5;
|
||||
@import "_pages.scss";
|
||||
@import "_rpc-tool.scss";
|
||||
@import "_blog.scss";
|
||||
@import "_tutorials.scss";
|
||||
@import "_feedback.scss";
|
||||
@import "_video.scss";
|
||||
@import "_contribute.scss";
|
||||
|
||||
@@ -1,697 +0,0 @@
|
||||
"""
|
||||
Generate rippled release notes from GitHub commit history.
|
||||
|
||||
Usage (from repo root):
|
||||
python3 tools/generate-release-notes.py --from release-3.0 --to release-3.1 [--date 2026-03-24] [--output path/to/file.md]
|
||||
|
||||
Arguments:
|
||||
--from (required) Base ref — must match exact tag or branch to compare from.
|
||||
--to (required) Target ref — must match exact tag or branch to compare to.
|
||||
--date (optional) Release date in YYYY-MM-DD format. Defaults to today.
|
||||
--output (optional) Output file path. Defaults to blog/<year>/rippled-<version>.md.
|
||||
|
||||
Requires: gh CLI (authenticated)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
# Emails to exclude from credits (Ripple employees not using @ripple.com).
|
||||
# Commits from @ripple.com addresses are already filtered automatically.
|
||||
EXCLUDED_EMAILS = {
|
||||
"3maisons@gmail.com", # Luc des Trois Maisons
|
||||
"a1q123456@users.noreply.github.com", # Jingchen Wu
|
||||
"bthomee@users.noreply.github.com", # Bart Thomee
|
||||
"21219765+ckeshava@users.noreply.github.com", # Chenna Keshava B S
|
||||
"gregtatcam@users.noreply.github.com", # Gregory Tsipenyuk
|
||||
"kuzzz99@gmail.com", # Sergey Kuznetsov
|
||||
"legleux@users.noreply.github.com", # Michael Legleux
|
||||
"mathbunnyru@users.noreply.github.com", # Ayaz Salikhov
|
||||
"mvadari@gmail.com", # Mayukha Vadari
|
||||
"115580134+oleks-rip@users.noreply.github.com", # Oleksandr Pidskopnyi
|
||||
"3397372+pratikmankawde@users.noreply.github.com", # Pratik Mankawde
|
||||
"35279399+shawnxie999@users.noreply.github.com", # Shawn Xie
|
||||
"5780819+Tapanito@users.noreply.github.com", # Vito Tumas
|
||||
"13349202+vlntb@users.noreply.github.com", # Valentin Balaschenko
|
||||
"129996061+vvysokikh1@users.noreply.github.com", # Vladislav Vysokikh
|
||||
"vvysokikh@gmail.com", # Vladislav Vysokikh
|
||||
}
|
||||
|
||||
|
||||
# Pre-compiled patterns for skipping version commits
|
||||
SKIP_PATTERNS = [
|
||||
re.compile(r"^Set version to", re.IGNORECASE),
|
||||
re.compile(r"^Version \d", re.IGNORECASE),
|
||||
re.compile(r"bump version to", re.IGNORECASE),
|
||||
re.compile(r"^Merge tag ", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
# --- API helpers ---
|
||||
|
||||
def run_gh_rest(endpoint):
|
||||
"""Run a gh api REST command and return parsed JSON."""
|
||||
result = subprocess.run(
|
||||
["gh", "api", endpoint],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Error running gh api: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def run_gh_graphql(query):
|
||||
"""Run a gh api graphql command and return parsed JSON.
|
||||
Handles partial failures (e.g., missing PRs) by returning
|
||||
whatever data is available alongside errors.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["gh", "api", "graphql", "-f", f"query={query}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
print(f"Error running graphql: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def fetch_commit_files(sha):
|
||||
"""Fetch list of files changed in a commit via REST API.
|
||||
Returns empty list on failure instead of exiting.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["gh", "api", f"repos/XRPLF/rippled/commits/{sha}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" Warning: Could not fetch files for commit {sha[:7]}", file=sys.stderr)
|
||||
return []
|
||||
data = json.loads(result.stdout)
|
||||
return [f["filename"] for f in data.get("files", [])]
|
||||
|
||||
|
||||
# --- Data fetching ---
|
||||
|
||||
def fetch_version_info(ref):
|
||||
"""Fetch version string and version-setting commit info in a single GraphQL call.
|
||||
Returns (version_string, formatted_commit_block).
|
||||
"""
|
||||
data = run_gh_graphql(f"""
|
||||
{{
|
||||
repository(owner: "XRPLF", name: "rippled") {{
|
||||
file: object(expression: "{ref}:src/libxrpl/protocol/BuildInfo.cpp") {{
|
||||
... on Blob {{ text }}
|
||||
}}
|
||||
ref: object(expression: "{ref}") {{
|
||||
... on Commit {{
|
||||
history(first: 1, path: "src/libxrpl/protocol/BuildInfo.cpp") {{
|
||||
nodes {{
|
||||
oid
|
||||
message
|
||||
author {{
|
||||
name
|
||||
email
|
||||
date
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
repo = data.get("data", {}).get("repository", {})
|
||||
|
||||
# Extract version string from BuildInfo.cpp
|
||||
file_text = (repo.get("file") or {}).get("text", "")
|
||||
match = re.search(r'versionString\s*=\s*"([^"]+)"', file_text)
|
||||
if not match:
|
||||
print("Warning: Could not find versionString in BuildInfo.cpp. Using placeholder.", file=sys.stderr)
|
||||
version = match.group(1) if match else "TODO"
|
||||
|
||||
# Extract version commit info
|
||||
nodes = (repo.get("ref") or {}).get("history", {}).get("nodes", [])
|
||||
if not nodes:
|
||||
commit_block = "commit TODO\nAuthor: TODO\nDate: TODO\n\n Set version to TODO"
|
||||
else:
|
||||
commit = nodes[0]
|
||||
raw_date = commit["author"]["date"]
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw_date)
|
||||
formatted_date = dt.strftime("%a %b %-d %H:%M:%S %Y %z")
|
||||
except ValueError:
|
||||
formatted_date = raw_date
|
||||
|
||||
name = commit["author"]["name"]
|
||||
email = commit["author"]["email"]
|
||||
sha = commit["oid"]
|
||||
message = commit["message"].split("\n")[0]
|
||||
commit_block = f"commit {sha}\nAuthor: {name} <{email}>\nDate: {formatted_date}\n\n {message}"
|
||||
|
||||
return version, commit_block
|
||||
|
||||
|
||||
def fetch_commits(from_ref, to_ref):
|
||||
"""Fetch all commits between two refs using the GitHub compare API."""
|
||||
commits = []
|
||||
page = 1
|
||||
while True:
|
||||
data = run_gh_rest(
|
||||
f"repos/XRPLF/rippled/compare/{from_ref}...{to_ref}?per_page=250&page={page}"
|
||||
)
|
||||
batch = data.get("commits", [])
|
||||
commits.extend(batch)
|
||||
if len(batch) < 250:
|
||||
break
|
||||
page += 1
|
||||
return commits
|
||||
|
||||
|
||||
def parse_features_macro(text):
|
||||
"""Parse features.macro into {amendment_name: status_string} dict."""
|
||||
results = {}
|
||||
for match in re.finditer(
|
||||
r'XRPL_(FEATURE|FIX)\s*\(\s*(\w+)\s*,\s*Supported::(\w+)\s*,\s*VoteBehavior::(\w+)', text):
|
||||
macro_type, name, supported, vote = match.groups()
|
||||
key = f"fix{name}" if macro_type == "FIX" else name
|
||||
results[key] = f"{supported}, {vote}"
|
||||
for match in re.finditer(r'XRPL_RETIRE(?:_(FEATURE|FIX))?\s*\(\s*(\w+)\s*\)', text):
|
||||
macro_type, name = match.groups()
|
||||
key = f"fix{name}" if macro_type == "FIX" else name
|
||||
results[key] = "retired"
|
||||
return results
|
||||
|
||||
|
||||
def fetch_amendment_diff(from_ref, to_ref):
|
||||
"""Compare features.macro between two refs to find amendment changes.
|
||||
Returns (changes, unchanged) where:
|
||||
- changes: {name: True/False} for amendments that changed status
|
||||
- unchanged: {name: True/False} for amendments with no status change
|
||||
True = include; False = exclude
|
||||
"""
|
||||
macro_path = "repos/XRPLF/rippled/contents/include/xrpl/protocol/detail/features.macro"
|
||||
|
||||
from_data = run_gh_rest(f"{macro_path}?ref={from_ref}")
|
||||
from_text = base64.b64decode(from_data["content"]).decode()
|
||||
from_amendments = parse_features_macro(from_text)
|
||||
|
||||
to_data = run_gh_rest(f"{macro_path}?ref={to_ref}")
|
||||
to_text = base64.b64decode(to_data["content"]).decode()
|
||||
to_amendments = parse_features_macro(to_text)
|
||||
|
||||
changes = {}
|
||||
for name, to_status in to_amendments.items():
|
||||
if name not in from_amendments:
|
||||
# New amendment — include only if Supported::yes
|
||||
changes[name] = to_status.startswith("yes")
|
||||
elif from_amendments[name] != to_status:
|
||||
# Include if either old or new status involves yes (voting-ready)
|
||||
from_status = from_amendments[name]
|
||||
changes[name] = from_status.startswith("yes") or to_status.startswith("yes")
|
||||
|
||||
# Removed amendments — include only if they were Supported::yes
|
||||
for name in from_amendments:
|
||||
if name not in to_amendments:
|
||||
changes[name] = from_amendments[name].startswith("yes")
|
||||
|
||||
# Unchanged amendments to also exclude (unreleased work)
|
||||
unchanged = sorted(
|
||||
name for name, to_status in to_amendments.items()
|
||||
if name not in changes and to_status != "retired" and not to_status.startswith("yes")
|
||||
)
|
||||
|
||||
return changes, unchanged
|
||||
|
||||
|
||||
def fetch_prs_graphql(pr_numbers):
|
||||
"""Fetch PR details in batches using GitHub GraphQL API.
|
||||
Falls back to issue lookup for numbers that aren't PRs.
|
||||
Returns a dict of {number: {title, body, labels, files, type}}.
|
||||
"""
|
||||
results = {}
|
||||
missing = []
|
||||
batch_size = 50
|
||||
pr_list = list(pr_numbers)
|
||||
|
||||
# Fetch PRs
|
||||
for i in range(0, len(pr_list), batch_size):
|
||||
batch = pr_list[i:i + batch_size]
|
||||
|
||||
fragments = []
|
||||
for pr_num in batch:
|
||||
fragments.append(f"""
|
||||
pr{pr_num}: pullRequest(number: {pr_num}) {{
|
||||
title
|
||||
body
|
||||
labels(first: 10) {{
|
||||
nodes {{ name }}
|
||||
}}
|
||||
files(first: 100) {{
|
||||
nodes {{ path }}
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
|
||||
query = f"""
|
||||
{{
|
||||
repository(owner: "XRPLF", name: "rippled") {{
|
||||
{"".join(fragments)}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
data = run_gh_graphql(query)
|
||||
repo_data = data.get("data", {}).get("repository", {})
|
||||
|
||||
for alias, pr_data in repo_data.items():
|
||||
pr_num = int(alias.removeprefix("pr"))
|
||||
if pr_data:
|
||||
results[pr_num] = {
|
||||
"title": pr_data["title"],
|
||||
"body": clean_pr_body(pr_data.get("body") or ""),
|
||||
"labels": [l["name"] for l in pr_data.get("labels", {}).get("nodes", [])],
|
||||
"files": [f["path"] for f in pr_data.get("files", {}).get("nodes", [])],
|
||||
"type": "pull",
|
||||
}
|
||||
else:
|
||||
missing.append(pr_num)
|
||||
|
||||
print(f" Fetched {min(i + batch_size, len(pr_list))}/{len(pr_list)} PRs...")
|
||||
|
||||
# Fetch missing numbers as issues
|
||||
if missing:
|
||||
print(f" Looking up {len(missing)} missing PR numbers as Issues...")
|
||||
for i in range(0, len(missing), batch_size):
|
||||
batch = missing[i:i + batch_size]
|
||||
|
||||
fragments = []
|
||||
for num in batch:
|
||||
fragments.append(f"""
|
||||
issue{num}: issue(number: {num}) {{
|
||||
title
|
||||
body
|
||||
labels(first: 10) {{
|
||||
nodes {{ name }}
|
||||
}}
|
||||
}}
|
||||
""")
|
||||
|
||||
query = f"""
|
||||
{{
|
||||
repository(owner: "XRPLF", name: "rippled") {{
|
||||
{"".join(fragments)}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
|
||||
data = run_gh_graphql(query)
|
||||
repo_data = data.get("data", {}).get("repository", {})
|
||||
|
||||
for alias, issue_data in repo_data.items():
|
||||
if issue_data:
|
||||
num = int(alias.removeprefix("issue"))
|
||||
results[num] = {
|
||||
"title": issue_data["title"],
|
||||
"body": clean_pr_body(issue_data.get("body") or ""),
|
||||
"labels": [l["name"] for l in issue_data.get("labels", {}).get("nodes", [])],
|
||||
"type": "issues",
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# --- Utilities ---
|
||||
|
||||
def clean_pr_body(text):
|
||||
"""Strip HTML comments and PR template boilerplate from body text."""
|
||||
# Remove HTML comments
|
||||
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
|
||||
# Remove unchecked checkbox lines, keep checked ones
|
||||
text = re.sub(r"^- \[ \] .+$", "", text, flags=re.MULTILINE)
|
||||
# Remove all markdown headings
|
||||
text = re.sub(r"^#{1,6} .+$", "", text, flags=re.MULTILINE)
|
||||
# Convert bare GitHub URLs to markdown links
|
||||
text = re.sub(r"(?<!\()https://github\.com/XRPLF/rippled/(pull|issues)/(\d+)(#[^\s)]*)?", r"[#\2](https://github.com/XRPLF/rippled/\1/\2\3)", text)
|
||||
# Convert remaining bare PR/issue references (#1234) to full GitHub links
|
||||
text = re.sub(r"(?<!\[)#(\d+)(?!\])", r"[#\1](https://github.com/XRPLF/rippled/pull/\1)", text)
|
||||
# Collapse multiple blank lines into one
|
||||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def extract_pr_number(commit_message):
|
||||
"""Extract PR number from commit message like 'Title (#1234)'."""
|
||||
match = re.search(r"#(\d+)", commit_message)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def should_skip(title):
|
||||
"""Check if a commit should be skipped."""
|
||||
return any(pattern.search(title) for pattern in SKIP_PATTERNS)
|
||||
|
||||
|
||||
def is_amendment(files):
|
||||
"""Check if any file in the list is features.macro."""
|
||||
return any("features.macro" in f for f in files)
|
||||
|
||||
|
||||
# --- Formatting ---
|
||||
|
||||
def format_commit_entry(sha, title, body="", files=None):
|
||||
"""Format an entry linked to a commit (no PR/Issue found)."""
|
||||
short_sha = sha[:7]
|
||||
url = f"https://github.com/XRPLF/rippled/commit/{sha}"
|
||||
parts = [
|
||||
f"- **{title.strip()}**",
|
||||
f" - Link: [{short_sha}]({url})",
|
||||
]
|
||||
if files:
|
||||
parts.append(f" - Files: {', '.join(files)}")
|
||||
if body:
|
||||
desc = re.sub(r"\s+", " ", clean_pr_body(body)).strip()
|
||||
if desc:
|
||||
parts.append(f" - Description: {desc}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def format_uncategorized_entry(pr_number, title, labels, body, files=None, link_type="pull"):
|
||||
"""Format an uncategorized entry with full context for AI sorting."""
|
||||
url = f"https://github.com/XRPLF/rippled/{link_type}/{pr_number}"
|
||||
parts = [
|
||||
f"- **{title.strip()}**",
|
||||
f" - Link: [#{pr_number}]({url})",
|
||||
]
|
||||
if labels:
|
||||
parts.append(f" - Labels: {', '.join(labels)}")
|
||||
if files:
|
||||
parts.append(f" - Files: {', '.join(files)}")
|
||||
if body:
|
||||
# Collapse to single line to prevent markdown formatting conflicts
|
||||
desc = re.sub(r"\s+", " ", body).strip()
|
||||
if desc:
|
||||
parts.append(f" - Description: {desc}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def generate_markdown(version, release_date, amendment_diff, amendment_unchanged, amendment_entries, entries, authors, version_commit):
|
||||
"""Generate the full markdown release notes."""
|
||||
year = release_date.split("-")[0]
|
||||
parts = []
|
||||
|
||||
parts.append(f"""---
|
||||
category: {year}
|
||||
date: "{release_date}"
|
||||
template: '../../@theme/templates/blogpost'
|
||||
seo:
|
||||
title: Introducing XRP Ledger version {version}
|
||||
description: rippled version {version} is now available.
|
||||
labels:
|
||||
- rippled Release Notes
|
||||
markdown:
|
||||
editPage:
|
||||
hide: true
|
||||
---
|
||||
# Introducing XRP Ledger version {version}
|
||||
|
||||
Version {version} of `rippled`, the reference server implementation of the XRP Ledger protocol, is now available.
|
||||
|
||||
|
||||
## Action Required
|
||||
|
||||
If you run an XRP Ledger server, upgrade to version {version} as soon as possible to ensure service continuity.
|
||||
|
||||
|
||||
## Install / Upgrade
|
||||
|
||||
On supported platforms, see the [instructions on installing or updating `rippled`](../../docs/infrastructure/installation/index.md).
|
||||
|
||||
| Package | SHA-256 |
|
||||
|:--------|:--------|
|
||||
| [RPM for Red Hat / CentOS (x86-64)](https://repos.ripple.com/repos/rippled-rpm/stable/rippled-{version}-1.el9.x86_64.rpm) | `TODO` |
|
||||
| [DEB for Ubuntu / Debian (x86-64)](https://repos.ripple.com/repos/rippled-deb/pool/stable/rippled_{version}-1_amd64.deb) | `TODO` |
|
||||
|
||||
For other platforms, please [build from source](https://github.com/XRPLF/rippled/blob/master/BUILD.md). The most recent commit in the git log should be the change setting the version:
|
||||
|
||||
```text
|
||||
{version_commit}
|
||||
```
|
||||
|
||||
|
||||
## Full Changelog
|
||||
""")
|
||||
|
||||
# Amendments section (auto-sorted by features.macro detection with diff guidance for AI)
|
||||
parts.append("\n### Amendments\n")
|
||||
if amendment_diff or amendment_unchanged:
|
||||
included = sorted(name for name, include in amendment_diff.items() if include)
|
||||
excluded = sorted(name for name, include in amendment_diff.items() if not include)
|
||||
comment_lines = ["<!-- Amendment sorting instructions. Remove this comment after sorting."]
|
||||
if included:
|
||||
comment_lines.append(f"Include: {', '.join(included)}")
|
||||
if excluded:
|
||||
comment_lines.append(f"Exclude: {', '.join(excluded)}")
|
||||
if amendment_unchanged:
|
||||
comment_lines.append(f"Other amendments not part of this release: {', '.join(amendment_unchanged)}")
|
||||
comment_lines.append("-->")
|
||||
parts.append("\n".join(comment_lines) + "\n")
|
||||
for entry in amendment_entries:
|
||||
parts.append(entry)
|
||||
|
||||
# Remaining empty subsection headings for manual/AI sorting
|
||||
sections = [
|
||||
"Features", "Breaking Changes", "Bug Fixes",
|
||||
"Refactors", "Documentation", "Testing", "CI/Build",
|
||||
]
|
||||
for section in sections:
|
||||
parts.append(f"\n### {section}\n")
|
||||
|
||||
# Credits
|
||||
parts.append("\n\n## Credits\n")
|
||||
if authors:
|
||||
parts.append("The following RippleX teams and GitHub users contributed to this release:\n")
|
||||
else:
|
||||
parts.append("The following RippleX teams contributed to this release:\n")
|
||||
parts.append("- RippleX Engineering")
|
||||
parts.append("- RippleX Docs")
|
||||
parts.append("- RippleX Product")
|
||||
for author in sorted(authors):
|
||||
parts.append(f"- {author}")
|
||||
|
||||
parts.append("""
|
||||
|
||||
## Bug Bounties and Responsible Disclosures
|
||||
|
||||
We welcome reviews of the `rippled` code and urge researchers to responsibly disclose any issues they may find.
|
||||
|
||||
For more information, see:
|
||||
|
||||
- [Ripple's Bug Bounty Program](https://ripple.com/legal/bug-bounty/)
|
||||
- [`rippled` Security Policy](https://github.com/XRPLF/rippled/blob/develop/SECURITY.md)
|
||||
""")
|
||||
|
||||
# Unsorted entries with full context (after all published sections)
|
||||
parts.append("<!-- Sort the entries below into the Full Changelog subsections. Remove this comment after sorting. -->\n")
|
||||
for entry in entries:
|
||||
parts.append(entry)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate rippled release notes")
|
||||
parser.add_argument("--from", dest="from_ref", required=True, help="Base ref (tag or branch)")
|
||||
parser.add_argument("--to", dest="to_ref", required=True, help="Target ref (tag or branch)")
|
||||
parser.add_argument("--date", help="Release date (YYYY-MM-DD). Defaults to today.")
|
||||
parser.add_argument("--output", help="Output file path (default: blog/<year>/rippled-<version>.md)")
|
||||
args = parser.parse_args()
|
||||
|
||||
args.date = args.date or date.today().isoformat()
|
||||
try:
|
||||
date.fromisoformat(args.date)
|
||||
except ValueError:
|
||||
print(f"Error: Invalid date format '{args.date}'. Use YYYY-MM-DD.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Fetching version info from {args.to_ref}...")
|
||||
version, version_commit = fetch_version_info(args.to_ref)
|
||||
print(f"Version: {version}")
|
||||
|
||||
year = args.date.split("-")[0]
|
||||
output_path = args.output or f"blog/{year}/rippled-{version}.md"
|
||||
|
||||
print(f"Fetching commits: {args.from_ref}...{args.to_ref}")
|
||||
commits = fetch_commits(args.from_ref, args.to_ref)
|
||||
print(f"Found {len(commits)} commits")
|
||||
|
||||
# Extract unique PR (in rare cases Issues) numbers and track authors
|
||||
pr_numbers = {}
|
||||
pr_shas = {} # PR/issue number → commit SHA (for file lookups on Issues)
|
||||
pr_bodies = {} # PR/issue number → commit body (for fallback descriptions)
|
||||
orphan_commits = [] # Commits with no PR/Issues link
|
||||
authors = set()
|
||||
|
||||
for commit in commits:
|
||||
full_message = commit["commit"]["message"]
|
||||
message = full_message.split("\n")[0]
|
||||
body = "\n".join(full_message.split("\n")[1:]).strip()
|
||||
sha = commit["sha"]
|
||||
author = commit["commit"]["author"]["name"]
|
||||
email = commit["commit"]["author"].get("email", "")
|
||||
|
||||
# Skip Ripple employees from credits
|
||||
login = (commit.get("author") or {}).get("login")
|
||||
if not email.lower().endswith("@ripple.com") and email not in EXCLUDED_EMAILS:
|
||||
if login:
|
||||
authors.add(f"@{login}")
|
||||
else:
|
||||
authors.add(author)
|
||||
|
||||
if should_skip(message):
|
||||
continue
|
||||
|
||||
pr_number = extract_pr_number(message)
|
||||
if pr_number:
|
||||
pr_numbers[pr_number] = message
|
||||
pr_shas[pr_number] = sha
|
||||
pr_bodies[pr_number] = body
|
||||
else:
|
||||
orphan_commits.append({"sha": sha, "message": message, "body": body})
|
||||
|
||||
print(f"Unique PRs after filtering: {len(pr_numbers)}")
|
||||
if orphan_commits:
|
||||
print(f"Commits without PR or Issue linked: {len(orphan_commits)}")
|
||||
# Fetch amendment diff between refs
|
||||
print(f"Comparing features.macro between {args.from_ref} and {args.to_ref}...")
|
||||
amendment_diff, amendment_unchanged = fetch_amendment_diff(args.from_ref, args.to_ref)
|
||||
if amendment_diff:
|
||||
for name, include in sorted(amendment_diff.items()):
|
||||
status = "include" if include else "exclude"
|
||||
print(f" Amendment {name}: {status}")
|
||||
else:
|
||||
print(" No amendment changes detected")
|
||||
|
||||
print(f"Building changelog entries...")
|
||||
|
||||
# Fetch all PR details in batches via GraphQL
|
||||
pr_details = fetch_prs_graphql(list(pr_numbers.keys()))
|
||||
|
||||
# Build entries, sorting amendments automatically
|
||||
amendment_entries = []
|
||||
entries = []
|
||||
for pr_number, commit_msg in pr_numbers.items():
|
||||
pr_data = pr_details.get(pr_number)
|
||||
|
||||
if pr_data:
|
||||
title = pr_data["title"]
|
||||
body = pr_data.get("body", "")
|
||||
labels = pr_data.get("labels", [])
|
||||
files = pr_data.get("files", [])
|
||||
link_type = pr_data.get("type", "pull")
|
||||
|
||||
# For issues (no files from GraphQL), fetch files from the commit
|
||||
if not files and pr_number in pr_shas:
|
||||
print(f" Building entry for Issue #{pr_number} via commit...")
|
||||
files = fetch_commit_files(pr_shas[pr_number])
|
||||
|
||||
if is_amendment(files) and amendment_diff:
|
||||
# Amendment entry — add to amendments section (AI will sort further)
|
||||
entry = format_uncategorized_entry(pr_number, title, labels, body, link_type=link_type)
|
||||
amendment_entries.append(entry)
|
||||
else:
|
||||
entry = format_uncategorized_entry(pr_number, title, labels, body, files, link_type)
|
||||
entries.append(entry)
|
||||
else:
|
||||
# Fallback to commit lookup for invalid PR and Issues link
|
||||
sha = pr_shas[pr_number]
|
||||
print(f" #{pr_number} not found as PR or Issue, building from commit {sha[:7]}...")
|
||||
files = fetch_commit_files(sha)
|
||||
if is_amendment(files) and amendment_diff:
|
||||
entry = format_commit_entry(sha, commit_msg, pr_bodies[pr_number])
|
||||
amendment_entries.append(entry)
|
||||
else:
|
||||
entry = format_commit_entry(sha, commit_msg, pr_bodies[pr_number], files)
|
||||
entries.append(entry)
|
||||
|
||||
# Build entries for orphan commits (no PR/Issue linked)
|
||||
for orphan in orphan_commits:
|
||||
sha = orphan["sha"]
|
||||
print(f" Building commit-only entry for {sha[:7]}...")
|
||||
files = fetch_commit_files(sha)
|
||||
if is_amendment(files) and amendment_diff:
|
||||
entry = format_commit_entry(sha, orphan["message"], orphan["body"])
|
||||
amendment_entries.append(entry)
|
||||
else:
|
||||
entry = format_commit_entry(sha, orphan["message"], orphan["body"], files)
|
||||
entries.append(entry)
|
||||
|
||||
# Generate markdown
|
||||
markdown = generate_markdown(version, args.date, amendment_diff, amendment_unchanged, amendment_entries, entries, authors, version_commit)
|
||||
|
||||
# Write output
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
f.write(markdown)
|
||||
|
||||
print(f"\nRelease notes written to: {output_path}")
|
||||
|
||||
# Update blog/sidebars.yaml
|
||||
sidebars_path = "blog/sidebars.yaml"
|
||||
# Derive sidebar path and year from actual output path
|
||||
relative_path = output_path.removeprefix("blog/")
|
||||
sidebar_year = relative_path.split("/")[0]
|
||||
new_entry = f" - page: {relative_path}"
|
||||
try:
|
||||
with open(sidebars_path, "r") as f:
|
||||
sidebar_content = f.read()
|
||||
|
||||
if relative_path in sidebar_content:
|
||||
print(f"{sidebars_path} already contains {relative_path}")
|
||||
else:
|
||||
# Find the year group and insert at the top of its items
|
||||
year_marker = f" - group: '{sidebar_year}'"
|
||||
if year_marker not in sidebar_content:
|
||||
# Year group doesn't exist — find the right chronological position
|
||||
new_group = f" - group: '{sidebar_year}'\n expanded: false\n items:\n{new_entry}\n"
|
||||
# Find all existing year groups and insert before the first one with a smaller year
|
||||
year_groups = list(re.finditer(r" - group: '(\d{4})'", sidebar_content))
|
||||
insert_pos = None
|
||||
for match in year_groups:
|
||||
existing_year = match.group(1)
|
||||
if int(sidebar_year) > int(existing_year):
|
||||
insert_pos = match.start()
|
||||
break
|
||||
if insert_pos is not None:
|
||||
sidebar_content = sidebar_content[:insert_pos] + new_group + sidebar_content[insert_pos:]
|
||||
else:
|
||||
# New year is older than all existing — append at the end
|
||||
sidebar_content = sidebar_content.rstrip() + "\n" + new_group
|
||||
else:
|
||||
# Insert after the year group's "items:" line
|
||||
year_idx = sidebar_content.index(year_marker)
|
||||
items_idx = sidebar_content.index(" items:", year_idx)
|
||||
insert_pos = items_idx + len(" items:\n")
|
||||
sidebar_content = sidebar_content[:insert_pos] + new_entry + "\n" + sidebar_content[insert_pos:]
|
||||
|
||||
with open(sidebars_path, "w") as f:
|
||||
f.write(sidebar_content)
|
||||
print(f"Added {relative_path} to {sidebars_path}")
|
||||
except FileNotFoundError:
|
||||
print(f"Warning: {sidebars_path} not found, skipping sidebar update", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user