blog infra
This commit is contained in:
@@ -2,6 +2,10 @@ import { defineConfig } from 'vitepress';
|
||||
import { withMermaid } from 'vitepress-plugin-mermaid';
|
||||
import typedocSidebar from '../api/typedoc-sidebar.json';
|
||||
|
||||
// Node imports for auto blog sidebar (works at docs/src/.vitepress)
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
// Special cases map for acronyms and exceptions
|
||||
const SPECIAL_CASES = {
|
||||
cryptocompare: 'CryptoCompare',
|
||||
@@ -66,202 +70,247 @@ function convertNavToPascalCase(nav: Array<any>) {
|
||||
});
|
||||
}
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default withMermaid({
|
||||
outDir: '../dist',
|
||||
title: 'XO Stack',
|
||||
description:
|
||||
'XO Stack: Building smart applications on the Bitcoin Cash Network',
|
||||
head: [
|
||||
// Favicon.
|
||||
['link', { rel: 'icon', href: '/favicon.png' }],
|
||||
// Basic OpenGraph tags
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:title', content: 'XO Stack' }],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'og:description',
|
||||
content:
|
||||
'XO Stack: Building smart applications on the Bitcoin Cash Network',
|
||||
},
|
||||
],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'og:image',
|
||||
content: 'https://stack.xo.cash/stack-opengraph.jpg',
|
||||
},
|
||||
],
|
||||
['meta', { property: 'og:url', content: 'https://stack.xo.cash/' }],
|
||||
function blogSidebar() {
|
||||
const blogDir = path.resolve(__dirname, '../blog');
|
||||
|
||||
// Optional additional tags
|
||||
['meta', { property: 'og:site_name', content: 'XO Stack' }],
|
||||
if (!fs.existsSync(blogDir)) {
|
||||
return [{ text: 'XO Blog', items: [] }];
|
||||
}
|
||||
|
||||
// Twitter Card tags (optional but recommended)
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
['meta', { name: 'twitter:site', content: '@yourtwitterhandle' }],
|
||||
],
|
||||
themeConfig: {
|
||||
// Set the logo
|
||||
logo: {
|
||||
light: '/logo-light.svg',
|
||||
dark: '/logo-dark.svg',
|
||||
const files = fs
|
||||
.readdirSync(blogDir)
|
||||
.filter((f) => f.endsWith('.md') && f !== 'index.md')
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
return [
|
||||
{
|
||||
text: 'XO Blog',
|
||||
items: files.map((filePath) => {
|
||||
const slug = path.parse(filePath).name;
|
||||
|
||||
// Try to extract YYYY-MM-DD from the filename
|
||||
const match = slug.match(/^(\d{4}-\d{2}-\d{2})-(.*)$/);
|
||||
let date = '';
|
||||
let titleSlug = slug;
|
||||
|
||||
if (match) {
|
||||
date = match[1]; // "2025-09-19"
|
||||
titleSlug = match[2]; // "first-post"
|
||||
}
|
||||
|
||||
const title = titleSlug
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/^\w/, (c) => c.toUpperCase());
|
||||
|
||||
return {
|
||||
// Use HTML for linebreak
|
||||
text: date ? `${date}<br/>${title}` : title,
|
||||
link: `/blog/${slug}`,
|
||||
};
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Enable search functionality.
|
||||
// TODO: Re-enable once we release documentation.
|
||||
/*
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default withMermaid(
|
||||
defineConfig({
|
||||
outDir: '../dist',
|
||||
title: 'XO Stack',
|
||||
description:
|
||||
'XO Stack: Building smart applications on the Bitcoin Cash Network',
|
||||
head: [
|
||||
// Favicon.
|
||||
['link', { rel: 'icon', href: '/favicon.png' }],
|
||||
// Basic OpenGraph tags
|
||||
['meta', { property: 'og:type', content: 'website' }],
|
||||
['meta', { property: 'og:title', content: 'XO Stack' }],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'og:description',
|
||||
content:
|
||||
'XO Stack: Building smart applications on the Bitcoin Cash Network',
|
||||
},
|
||||
],
|
||||
[
|
||||
'meta',
|
||||
{
|
||||
property: 'og:image',
|
||||
content: 'https://stack.xo.cash/stack-opengraph.jpg',
|
||||
},
|
||||
],
|
||||
['meta', { property: 'og:url', content: 'https://stack.xo.cash/' }],
|
||||
|
||||
// Optional additional tags
|
||||
['meta', { property: 'og:site_name', content: 'XO Stack' }],
|
||||
|
||||
// Twitter Card tags (optional but recommended)
|
||||
['meta', { name: 'twitter:card', content: 'summary_large_image' }],
|
||||
['meta', { name: 'twitter:site', content: '@yourtwitterhandle' }],
|
||||
],
|
||||
themeConfig: {
|
||||
// Set the logo
|
||||
logo: {
|
||||
light: '/logo-light.svg',
|
||||
dark: '/logo-dark.svg',
|
||||
},
|
||||
|
||||
// Enable search functionality.
|
||||
// TODO: Re-enable once we release documentation.
|
||||
/*
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
*/
|
||||
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
// TODO: Re-enable once we release documentation
|
||||
// { text: 'Documentation', link: '/quick-start' },
|
||||
{ text: 'Tools', link: '/tools/primitives' },
|
||||
{ text: 'Playground', link: '/playground' },
|
||||
],
|
||||
|
||||
sidebar: {
|
||||
// Default Side Menu.
|
||||
'/': [
|
||||
{
|
||||
text: 'Guide',
|
||||
items: [
|
||||
{ text: 'Quick Start', link: '/quick-start' },
|
||||
{ text: 'Architecture', link: '/architecture' },
|
||||
{ text: 'Best Practices', link: '/api-examples' },
|
||||
{ text: 'Contributing', link: '/contributing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Frameworks',
|
||||
items: [
|
||||
{ text: 'Vue', link: '/frameworks/vue' },
|
||||
{ text: 'React', link: '/frameworks/react' },
|
||||
{ text: 'Svelte', link: '/frameworks/svelte' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'API',
|
||||
items: convertNavToPascalCase(typedocSidebar),
|
||||
},
|
||||
// https://vitepress.dev/reference/default-theme-config
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
// TODO: Re-enable once we release documentation
|
||||
// { text: 'Documentation', link: '/quick-start' },
|
||||
{ text: 'Blog', link: '/blog' },
|
||||
{ text: 'Tools', link: '/tools/primitives' },
|
||||
{ text: 'Playground', link: '/playground' },
|
||||
],
|
||||
// Side-menu for all routes under /tools/
|
||||
'/tools/': [
|
||||
{
|
||||
text: 'XO Tools',
|
||||
items: [
|
||||
{
|
||||
text: 'Primitives',
|
||||
link: '/tools/primitives',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Address', link: '/tools/primitives#address' },
|
||||
{
|
||||
text: 'Block Header',
|
||||
link: '/tools/primitives#block-header',
|
||||
},
|
||||
{ text: 'Bytes', link: '/tools/primitives#bytes' },
|
||||
{
|
||||
text: 'Encrypter',
|
||||
link: '/tools/primitives#encrypter',
|
||||
},
|
||||
{
|
||||
text: 'Hasher',
|
||||
link: '/tools/primitives#hasher',
|
||||
},
|
||||
{
|
||||
text: 'HD Private Node',
|
||||
link: '/tools/primitives#hd-private-node',
|
||||
},
|
||||
{
|
||||
text: 'HD Public Node',
|
||||
link: '/tools/primitives#hd-public-node',
|
||||
},
|
||||
{
|
||||
text: 'Mnemonic',
|
||||
link: '/tools/primitives#mnemonic',
|
||||
},
|
||||
{
|
||||
text: 'Private Key',
|
||||
link: '/tools/primitives#private-key',
|
||||
},
|
||||
{
|
||||
text: 'Public Key',
|
||||
link: '/tools/primitives#public-key',
|
||||
},
|
||||
{
|
||||
text: 'Satoshis',
|
||||
link: '/tools/primitives#satoshis',
|
||||
},
|
||||
{
|
||||
text: 'Script',
|
||||
link: '/tools/primitives#script',
|
||||
},
|
||||
{
|
||||
text: 'Transaction',
|
||||
link: '/tools/primitives#transaction',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Stack',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: 'Blockchain',
|
||||
link: '/tools/stack/blockchain',
|
||||
items: [
|
||||
{
|
||||
text: 'Electrum',
|
||||
link: '/tools/stack/blockchain#electrum',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
sidebar: {
|
||||
// Default Side Menu.
|
||||
'/': [
|
||||
{
|
||||
text: 'Guide',
|
||||
items: [
|
||||
{ text: 'Quick Start', link: '/quick-start' },
|
||||
{ text: 'Architecture', link: '/architecture' },
|
||||
{ text: 'Best Practices', link: '/api-examples' },
|
||||
{ text: 'Contributing', link: '/contributing' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Frameworks',
|
||||
items: [
|
||||
{ text: 'Vue', link: '/frameworks/vue' },
|
||||
{ text: 'React', link: '/frameworks/react' },
|
||||
{ text: 'Svelte', link: '/frameworks/svelte' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'API',
|
||||
items: convertNavToPascalCase(typedocSidebar),
|
||||
},
|
||||
],
|
||||
// Side-menu for all routes under /tools/
|
||||
'/tools/': [
|
||||
{
|
||||
text: 'XO Tools',
|
||||
items: [
|
||||
{
|
||||
text: 'Primitives',
|
||||
link: '/tools/primitives',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{ text: 'Address', link: '/tools/primitives#address' },
|
||||
{
|
||||
text: 'Block Header',
|
||||
link: '/tools/primitives#block-header',
|
||||
},
|
||||
{ text: 'Bytes', link: '/tools/primitives#bytes' },
|
||||
{
|
||||
text: 'Encrypter',
|
||||
link: '/tools/primitives#encrypter',
|
||||
},
|
||||
{
|
||||
text: 'Hasher',
|
||||
link: '/tools/primitives#hasher',
|
||||
},
|
||||
{
|
||||
text: 'HD Private Node',
|
||||
link: '/tools/primitives#hd-private-node',
|
||||
},
|
||||
{
|
||||
text: 'HD Public Node',
|
||||
link: '/tools/primitives#hd-public-node',
|
||||
},
|
||||
{
|
||||
text: 'Mnemonic',
|
||||
link: '/tools/primitives#mnemonic',
|
||||
},
|
||||
{
|
||||
text: 'Private Key',
|
||||
link: '/tools/primitives#private-key',
|
||||
},
|
||||
{
|
||||
text: 'Public Key',
|
||||
link: '/tools/primitives#public-key',
|
||||
},
|
||||
{
|
||||
text: 'Satoshis',
|
||||
link: '/tools/primitives#satoshis',
|
||||
},
|
||||
{
|
||||
text: 'Script',
|
||||
link: '/tools/primitives#script',
|
||||
},
|
||||
{
|
||||
text: 'Transaction',
|
||||
link: '/tools/primitives#transaction',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Stack',
|
||||
collapsed: false,
|
||||
items: [
|
||||
{
|
||||
text: 'Blockchain',
|
||||
link: '/tools/stack/blockchain',
|
||||
items: [
|
||||
{
|
||||
text: 'Electrum',
|
||||
link: '/tools/stack/blockchain#electrum',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
// Blog sidebar (auto-generated)
|
||||
'/blog/': blogSidebar(),
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
// { icon: 'github', link: 'https://gitlab.com/GeneralProtocols/xo/stack' },
|
||||
{ icon: 'x', link: 'https://x.com/xo_bitcoincash' },
|
||||
],
|
||||
},
|
||||
/*
|
||||
|
||||
*/
|
||||
|
||||
socialLinks: [
|
||||
// { icon: 'github', link: 'https://gitlab.com/GeneralProtocols/xo/stack' },
|
||||
{ icon: 'x', link: 'https://x.com/xo_bitcoincash' },
|
||||
],
|
||||
|
||||
// This is the outline on the right-hand side of the page.
|
||||
outline: {
|
||||
level: [2, 4],
|
||||
label: 'On this page',
|
||||
},
|
||||
},
|
||||
|
||||
// Set the dev and build targets to "esnext"
|
||||
// NOTE: We do this so that we can support top-level-await.
|
||||
vite: {
|
||||
esbuild: {
|
||||
target: 'esnext',
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: 'esnext',
|
||||
// This is the outline on the right-hand side of the page.
|
||||
outline: {
|
||||
level: [2, 4],
|
||||
label: 'On this page',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
// Configure to use browser version of @xocash/stack
|
||||
conditions: ['browser', 'import', 'module', 'default'],
|
||||
|
||||
// Set the dev and build targets to "esnext"
|
||||
// NOTE: We do this so that we can support top-level-await.
|
||||
vite: {
|
||||
esbuild: {
|
||||
target: 'esnext',
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: 'esnext',
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
// Configure to use browser version of @xocash/stack
|
||||
conditions: ['browser', 'import', 'module', 'default'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div v-if="!posts.length" class="empty">
|
||||
<p>No posts yet. Add Markdown files to <code>docs/src/blog/</code>.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid">
|
||||
<article v-for="p in posts" :key="p.url" class="card">
|
||||
<div class="body">
|
||||
<!-- date -->
|
||||
<time v-if="p.dateText" class="date">{{ p.dateText }}</time>
|
||||
|
||||
<!-- title -->
|
||||
<h2 class="title">
|
||||
<a :href="p.url">{{ p.title }}</a>
|
||||
</h2>
|
||||
|
||||
<!-- description -->
|
||||
<p v-if="p.description" class="desc">{{ p.description }}</p>
|
||||
|
||||
<!-- author -->
|
||||
<div v-if="p.author" class="author">
|
||||
{{ p.author }}
|
||||
</div>
|
||||
|
||||
<div class="spacer"></div>
|
||||
|
||||
<!-- tags at bottom (styled globally) -->
|
||||
<div v-if="p.tags && p.tags.length" class="tags">
|
||||
<span v-for="t in p.tags" :key="t" class="tag-badge VPButton brand">{{ t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
// Use the vite custom import to get each markdown file as a module
|
||||
// with additional metadata from the markdown's frontmatter (if any).
|
||||
// The modules are in a lookup according to their relative path.
|
||||
const allMDModules = import.meta.glob('../../../blog/**/*.md', { eager: true })
|
||||
|
||||
function formatDate(d) {
|
||||
// instanceof checks that we have a Date object
|
||||
// isNaN checks validity of the Date object
|
||||
if (!(d instanceof Date) || isNaN(d)) {
|
||||
throw new Error(`argument is not a valid date object: ${d}`)
|
||||
}
|
||||
|
||||
// JS Date doesn't have a great way to do this timezone aware so this manual method is common
|
||||
// Note: getMonth() alone is 0-indexed so it has a +1
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
const posts = Object.entries(allMDModules)
|
||||
// Ignore index.md in the md module lookup
|
||||
.filter(([p]) => !/[/\\]index\.md$/.test(p))
|
||||
// Convert each path + md module into a well formed post object
|
||||
.map(([p, mdModule]) => {
|
||||
// Extract the frontmatter, which can be in different places depending on the vite setup
|
||||
const frontmatter =
|
||||
(mdModule && mdModule.frontmatter) ||
|
||||
(mdModule && mdModule.__pageData && mdModule.__pageData.frontmatter) ||
|
||||
{}
|
||||
|
||||
const normalizedPath = p.replace(/\\/g, '/')
|
||||
const relativePath = normalizedPath.split('/blog/')[1] || ''
|
||||
const slug = relativePath.replace(/\.md$/, '')
|
||||
|
||||
// We assume that the posts have a 'yyyy-mm-dd-' prefix and remove it.
|
||||
const namePart = slug.replace(/^\d{4}-\d{2}-\d{2}-/, '')
|
||||
// We use global regex replace to replace all hyphens, and regex to capitalize the first letter
|
||||
const fallbackTitle = namePart.replace(/-/g, ' ').replace(/^\w/, c => c.toUpperCase())
|
||||
|
||||
// Extract the post author from the frontmatter
|
||||
const postAuthor = frontmatter.author || null
|
||||
|
||||
// Extract the post date from either the frontmatter or the filename, in that order
|
||||
const postDate = frontmatter.date ? new Date(frontmatter.date) : null
|
||||
if ((postDate === null) || (isNaN(postDate))) {
|
||||
throw new Error(`Blog posts are required to have a date in their frontmatter but did not find one for ${slug}`);
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
url: '/blog/' + slug,
|
||||
title: frontmatter.title || fallbackTitle,
|
||||
dateText: postDate ? formatDate(postDate) : '',
|
||||
description: frontmatter.description || '',
|
||||
author: postAuthor,
|
||||
tags: Array.isArray(frontmatter.tags) ? frontmatter.tags : [],
|
||||
sort: postDate ? postDate.getTime() : 0
|
||||
}
|
||||
})
|
||||
// Sort the blogs with most recent first
|
||||
.sort((a, b) => b.sort - a.sort)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* responsive grid */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card { grid-column: span 4; }
|
||||
@media (max-width: 1100px) { .card { grid-column: span 6; } }
|
||||
@media (max-width: 700px) { .card { grid-column: span 12; } }
|
||||
|
||||
/* compact, uniform-height cards */
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 240px; /* all cards same height */
|
||||
border-radius: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
overflow: hidden;
|
||||
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
|
||||
}
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px 14px 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* date */
|
||||
.date {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.author {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-brand-1);
|
||||
margin: 4px 0px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* title (clamped to 2 lines) */
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.title a { color: var(--vp-c-text-1); text-decoration: none; }
|
||||
.title a:hover { text-decoration: underline; }
|
||||
|
||||
.vp-doc h2 {
|
||||
padding-top: 8px !important; /* override default 24px */
|
||||
}
|
||||
|
||||
/* description (clamped to 3 lines) */
|
||||
.desc {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* spacer pushes tags to bottom */
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* tags row (visual style comes from your global CSS) */
|
||||
|
||||
/* tags row (visual style comes from your global CSS) */
|
||||
.tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* individual tag badge */
|
||||
.tag-badge {
|
||||
background: var(--vp-button-brand-bg);
|
||||
color: #fff;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px; /* adds rounded corners */
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
@@ -222,3 +222,14 @@ details summary {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tag-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
background: linear-gradient(120deg, var(--tag-grad-start), var(--tag-grad-end));
|
||||
line-height: 1;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import Layout from './Layout.vue';
|
||||
import './style.css';
|
||||
import './custom.css';
|
||||
import './tools.css';
|
||||
import './mermaid-overrides.css';
|
||||
import './mermaid-lightbox.client';
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// mermaid-lightbox.client.ts
|
||||
// Click any .mermaid diagram to open a zoomable modal (works beautifully on desktop + mobile)
|
||||
// Includes a theme-aware "Click diagram to expand" note that hides instantly when lightbox opens
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const OVERLAY_ATTR = 'data-mermaid-lightbox';
|
||||
const NO_LIGHTBOX_ATTR = 'data-no-lightbox';
|
||||
const MEMO_CLASS = 'mermaid-expand-memo';
|
||||
|
||||
const hideAllMemos = () =>
|
||||
document
|
||||
.querySelectorAll(`.${MEMO_CLASS}`)
|
||||
.forEach((m) => ((m as HTMLElement).style.opacity = '0'));
|
||||
|
||||
const showAllMemos = () =>
|
||||
document
|
||||
.querySelectorAll(`.${MEMO_CLASS}`)
|
||||
.forEach((m) => ((m as HTMLElement).style.opacity = '0.55'));
|
||||
|
||||
// === Create overlay (only once) ===
|
||||
const ensureOverlay = () => {
|
||||
let overlay = document.querySelector<HTMLDivElement>(
|
||||
`div[${OVERLAY_ATTR}]`,
|
||||
);
|
||||
if (overlay) return overlay;
|
||||
|
||||
overlay = document.createElement('div');
|
||||
overlay.setAttribute(OVERLAY_ATTR, 'true');
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.style.cssText = `
|
||||
position: fixed; inset: 0; z-index: 9999;
|
||||
background: rgba(0,0,0,.6);
|
||||
display: none; align-items: center; justify-content: center;
|
||||
padding: 0;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
`;
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'mermaid mermaid-lightbox-content';
|
||||
box.style.cssText = `
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
padding: 0.5rem;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,.35);
|
||||
`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.setAttribute('aria-label', 'Close');
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute; top: 4px; right: 10px;
|
||||
font-size: 32px; line-height: 1;
|
||||
border: 0; background: transparent;
|
||||
cursor: pointer; color: var(--vp-c-text-1);
|
||||
z-index: 2;
|
||||
`;
|
||||
|
||||
const hide = () => {
|
||||
overlay!.style.display = 'none';
|
||||
box.replaceChildren();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
showAllMemos();
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') hide();
|
||||
};
|
||||
|
||||
(overlay as any).showSvg = (svg: SVGSVGElement) => {
|
||||
// Hide memos immediately
|
||||
hideAllMemos();
|
||||
|
||||
// Clone ONLY the SVG element, not any parent containers
|
||||
const clone = svg.cloneNode(true) as SVGSVGElement;
|
||||
clone.removeAttribute('width');
|
||||
clone.removeAttribute('height');
|
||||
clone.style.display = 'block';
|
||||
clone.style.width = '100%';
|
||||
clone.style.height = 'auto';
|
||||
clone.style.maxWidth = '100%';
|
||||
clone.style.maxHeight = '90vh';
|
||||
clone.setAttribute('preserveAspectRatio', 'xMidYMid meet');
|
||||
|
||||
// Clear the box completely before adding new content
|
||||
box.innerHTML = '';
|
||||
box.appendChild(closeBtn);
|
||||
box.appendChild(clone);
|
||||
|
||||
overlay!.style.display = 'flex';
|
||||
document.addEventListener('keydown', onKey);
|
||||
};
|
||||
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
hide();
|
||||
});
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) hide();
|
||||
});
|
||||
|
||||
overlay.appendChild(box);
|
||||
document.body.appendChild(overlay);
|
||||
return overlay;
|
||||
};
|
||||
|
||||
// === Add the small memo below each Mermaid ===
|
||||
const addMemo = (container: HTMLElement) => {
|
||||
if (container.nextElementSibling?.classList.contains(MEMO_CLASS)) return;
|
||||
const memo = document.createElement('div');
|
||||
memo.className = MEMO_CLASS;
|
||||
memo.textContent = 'Click diagram to expand';
|
||||
memo.style.cssText = `
|
||||
text-align: center;
|
||||
font-size: 0.78rem;
|
||||
color: currentColor;
|
||||
opacity: 0.55;
|
||||
margin-top: 0.4rem;
|
||||
font-family: system-ui, sans-serif;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease;
|
||||
`;
|
||||
container.insertAdjacentElement('afterend', memo);
|
||||
};
|
||||
|
||||
// === Wire up click events ===
|
||||
const wire = (root: ParentNode = document) => {
|
||||
const svgs = root.querySelectorAll<SVGSVGElement>(
|
||||
'.mermaid svg[id^="mermaid-"]',
|
||||
);
|
||||
svgs.forEach((svg) => {
|
||||
const container = svg.closest('.mermaid') as HTMLElement | null;
|
||||
if (!container || container.hasAttribute(NO_LIGHTBOX_ATTR)) return;
|
||||
if ((svg as any)._mlb) return;
|
||||
|
||||
container.style.cursor = 'zoom-in';
|
||||
container.setAttribute('role', 'button');
|
||||
container.setAttribute('tabindex', '0');
|
||||
addMemo(container);
|
||||
|
||||
container.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement)?.closest('a')) return;
|
||||
const overlay = ensureOverlay();
|
||||
(overlay as any).showSvg(svg);
|
||||
});
|
||||
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const overlay = ensureOverlay();
|
||||
(overlay as any).showSvg(svg);
|
||||
}
|
||||
});
|
||||
|
||||
(svg as any)._mlb = true;
|
||||
});
|
||||
};
|
||||
|
||||
// Initial run + observer for re-renders
|
||||
wire();
|
||||
const mo = new MutationObserver(() => wire());
|
||||
mo.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
Mermaid — Site CSS
|
||||
- Mindmap: your custom palette (section-*)
|
||||
- Flowchart: blue theme with white text
|
||||
- Works in light & dark (via CSS vars)
|
||||
- Responsive width + centering
|
||||
────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* ===== Brand palette (edit to taste) ===== */
|
||||
:root {
|
||||
/* === Mindmap Blue–Teal Theme === */
|
||||
--mm-root-fill: #1e3a8a;
|
||||
--mm-sec0-fill: #1e6091;
|
||||
--mm-sec1-fill: #168aad;
|
||||
--mm-sec2-fill: #34a0a4;
|
||||
--mm-sec3-fill: #99d98c;
|
||||
|
||||
|
||||
|
||||
/* uniform thicker grey stroke for all nodes */
|
||||
--mm-root-stroke: #7a7a7a;
|
||||
--mm-sec0-stroke: #7a7a7a;
|
||||
--mm-sec1-stroke: #7a7a7a;
|
||||
--mm-sec2-stroke: #7a7a7a;
|
||||
--mm-sec3-stroke: #7a7a7a;
|
||||
|
||||
/* optional text + fallback colors */
|
||||
--mm-fallback-fill: #f8fafc;
|
||||
--mm-fallback-stroke:#7a7a7a;
|
||||
--mm-fallback-text: #0f172a;
|
||||
|
||||
/* edge color (grey-green neutral to fit palette) */
|
||||
--mm-edge: #7a7a7a;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== Shared text overrides (foreignObject + tspans) ===== */
|
||||
.mermaid svg[id^="mermaid-"] {
|
||||
|
||||
font-size: 110% !important; /* small global bump */
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] text,
|
||||
.mermaid svg[id^="mermaid-"] tspan {
|
||||
fill: #ffffff !important; /* default white text */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] foreignObject,
|
||||
.mermaid svg[id^="mermaid-"] foreignObject * {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* ===== Mindmap palette (section-* classes) ===== */
|
||||
/* Root node */
|
||||
.mermaid svg[id^="mermaid-"] .section-root rect,
|
||||
.mermaid svg[id^="mermaid-"] .section-root path,
|
||||
.mermaid svg[id^="mermaid-"] .section-root circle,
|
||||
.mermaid svg[id^="mermaid-"] .section-root polygon {
|
||||
fill: var(--mm-root-fill) !important;
|
||||
stroke: var(--mm-root-stroke) !important;
|
||||
stroke-width: 4px !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .section-root text,
|
||||
.mermaid svg[id^="mermaid-"] .section-root tspan {
|
||||
fill: #ffffff !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
/* Depth sections */
|
||||
.mermaid svg[id^="mermaid-"] .section-0 rect,
|
||||
.mermaid svg[id^="mermaid-"] .section-0 path,
|
||||
.mermaid svg[id^="mermaid-"] .section-0 circle,
|
||||
.mermaid svg[id^="mermaid-"] .section-0 polygon {
|
||||
fill: var(--mm-sec0-fill) !important;
|
||||
stroke: var(--mm-sec0-stroke) !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .section-0 text,
|
||||
.mermaid svg[id^="mermaid-"] .section-0 tspan { fill: #ffffff !important; }
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .section-1 rect,
|
||||
.mermaid svg[id^="mermaid-"] .section-1 path,
|
||||
.mermaid svg[id^="mermaid-"] .section-1 circle,
|
||||
.mermaid svg[id^="mermaid-"] .section-1 polygon {
|
||||
fill: var(--mm-sec1-fill) !important;
|
||||
stroke: var(--mm-sec1-stroke) !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .section-1 text,
|
||||
.mermaid svg[id^="mermaid-"] .section-1 tspan { fill: #ffffff !important; }
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .section-2 rect,
|
||||
.mermaid svg[id^="mermaid-"] .section-2 path,
|
||||
.mermaid svg[id^="mermaid-"] .section-2 circle,
|
||||
.mermaid svg[id^="mermaid-"] .section-2 polygon {
|
||||
fill: var(--mm-sec2-fill) !important;
|
||||
stroke: var(--mm-sec2-stroke) !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .section-2 text,
|
||||
.mermaid svg[id^="mermaid-"] .section-2 tspan { fill: #ffffff !important; }
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .section-3 rect,
|
||||
.mermaid svg[id^="mermaid-"] .section-3 path,
|
||||
.mermaid svg[id^="mermaid-"] .section-3 circle,
|
||||
.mermaid svg[id^="mermaid-"] .section-3 polygon {
|
||||
fill: var(--mm-sec3-fill) !important;
|
||||
stroke: var(--mm-sec3-stroke) !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .section-3 text,
|
||||
.mermaid svg[id^="mermaid-"] .section-3 tspan { fill: #ffffff !important; }
|
||||
|
||||
/* Fallback for other section-* */
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-"] rect,
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-"] path,
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-"] circle,
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-"] polygon {
|
||||
fill: var(--mm-fallback-fill) !important;
|
||||
stroke: var(--mm-fallback-stroke) !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-"] text,
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-"] tspan {
|
||||
fill: var(--mm-fallback-text) !important;
|
||||
}
|
||||
|
||||
/* Mindmap branches/edges */
|
||||
.mermaid svg[id^="mermaid-"] .edge,
|
||||
.mermaid svg[id^="mermaid-"] .mindmap-edges path,
|
||||
.mermaid svg[id^="mermaid-"] .mindmap-edges line,
|
||||
.mermaid svg[id^="mermaid-"] [class^="section-edge-"] {
|
||||
stroke: var(--mm-edge) !important;
|
||||
stroke-width: 2px !important;
|
||||
fill: none !important;
|
||||
}
|
||||
|
||||
/* Optional: tiny underline lines Mermaid adds in some node styles */
|
||||
.mermaid svg[id^="mermaid-"] .node-line-0,
|
||||
.mermaid svg[id^="mermaid-"] .node-line-1,
|
||||
.mermaid svg[id^="mermaid-"] .node-line-2 {
|
||||
stroke: rgba(0,0,0,0.25) !important;
|
||||
}
|
||||
|
||||
|
||||
:root {
|
||||
--mm-node-blue: #1570ef; /* inner node fill (light) */
|
||||
--mm-surface: rgba(0,0,0,0.06); /* cluster/edge-label pill bg on light */
|
||||
--mm-edge: color-mix(in srgb, currentColor 70%, transparent);
|
||||
}
|
||||
html.dark {
|
||||
--mm-node-blue: #1363d1; /* inner node fill (dark) */
|
||||
--mm-surface: rgba(255,255,255,0.08); /* cluster/edge-label pill bg on dark */
|
||||
--mm-edge: color-mix(in srgb, currentColor 60%, transparent);
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .label text,
|
||||
.mermaid svg[id^="mermaid-"] text,
|
||||
.mermaid svg[id^="mermaid-"] tspan,
|
||||
.mermaid svg[id^="mermaid-"] foreignObject,
|
||||
.mermaid svg[id^="mermaid-"] foreignObject * {
|
||||
fill: currentColor !important;
|
||||
color: currentColor !important;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .node rect,
|
||||
.mermaid svg[id^="mermaid-"] .node circle,
|
||||
.mermaid svg[id^="mermaid-"] .node ellipse,
|
||||
.mermaid svg[id^="mermaid-"] .node polygon,
|
||||
.mermaid svg[id^="mermaid-"] .node path {
|
||||
fill: var(--mm-node-blue) !important;
|
||||
stroke: none !important;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .node .label text,
|
||||
.mermaid svg[id^="mermaid-"] .node .label tspan,
|
||||
.mermaid svg[id^="mermaid-"] .node foreignObject,
|
||||
.mermaid svg[id^="mermaid-"] .node foreignObject * {
|
||||
fill: #ffffff !important;
|
||||
color: #ffffff !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .cluster rect {
|
||||
fill: var(--mm-surface) !important;
|
||||
stroke: none !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .cluster text,
|
||||
.mermaid svg[id^="mermaid-"] .cluster span {
|
||||
fill: currentColor !important;
|
||||
color: currentColor !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .edgePath .path,
|
||||
.mermaid svg[id^="mermaid-"] .flowchart-link {
|
||||
stroke: var(--mm-edge) !important;
|
||||
stroke-width: 2px !important;
|
||||
fill: none !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .marker,
|
||||
.mermaid svg[id^="mermaid-"] .arrowheadPath {
|
||||
fill: var(--mm-edge) !important;
|
||||
stroke: var(--mm-edge) !important;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] .edgeLabel rect {
|
||||
fill: var(--mm-surface) !important;
|
||||
stroke: none !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
.mermaid svg[id^="mermaid-"] .edgeLabel span,
|
||||
.mermaid svg[id^="mermaid-"] .edgeLabel text {
|
||||
fill: currentColor !important;
|
||||
color: currentColor !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mermaid svg[id^="mermaid-"] rect.text { fill: none !important; }
|
||||
|
||||
/* ===== Layout: responsive width + centering in docs ===== */
|
||||
.vp-doc .mermaid > svg {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
display: block;
|
||||
}
|
||||
.vp-doc .mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
title: 'Intro to XO: The Why and the What'
|
||||
date: 2025-10-15
|
||||
description: 'Introducing the XO Project: What is it and why is it important?'
|
||||
author: John Moriarty
|
||||
tags: [general audience]
|
||||
---
|
||||
|
||||
# Intro to XO - The Why and the What
|
||||
|
||||
You may have heard about XO, General Protocols’ project that promises to make it easy for developers to create superior Bitcoin Cash applications. But what is it, actually? And why do we need it?
|
||||
|
||||
## The problems to be solved
|
||||
|
||||
The Bitcoin Cash (BCH) blockchain has undergone several groundbreaking protocol upgrades in recent years. These upgrades made it possible to build complex and innovative decentralized financial applications (or “dapps”) using Bitcoin Cash smart contracts.
|
||||
|
||||
But building and interacting with BCH applications is still relatively difficult for software developers. The process requires esoteric knowledge about Bitcoin Cash’s protocol, and it often involves writing an entire wallet application around a dapp. This is a huge burden for app developers, and it’s also a suboptimal experience for users, who are forced to keep track of more and more wallets.
|
||||
|
||||
The Wallet Connect protocol lets a user interact with a decentralized application using their personal wallet, but that workflow has its own problems. In that case, the dapp provides the wallet with transactions to sign, but there is no guarantee to the user that the provided transaction does what the application says it does. The user is forced to trust a third party when using dapps because their wallet doesn’t understand how the decentralized application works, and so it can’t verify anything itself. This is a serious security risk.
|
||||
|
||||
Additionally, when a wallet doesn’t understand a decentralized application, it can’t keep track of the data needed to continue interacting with that application. For example, to use a derivatives application like BCHBull, you need to hold on to the data used to create the contract: the price source, leverage amounts, and position amounts. This contextually relevant data is called “state” and, in many cases, losing access to state means losing access to your funds, since you won’t be able to construct the transactions that withdraw or claim the BCH that is tied up in your contracts. Once again, users are forced to trust a third party.
|
||||
|
||||
## Enter the XO Stack
|
||||
|
||||
What’s needed to address these problems are software tools and standards that allow wallets and applications to coordinate in a safe, private, and user friendly way. That’s what General Protocols is building with the XO stack.
|
||||
|
||||
### Templates
|
||||
|
||||
The first fundamental pillar of the XO Stack is the concept of templates. A template is a description of a Bitcoin Cash use case in a standardized format. It includes the use case’s different participants, what actions each participant can take, what state must be kept track of, as well as human readable descriptions of all data.
|
||||
|
||||
A template-aware wallet can take a template and some contextual data as input and then construct the required transaction(s) independently. This on its own is a huge step forward, but it’s important to understand that this does not completely remove trust from the equation. Instead, it moves the trust from the app to the templates themselves. While a template-aware wallet can use a template to infer what it needs to do to complete an action, it can’t verify that the human readable descriptions are accurate, or determine whether a resulting action is something a user would actually want to do.
|
||||
|
||||
So wallet developers and users must decide which template sources to trust. A wallet developer may pre-approve a select few well known and vetted templates, for instance, an Anyhedge template (Anyhedge is the protocol used by BCHBull), or a Pay to Public Key Hash template (for simple sends). Then, if their wallet allows adding custom trusted templates, a user might decide to add several decentralized exchange templates to their wallet’s trusted list, so that they can trade between Bitcoin Cash and fungible tokens, or add funds to liquidity pools. It’s critical, however, that a user treats new or unknown templates with extreme caution like they would any new financial app.
|
||||
|
||||
Once a trusted set of templates is established, the benefits follow. Any application that makes use of those templates can pass the transaction construction responsibilities on to your wallet without any additional trust required. For example, you could use any number of different dapps that use the Anyhedge protocol to provide derivatives products, or you could use various decentralized exchanges that use the same swap and liquidity pool protocols. In each case, your wallet will be able to provide human readable descriptions of the actions you’re taking, allowing you to check that an application is doing exactly what it says it’s doing. This is still important because, while your wallet can verify the app is using a trusted template, it doesn’t necessarily know what values you entered in the application’s user interface!
|
||||
|
||||
### The XO Engine
|
||||
|
||||
This templating system has enormous benefits for users when it comes to safety and ease of use. But with all of Bitcoin Cash’s newly unlocked smart contract capabilities accessible via the template standard, developers will need sophisticated tools to manage that complexity. That’s where the XO Engine comes in.
|
||||
|
||||
The XO engine is the workhorse and personal Bitcoin Cash expert that handles all the low-level details for developers. It’s responsible for interpreting templates, managing cryptographic keys, tracking state, generating invitations, constructing transactions, and more. When using the XO stack, developers only need to familiarize themselves with how to make high-level requests of the engine, and the rest is taken care of for them. This massively reduces development time, cost, and risk of safety-compromising programming errors.
|
||||
|
||||
## Onward with XO!
|
||||
|
||||
The Bitcoin Cash protocol is in an excellent position regarding its technical capabilities. We at General Protocols are excited to bring Bitcoin Cash infrastructure up to speed, making powerful BCH apps easy and intuitive to develop with the XO Stack. We also continue to design and develop our premier XO App, the XO Wallet. With the full power of Bitcoin Cash under the hood, the XO Wallet promises to be an essential part of a brand new ecosystem of apps, developed by General Protocols and others. Keep your eyes open for more XO-related content as we build the future of programmable money!
|
||||
@@ -0,0 +1,704 @@
|
||||
---
|
||||
title: P2PKH in XO - From vision to reality
|
||||
date: 2025-10-16
|
||||
description: This is an XO article!
|
||||
author: Jonathan Silverblood
|
||||
tags: [templates, engine, technical audience]
|
||||
---
|
||||
|
||||
# P2PKH in XO: From vision to reality
|
||||
|
||||
```mermaid
|
||||
mindmap
|
||||
root )Responsibilities(
|
||||
|
||||
[INVITATIONS]
|
||||
(Connects)
|
||||
📱 Apps
|
||||
👛 Wallets
|
||||
👥 Users
|
||||
(Coordinates)
|
||||
⚡ Actions
|
||||
🧩 Variables
|
||||
📥 Inputs
|
||||
📤 Outputs
|
||||
|
||||
[TEMPLATES]
|
||||
(Describes)
|
||||
🧩 Variables
|
||||
💸 Transactions
|
||||
📥 Inputs
|
||||
📤 Outputs
|
||||
🏷️ Lockscripts
|
||||
(Defines)
|
||||
⚡ Actions
|
||||
💸 Transactions
|
||||
💾 Data
|
||||
|
||||
[ENGINE]
|
||||
(Generates)
|
||||
🔑 Keys
|
||||
🪪 Identities
|
||||
✉️ Invitations
|
||||
💸 Transactions
|
||||
(Manages)
|
||||
🤫 Secrets
|
||||
🧩 Variables
|
||||
📤 Outputs
|
||||
🏷️ Lockscripts
|
||||
```
|
||||
|
||||
In this article, we are going to go over how a templating engine might function in the context of a P2PKH template.
|
||||
|
||||
We will go over both the expected flow for a user interaction, as well as explaining how the engine gets all data it requires to function.
|
||||
|
||||
Finally, we will cover some of the rationale for why this system is valuable.
|
||||
|
||||
## The Templating System
|
||||
|
||||
### Engine
|
||||
|
||||
At the center of the templating system is an engine that together with templates is responsible for the full lifecycle of `transactions`, `relevant state` and `private keys`.
|
||||
|
||||
### Templates
|
||||
|
||||
The templates are immutable and only contain detailed descriptions and instructions on how to create and manage blockchain and use-case related data structures, such as transactions and lockscripts.
|
||||
|
||||
Templates are organized around actions which defines when and what data to create.
|
||||
|
||||
### Invitations
|
||||
|
||||
When a template action is requested, the engine follows a consistent and re-usable invitation process.
|
||||
|
||||
First an invitation is created that defines what template and action has been requested. This invitation is then accepted by entities wanting to participate in that action, including which of the available roles they want take.
|
||||
|
||||
Then, the invitation is extended with any missing information by the participants until it reaches a state where the action defined in the invitation can be executed.
|
||||
|
||||
This process is the same regardless of the complexity of the template and allows for both single-user and multi-user participation.
|
||||
|
||||
### Identity
|
||||
|
||||
When we are working with templates and interactions using invitations, it is important that each entity that is providing data or is taking part in an action has a cryptographic identity. This helps provide a clear log and history, allows us to assign blame and to recover when something has gone wrong.
|
||||
|
||||
The engine will deterministically create an identity based on the root entropy provided during initialization. If no entropy is provided it will generate a temporary root entropy and will not have a stable identity.
|
||||
|
||||
More information on identities will be covered in a future article.
|
||||
|
||||
### Templates
|
||||
|
||||
### Dynamic content
|
||||
|
||||
The templating system described here uses `CashASM` to provide dynamic descriptions, properties and other values.
|
||||
|
||||
This article will not go into depth on how `CashASM` works, but here's what you need to know:
|
||||
|
||||
- `$(expression)` evaluates the expression using the BCH VM opcodes, then return the top stack item.
|
||||
- `<something>` push something to the stack.
|
||||
|
||||
Here's an example evaluating to `BitcoinCash`, for reference:
|
||||
|
||||
> $(<'Bitcoin'> <'Cash'> OP_CAT)
|
||||
|
||||
## Detailed explanation of a P2PKH interaction
|
||||
|
||||
We will now go over a very detailed example of how the engine, a template and invitations all go together to make a request for `100.00` `MUSD` fungible tokens.
|
||||
|
||||
### Starting the engine
|
||||
|
||||
Before the engine can be used, it needs to be initialized.
|
||||
|
||||
This process provides the engine an initial root entropy, storage facilities and ways to monitor the bitcoin cash blockchain:
|
||||
|
||||
```ts
|
||||
// Create seed.
|
||||
// NOTE: this should be randomized the first time, then reused when the engine is setup.
|
||||
const seed = '<some random stuff>';
|
||||
|
||||
// Set up a way to monitor block chain status
|
||||
const blockchainMonitor =
|
||||
'<some code to set up a blockchain monitoring solution>';
|
||||
|
||||
// Set up ways to store state and history
|
||||
const browserIDBState = '<some code to store state in your browsers indexeddb>';
|
||||
const privateServerState =
|
||||
'<some code to store state with a private state service>';
|
||||
|
||||
// Initialize the engine using the seed, blockchain and state providers.
|
||||
const xoEngine = new Engine(seed, blockchainMonitor, [
|
||||
browserIDBState,
|
||||
privateServerState,
|
||||
]);
|
||||
```
|
||||
|
||||
### Importing template
|
||||
|
||||
Before we can use the P2PKH template, we need to import it into the engine:
|
||||
|
||||
```ts
|
||||
const p2pkhTemplateIdentifier = await xoEngine.importTemplate(TemplateP2PKH);
|
||||
```
|
||||
|
||||
### Requesting Fungible Tokens
|
||||
|
||||
In the template there is list of starting actions that are available at any time:
|
||||
|
||||
```ts
|
||||
start:
|
||||
{
|
||||
owner: [ 'receive', 'requestSatoshis', 'requestFungibleTokens', 'requestNonfungibleTokens' ],
|
||||
},
|
||||
```
|
||||
|
||||
Note that these actions are grouped by role, in this case the P2PKH owner can receive and request funds. Other templates might have different starting actions for different roles.
|
||||
|
||||
Since we are looking to specifically request `100.00` `MUSD` fungible tokens, we will need to take a look at the `requestFungibleTokens` action:
|
||||
|
||||
```ts
|
||||
requestFungibleTokens:
|
||||
{
|
||||
name: 'Request Fungible Tokens',
|
||||
description: 'Requests a specific amount of a fungible tokens from one or more senders.',
|
||||
icon: 'request',
|
||||
|
||||
variables: [ 'requestedTokenCategory', 'requestedTokenAmount' ],
|
||||
transactions: [ 'requestFungibleTokensTransaction' ],
|
||||
},
|
||||
```
|
||||
|
||||
The name and description here describes the action itself and is intended to help user find the right action for what they want to do.
|
||||
|
||||
The action will in the end produce a `requestFungibleTokensTransaction` transaction, but to do so it needs some variables like the `requestedTokenCategory` and the `requestedTokenAmount`.
|
||||
|
||||
Here's the definition of these variables in the template:
|
||||
|
||||
```
|
||||
requestedTokenCategory:
|
||||
{
|
||||
name: 'Requested Token Category',
|
||||
description: 'The token category requested',
|
||||
type: 'bytes',
|
||||
hint: 'token_category',
|
||||
},
|
||||
requestedTokenAmount:
|
||||
{
|
||||
name: 'Requested Token Amount',
|
||||
description: 'The fungible token amount requested',
|
||||
type: 'integer',
|
||||
hint: 'token_amount',
|
||||
},
|
||||
```
|
||||
|
||||
Note that there is a technical type, the `bytes`, and a type `hint`.
|
||||
|
||||
The type hint is there so that apps and wallets can provide better user experiences, and lets the engine do some helpful validation.
|
||||
|
||||
For example, rather than have the user provide the MUSD token category like a hex string: `b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366`, the wallet might show a dropdown with known tokens the user can choose from with the option of manual input as needed.
|
||||
|
||||
In our example we will simply hard-code `100.00` worth of `MUSD`:
|
||||
|
||||
```ts
|
||||
const moriaTokenCategory =
|
||||
'b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366';
|
||||
const moriaDecimals = 2;
|
||||
|
||||
const requestVariables = {
|
||||
requestedTokenCategory: hexToBin(moriaTokenCategory),
|
||||
requestedTokenAmount: BigInt(100.0 * 10 ** moriaDecimals),
|
||||
};
|
||||
|
||||
const requestInvitation = await xoEngine.createStartingInvitation(
|
||||
p2pkhTemplateIdentifier,
|
||||
'requestFungibleTokens',
|
||||
{ variables: requestVariables },
|
||||
);
|
||||
```
|
||||
|
||||
This code will return an invitation that contains information such as the template identifier, what action is in progress and any variable values for that action. Additionally every value in the invitation is covered by a signature from the entity that provided it.
|
||||
|
||||
In this example, we will use this invitation to coordinate the details of the request between the sender and the receiver.
|
||||
|
||||
### Under the hood
|
||||
|
||||
As part of creating the starting invitation, the engine also needs to provide all information that should come from the current user. Since the starting action here was grouped under the `owner` role, it also automatically accepts the invitation in order to take on that role.
|
||||
|
||||
The next steps are going to drill down through the template in order to demonstrate how the engine finds the requirements of the action and how it generates the necessary data to satisfy those requirements without any further user interactions.
|
||||
|
||||
Here's an overview of the P2PKH template structure:
|
||||
|
||||
```mermaid
|
||||
flowchart RL
|
||||
subgraph Template
|
||||
Transaction[requestFungibleTokensTransaction]
|
||||
Output[requestFungibleTokensOutput]
|
||||
Lock[receivingLockingScript]
|
||||
|
||||
Hash --> Lock
|
||||
Lock --> Output
|
||||
Output --> Transaction
|
||||
end
|
||||
subgraph Engine
|
||||
Hash[["$(<ownerKey.public_key> OP_HASH160)"]]
|
||||
Public[ownerKey.publicKey]
|
||||
Private[ownerKey]
|
||||
Secret[Seed]
|
||||
|
||||
Public --> Hash
|
||||
Private --> Public
|
||||
Secret --> Private
|
||||
end
|
||||
```
|
||||
|
||||
Here is the `requestFungibleTokensTransaction` that the action needs to produce:
|
||||
|
||||
```ts
|
||||
requestFungibleTokensTransaction:
|
||||
{
|
||||
name: 'Completed request',
|
||||
description: 'Completed request for $(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) $(<requestedTokenCategory.symbol>).',
|
||||
icon: 'request',
|
||||
|
||||
// Standard transaction without a locktime.
|
||||
version: 2,
|
||||
locktime: 0,
|
||||
|
||||
// Inputs and outputs that must exist in the transaction.
|
||||
inputs: [ ],
|
||||
outputs [ 'requestFungibleTokensOutput' ],
|
||||
|
||||
// ...
|
||||
composable: true,
|
||||
},
|
||||
```
|
||||
|
||||
As you can see, it's a simple transaction construction, with a requirement set for a single `requestFungibleTokensOutput` output.
|
||||
|
||||
The transaction also has a description that might not look very human readable in here, but once you take the description provided and apply the CashASM evaluations in it, it transforms to a much more human friendly:
|
||||
|
||||
> Completed request for 100.00 MUSD.
|
||||
|
||||
The engine now needs to determine how to create the `requestFungibleTokensOutput`:
|
||||
|
||||
```ts
|
||||
requestFungibleTokensOutput:
|
||||
{
|
||||
name: 'Requested',
|
||||
description: '$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_DIV).$(<requestedTokenAmount> <requestedTokenCategory.decimalsFactor> OP_MOD) requested $(<requestedTokenCategory.symbol>) tokens.',
|
||||
icon: 'request',
|
||||
|
||||
// Defines how the requested funds should be locked.
|
||||
lockscript: 'receivingLockingScript',
|
||||
|
||||
// Require a flat 1000 satoshis to ensure the fungible tokens remains transferrable.
|
||||
valueSatoshis: 1000n,
|
||||
|
||||
// Require only the specified amount and type of fungible tokens.
|
||||
// NOTE: This can be composed with a request for a non-fungible token, but will always result in two separate outputs.
|
||||
token:
|
||||
{
|
||||
category: '$(<requestedTokenCategory>)',
|
||||
amount: '$(<requestedTokenAmount>)',
|
||||
nft: null,
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Like the transaction, the output has a human readable name and description, but also requires a flat 1000 satoshis as well as the requested `amount` and `category`, and is managed by the `receivingLockingScript`.
|
||||
|
||||
If we follow that lockscript we will get to a quite long section, but for now the part that we care about is determining how the engine will resolve this to get an actual lockscript it can use in this output.
|
||||
|
||||
Here's a shortened version showing a deeper reference to the `lockP2PKH` script:
|
||||
|
||||
```ts
|
||||
receivingLockingScript:
|
||||
{
|
||||
// Defines how spending future received funds should be authorized.
|
||||
lockingType: 'standard',
|
||||
lockingScript: 'lockP2PKH',
|
||||
}
|
||||
```
|
||||
|
||||
Following that reference shows the `lockP2PKH` script itself:
|
||||
|
||||
```ts
|
||||
lockP2PKH: 'OP_DUP OP_HASH160 <$(<ownerKey.public_key> OP_HASH160)> OP_EQUALVERIFY OP_CHECKSIG',
|
||||
```
|
||||
|
||||
As some of you might've noticed, this is where the actual P2PKH part comes in, and the lockscript as it will be used on-chain is constructed.
|
||||
|
||||
However, we're not quite done yet as the `lockP2PKH` is defined with a dynamic CashASM expression: `<$(<ownerKey.public_key> OP_HASH160)>`.
|
||||
|
||||
This part takes the ownerKey, produces the corresponding public key and pushes it to the stack, then runs OP_HASH160 and return the hash.
|
||||
|
||||
In order to resolve this it needs to know how to get to the `ownerKey`:
|
||||
|
||||
```ts
|
||||
// Describe the secret private key.
|
||||
ownerKey:
|
||||
{
|
||||
name: 'Owners Private Key',
|
||||
description: 'The private key used to authorize spending of received funds.',
|
||||
type: 'bytes',
|
||||
hint: 'private_key',
|
||||
},
|
||||
```
|
||||
|
||||
This key is not provided by the action, but since the engine knows what a private key is and no key was provided it will create a new key on-demand every time such a key is needed, avoiding unnecessary address re-use.
|
||||
|
||||
The engine can now evaluate the CashASM part of the `lockP2PKH` script to create the `receivingLockingScript` that completes the `requestFungibleTokensOutput`, which is added to the invitation.
|
||||
|
||||
The `ownerKey` is not added to the invitation though, and we will go into more details when discussing state and secrets later in the article.
|
||||
|
||||
_At this point, we have gone through all the requirements for setting up a request to receive `100.00` `MUSD` and we have created an invitation that can be shared to complete the request._
|
||||
|
||||
### Handling the fungible tokens request
|
||||
|
||||
Let's assume that we have received the request invitation above via an XO compatible transport, and that we have an XO compatible wallet with sufficient funds in it.
|
||||
|
||||
In order to handle the request, there is a number of questions that will need to be answered:
|
||||
|
||||
- what part do I take in this action?
|
||||
- what information will I need to provide?
|
||||
- which of my funds will be used for this?
|
||||
- do I actually even want to do this?
|
||||
|
||||
Ideally, we also want to interact with the wallet user as few times as possible, so the first thing we would need to do is figure out what is required of the user.
|
||||
|
||||
#### Roles
|
||||
|
||||
We start by checking if there is any open roles the user can take:
|
||||
|
||||
```ts
|
||||
const availableRoles = await xoEngine.listAvailableRoles(requestInvitation);
|
||||
```
|
||||
|
||||
This step is needed because taking on a role defines which of the variables and resources needs to be provided later.
|
||||
|
||||
In this case, the only role that for the action was the `owner`, and that is already taken, so the only thing for us to do here is to accept the request without taking on a specific role in it:
|
||||
|
||||
```ts
|
||||
const acceptedRequestInvitiation = await xoEngine.acceptInvitation(
|
||||
requestInvitation,
|
||||
[],
|
||||
);
|
||||
```
|
||||
|
||||
_In a more complex setup, this would require showing the user a list of available roles that they can choose from, before accepting the invitation_
|
||||
|
||||
#### Variables
|
||||
|
||||
Next step is to check what variables are missing to complete the request:
|
||||
|
||||
```ts
|
||||
const { requiredVariables } =
|
||||
await xoEngine.listRequirements(requestInvitation);
|
||||
```
|
||||
|
||||
This step is needed because the required variables could be used to define the resources needed to be provided later.
|
||||
|
||||
The action in this invitation only required two variables, the `requestedTokenAmount` and `requestedTokenCategory`. Both of these were already set by the `owner`, so there's nothing for us to do here.
|
||||
|
||||
_In a more complex setup, this would require asking the user to input values for their required variables before moving on._
|
||||
|
||||
#### Inputs and outputs
|
||||
|
||||
The last step is to determine what resources are required in terms of blockchain assets.
|
||||
|
||||
For satoshis, we look at the the `requestFungibleTokensOutput` to see `1000`:
|
||||
|
||||
> valueSatoshis: 1000n,
|
||||
|
||||
For tokens we also look at the `requestFungibleTokensOutput` and here we can see that it has a fungible token amount that was evaluated to `10000n` and a token category of `b38a33f750f84c5c169a6f23cb873e6e79605021585d4f3408789689ed87f366`:
|
||||
|
||||
token:
|
||||
{
|
||||
category: '$(<requestedTokenCategory>)',
|
||||
amount: '$(<requestedTokenAmount>)',
|
||||
nft: null,
|
||||
},
|
||||
|
||||
Now that the engine knows what resources must exist in the output of the transaction, we can infer this as the required resources to provide if we want to complete this action.
|
||||
|
||||
Now that the engine knows what resources must exist in the output of the transaction, it can infer the required inputs. In this case to cover the required output and mining fee.
|
||||
|
||||
The next step would be to select some UTXOs to add as inputs, create any needed change outputs and then update the invitation with this information, which can be done automatically by the engine:
|
||||
|
||||
```ts
|
||||
const requirements = {
|
||||
requiredSatoshis,
|
||||
requiredFungibleTokens,
|
||||
requiredNonFungibleToken,
|
||||
};
|
||||
|
||||
const preparedRequestInvitation = await xoEngine.reserveResources(
|
||||
acceptedRequestInvitiation,
|
||||
requirements,
|
||||
);
|
||||
```
|
||||
|
||||
In a later example we will show how to do this manually, to achieve coin control, choice in coin selection strategy and using different lockscripts for different change outputs to maintain privacy.
|
||||
|
||||
#### Authorization
|
||||
|
||||
Now that we know all roles, variables and resources the user would provide, we can show this information to the user and ask for a confirmation.
|
||||
|
||||
Assuming the user then agrees to take part in the action we go ahead and sign our inputs, marking our participation in this invitation complete:
|
||||
|
||||
```ts
|
||||
const signedRequestInvitation = await xoEngine.signInvitation(
|
||||
preparedRequestInvitiation,
|
||||
);
|
||||
```
|
||||
|
||||
The invitation can then be sent back, or if signed by all relevant parties, we can execute the action in the template:
|
||||
|
||||
```ts
|
||||
const requestTransaction = await xoEngine.executeAction(
|
||||
signedRequestInvitation,
|
||||
BROADCAST_ON_EXECUTION,
|
||||
);
|
||||
```
|
||||
|
||||
The request for `100.00` `MUSD` fungible tokens is now complete and a transaction exist on-chain.
|
||||
|
||||
### Managing state
|
||||
|
||||
Now that the easy part is done, let's dig into the heart of the engine and the templating system.
|
||||
|
||||
Now that a transaction has been broadcast on chain that has an output matching the locking script we used for our request, the engine will automatically detect this output and internally connect it with the template and action, keep track of the original variables.
|
||||
|
||||
However, if you remember from earlier the `ownerKey` was generated on-demand as-needed, but in order to spend this new output we would need the same private key as was used to create the lockscript, so let's revisit the `receivingLockingScript`, this time without shortening it:
|
||||
|
||||
```ts
|
||||
receivingLockingScript:
|
||||
{
|
||||
name: 'Received',
|
||||
description: 'Funds received without wallet coordination.',
|
||||
icon: 'address',
|
||||
|
||||
// Defines how spending future received funds should be authorized.
|
||||
lockingType: 'standard',
|
||||
lockingScript: 'lockP2PKH',
|
||||
|
||||
// Define a default unlocking script to be used when no action provided script is present.
|
||||
unlockingScript: 'unlockP2PKH',
|
||||
|
||||
roles:
|
||||
{
|
||||
owner:
|
||||
{
|
||||
// The only state that is required to be persisted when receiving funds is the owners private key.
|
||||
state: [],
|
||||
secrets: [ 'ownerKey' ],
|
||||
|
||||
// List actions that can be taken with the ownerKey for each address/lockscript.
|
||||
actions: [ 'sign', 'verify', 'sendSatoshis', 'sendFungibleTokens', 'sendNonfungibleTokens', 'burnFungibleTokens', 'burnNonfungibleTokens' ],
|
||||
|
||||
// Indicates how much of received funds should be part of a wallets total balance.
|
||||
balance:
|
||||
{
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonFungibleToken: true,
|
||||
},
|
||||
|
||||
// Indicate when received funds should be considered in automatic coin selection to meet input requirements of other transactions.
|
||||
selectable: true,
|
||||
|
||||
// Indicate that received funds are not considered good privacy.
|
||||
privacy: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The description here might seem a little odd at first, as it does not match the action we had earlier, but this is intentional.
|
||||
|
||||
At any time when the lockscript is generated, it is the engines responsibility to start tracking that lockscript and all activity related to it. When such activity can be linked to an action, it will use the descriptions provided by that action, but sometimes there is activity that the engine is not aware of, such as when you receive an airdrop or someone dusts your address.
|
||||
|
||||
The name and description of the lockscript are then used for such outputs that cannot be linked to an intentional action, allowing us to provide human meaningful context even for uncoordinated activity.
|
||||
|
||||
You can also see that there is a default unlocking script, which is used when the engine needs to use an output on this lockscript and have not received any explicit instructions on how to unlock it.
|
||||
|
||||
The important part for us right now though, is the `state`/`secrets`.
|
||||
|
||||
```ts
|
||||
// The only state that is required to be persisted when receiving funds is the owners private key.
|
||||
state: [],
|
||||
secrets: [ 'ownerKey' ],
|
||||
```
|
||||
|
||||
When the engine is asked to create either an output of a lockscript, the template can define a set of variables that are to be automatically managed. The engine would then take this state and internally tie it to the output, or any output using the specific lockscript.
|
||||
|
||||
When the output is later spent, the state attached to it will be made available to satisfy any constraints originating from the same template. In this P2PKH example, the `ownerKey` is marked as part of the `secrets` which is a subset of the full state that will not be shared when doing invitations or similar actions.
|
||||
|
||||
In more advanced uses, the state might be defined at one transaction, but only be needed several transactions later. In these cases, it is up to the template authors to define outputs to carry the state from one transaction to the next until it is no longer needed.
|
||||
|
||||
Note that a result of this behaviour is that state has a lifecycle and once an output is spent, any state attached to that output can be archived into history or even pruned entirely, depending on the needs of the wallet or application.
|
||||
|
||||
Managing state is likely to most complex and valuable part of the templating system, and much more can be said on the matter. We will go into more detail in later examples.
|
||||
|
||||
### What is ownership?
|
||||
|
||||
There are a few more parts of the `receivingLockingScript` left to go over that relates to ownership and control of outputs: `actions`, `balance`, `selectable` and `privacy`.
|
||||
|
||||
These properties provide information to an app or wallet that uses the engine. Let's go over each of them to get a better understanding of what they do:
|
||||
|
||||
```ts
|
||||
// List actions that can be taken with the ownerKey for each address/lockscript.
|
||||
actions: [ 'sign', 'verify', 'sendSatoshis', 'sendFungibleTokens', 'sendNonfungibleTokens', 'burnFungibleTokens', 'burnNonfungibleTokens' ],
|
||||
```
|
||||
|
||||
The `actions` list provides references to actions that you might be able to take with a given output or the state attached to an output.
|
||||
|
||||
In the P2PKH template, these can be summarized as actions to sign and verify messages as well as sending and burning value.
|
||||
|
||||
This allows an app or wallet to present possible actions when the user is looking at a given output or address. Some of the actions listed here may have additional constraints, for example:
|
||||
|
||||
```ts
|
||||
// Sending is only available for outputs that have fungible tokens on them.
|
||||
condition: '$(OP_INPUTINDEX OP_UTXOTOKENAMOUNT <0> OP_GREATERTHAN)',
|
||||
```
|
||||
|
||||
The `sendFungibleTokens` action has a condition that makes it unavailable if the input does not have any fungible tokens on it.
|
||||
|
||||
Apart from potential `actions`, there is also a `balance` property which can be used to get a better understanding of how much of an output is estimated to owned by the user:
|
||||
|
||||
```ts
|
||||
balance:
|
||||
{
|
||||
satoshis: true,
|
||||
fungibleTokens: true,
|
||||
nonFungibleToken: true,
|
||||
},
|
||||
```
|
||||
|
||||
In the P2PKH example, these are set to `true` to indicate that the full output is estimated to be owned by the owner. However, these fields can also be set to `false`, or a number.
|
||||
|
||||
In more advanced cases, these fields would be CashASM evaluations that would be able to determine values based on the variables in the state, and in some cases they would be impossible to define and would be unknown.
|
||||
|
||||
It is up to the app or wallet to determine how they want to use, or not use, this information.
|
||||
|
||||
Finally, there are two properties relating to coin selection: `selectable` and `privacy`:
|
||||
|
||||
```ts
|
||||
// Indicate when received funds should be considered in automatic coin selection.
|
||||
selectable: true,
|
||||
|
||||
// Indicate that received funds are not considered good privacy.
|
||||
privacy: false,
|
||||
```
|
||||
|
||||
Coin selection is a vast field with no best answer, so these are provided as additional information that a coin selection algorithm may use, and not as strict directives for when and how to do coin selection.
|
||||
|
||||
The `selectable` property allows the template authors to indicate if it is sensible to use an output in automatic coin selection and can be either `true` (may be used), `false` (should not be used) or can be left `undefined`.
|
||||
|
||||
The `privacy` property allows the template authors to indicate what level of privacy an output has, which can be helpful to avoid mixing of inputs of different levels of privacy.
|
||||
|
||||
### Other actions
|
||||
|
||||
Before we wrap up, let's take a deeper look at the `sign` and `verify`actions:
|
||||
|
||||
```ts
|
||||
sign:
|
||||
{
|
||||
name: 'Sign Message',
|
||||
description: 'Signs a provided message using the Bitcoin message signing protocol.',
|
||||
icon: 'sign',
|
||||
|
||||
variables: [ 'message' ],
|
||||
data: [ 'messageSignature' ],
|
||||
},
|
||||
verify:
|
||||
{
|
||||
name: 'Verify Message Signature',
|
||||
description: 'Verifies a provided message signature according to the Bitcoin message signing protocol.',
|
||||
icon: 'verify',
|
||||
|
||||
variables: [ 'messageSignature', 'message' ],
|
||||
data: [ 'messageSignatureValidity' ],
|
||||
},
|
||||
```
|
||||
|
||||
Contrary to previous actions we have looked at, these do not result in transactions, but instead provides arbitrary `data`.
|
||||
|
||||
Let's look at the `messageSignature` definition, and the related `messagePrefix` constant:
|
||||
|
||||
```ts
|
||||
messageSignature:
|
||||
{
|
||||
// Evaluate CashASM expression to get the signature needed.
|
||||
value: '$(<messagePrefix> <message> OP_CAT <ownerKey.data_signature.top_stack_element>)',
|
||||
type: 'bytes',
|
||||
hint: 'signature',
|
||||
},
|
||||
```
|
||||
|
||||
```ts
|
||||
messagePrefix:
|
||||
{
|
||||
name: 'Message Prefix',
|
||||
description: 'Standard message prefix used in the bitcoin signed message protocol.',
|
||||
type: 'bytes',
|
||||
|
||||
// Value is enforce to the bitcoin signing magic string:
|
||||
// "\x18Bitcoin Signed Message:\n"
|
||||
value: '0x18426974636f696e205369676e6564204d6573736167653a0a',
|
||||
},
|
||||
```
|
||||
|
||||
Together, these encode the standard bitcoin message signing procedure to safely sign an arbitrary message using the `ownerKey` from any given address or output, without exposing the keys to 3rd parties or allowing them to sign potentially dangerous messages.
|
||||
|
||||
The validation of a signature is done in a similar way:
|
||||
|
||||
```ts
|
||||
messageSignatureValidity:
|
||||
{
|
||||
// Evalute the validity of the message with the owners public key.
|
||||
value: '$(<messageSignature> <messagePrefix> <message> OP_CAT <ownerKey.publicKey> OP_CHECKDATASIG)';
|
||||
type: 'integer',
|
||||
hint: 'script_boolean',
|
||||
},
|
||||
```
|
||||
|
||||
This means that any wallet that uses this P2PKH template would be have this feature without needing to add any special handling or code for it, simply find any address/lockscript or output, look at the associated actions and initiate an action to sign or verify a message.
|
||||
|
||||
## Rationale
|
||||
|
||||
After reading this article, you might rightfully feel that it's all a bit much, and wonder why someone would go through the effort and spend the resources required to build such a complicated machinery.
|
||||
|
||||
The answer is that it allows us to build new applications both faster and safer, while providing a significantly better user experience.
|
||||
|
||||
### Safety
|
||||
|
||||
In the traditional wallet and app ecosystem today, the default is to run on HD wallets using seed words for backup and recovery.
|
||||
|
||||
With every new feature added to the Bitcoin Cash blockchain that empowers smart contract developers, the number of use cases where the wallet seed is insufficient grows.
|
||||
|
||||
Contracts by nature are stateful and loss of state is often equivalent to loss of funds. While this can be mitigated to some degree by carrying state in token commitments, it has the tradeoff of exposing that state to the world. Further, some state, like your prediction in a guru bet, is required to be kept secret, and cannot be safely stored in a token commitment.
|
||||
|
||||
By addressing the need for state management in modern smart contracts we reduce the risk of state loss and therefor improve safety for users.
|
||||
|
||||
Further, by creating an engine that is capable of both generating and storing keys, and keeping them isolated and restricting their use to template defined actions, we provide additional safety in terms of safe key management. This is also a missing piece in order to achieve fully non-custodial applications, where both control of all keys, as well as decisionmaking for what those keys are used for resides entirely with the users wallet.
|
||||
|
||||
Finally, by creating an invitation and coordination mechanic that doesn't rely on single-key operation and that does template based validation of provided data, we improve safety for users when interacting with 3rd party distributed applications.
|
||||
|
||||
### User experience
|
||||
|
||||
With templates, every transaction, input, output, lockscript and address can be described with dynamically evaluated CashASM names and descriptions. Further, every variable or input required by the user can be described as well, and their type and data can be validated.
|
||||
|
||||
With templates, the need for technical identifiers is massively reduced.
|
||||
|
||||
Further, by having contextual actions, external links and other metadata it becomes much easier for users to be empowered and stay in control.
|
||||
|
||||
Finally, with templates it becomes possible to estimate a users aggregate net worth across a wide variety of contracts, as well as show how much of that value is readily accessible for spending and how much is currently locked.
|
||||
|
||||
### Higher productivity
|
||||
|
||||
With templates, the correct use of a contract is directly encoded. Importing a template that someone else has made can be done to provide new features at almost no cost.
|
||||
|
||||
Compared to some alternatives, templates also provide a consistent way to adopt new features where previously app and wallet makers would need to import specific libraries for each feature they want, or do full bespoke implementations.
|
||||
|
||||
For reference, the AnyHedge library, excluding tests and examples, is `262.8 KiB` large.
|
||||
|
||||
While it can do much more than a corresponding template, a lot of it is re-inventing the BitcoinCash domain knowledge, working with inputs and outputs etc, and my estimate is that less than a third of it would been needed if we had full template support.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
title: XO Blog
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import BlogCards from '../.vitepress/theme/components/BlogCards.vue'
|
||||
</script>
|
||||
|
||||
# XO Blog
|
||||
|
||||
Welcome to the XO Blog where we talk about all the things
|
||||
|
||||
<BlogCards />
|
||||
@@ -7,6 +7,9 @@ hero:
|
||||
text: 'On Bitcoin Cash'
|
||||
tagline: Building smart applications on the BCH UTXO Network
|
||||
actions:
|
||||
- theme: brand
|
||||
text: XO Blog
|
||||
link: /blog/
|
||||
- theme: brand
|
||||
text: Tools
|
||||
link: /tools/primitives
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,318 @@
|
||||
{
|
||||
"colors": {
|
||||
"release": "#ef4444",
|
||||
"milestone": "#7c3aed"
|
||||
},
|
||||
|
||||
"releases": [
|
||||
{
|
||||
"label": "XO Stack v0.001",
|
||||
"quarter": "Q4 2025",
|
||||
"notes": "First public preview of the XO runtime and JSON Payment Protocol."
|
||||
}
|
||||
],
|
||||
|
||||
"milestones": [
|
||||
{
|
||||
"label": "Primitives release",
|
||||
"quarter": "Q2 2025",
|
||||
"notes": "Core primitives stabilized and documented."
|
||||
},
|
||||
{
|
||||
"label": "Demo wallet",
|
||||
"quarter": "Q3 2025",
|
||||
"notes": "Reference wallet demonstrating templates, notifications, and sync."
|
||||
},
|
||||
{
|
||||
"label": "Demo app",
|
||||
"quarter": "Q4 2025",
|
||||
"notes": "End-to-end sample with payments via JSON protocol."
|
||||
}
|
||||
],
|
||||
|
||||
"categories": [
|
||||
{
|
||||
"name": "XO Stack",
|
||||
"color": "#2563eb",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "Primitives",
|
||||
"color": "#1d4ed8",
|
||||
"items": [
|
||||
{
|
||||
"label": "Core primitives v1",
|
||||
"start": "Q1 2025",
|
||||
"end": "Q2 2025",
|
||||
"notes": "Accounts, assets, attestations; spec + tests."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "XO Engine",
|
||||
"color": "#3b82f6",
|
||||
"items": [
|
||||
{
|
||||
"label": "Runtime & scheduler",
|
||||
"start": "Q1 2023",
|
||||
"end": "Q4 2029",
|
||||
"notes": "Deterministic execution; metering & limits."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Playground",
|
||||
"items": [
|
||||
{
|
||||
"label": "Interactive sandbox",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q3 2025",
|
||||
"notes": "In-browser runner; shareable links."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "JSON Payment Protocol",
|
||||
"color": "#1e40af",
|
||||
"items": [
|
||||
{
|
||||
"label": "Draft + POC",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Request/response schema; multi-sig routes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "API",
|
||||
"items": [
|
||||
{
|
||||
"label": "Hosted API v1",
|
||||
"start": "Q3 2025",
|
||||
"end": "Q1 2026",
|
||||
"notes": "Rate limits, keys, usage analytics."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"name": "XO Wallet",
|
||||
"color": "#16a34a",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "User interface",
|
||||
"color": "#15803d",
|
||||
"items": [
|
||||
{
|
||||
"label": "Wallet UX pass",
|
||||
"start": "Q1 2025",
|
||||
"end": "Q3 2025",
|
||||
"notes": "Navigation, theming, accessibility."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Template reading",
|
||||
"items": [
|
||||
{
|
||||
"label": "Template interpreter",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q3 2025",
|
||||
"notes": "Execute payment templates from QR/links."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Security protocols",
|
||||
"color": "#22c55e",
|
||||
"items": [
|
||||
{
|
||||
"label": "Secure enclave + recovery",
|
||||
"start": "Q1 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Biometrics; social recovery flows."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Notification system",
|
||||
"items": [
|
||||
{
|
||||
"label": "Push + inbox",
|
||||
"start": "Q3 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Background sync and actionable notifications."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Android / Apple integration",
|
||||
"color": "#10b981",
|
||||
"items": [
|
||||
{
|
||||
"label": "Platform bridges",
|
||||
"start": "Q3 2025",
|
||||
"end": "Q1 2026",
|
||||
"notes": "Share sheets, intents, universal links."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wallet sync server",
|
||||
"items": [
|
||||
{
|
||||
"label": "Sync & backup",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Multi-device state sync; encrypted backups."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Demos",
|
||||
"color": "#f59e0b",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "Demo app 1",
|
||||
"color": "#d97706",
|
||||
"items": [
|
||||
{
|
||||
"label": "MVP + auth",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q3 2025",
|
||||
"notes": "Starter app with login and payments."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Demo templates",
|
||||
"items": [
|
||||
{
|
||||
"label": "Common payment flows",
|
||||
"start": "Q3 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Subscriptions, refunds, and escrow."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Documentation",
|
||||
"color": "#60a5fa",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "Docs",
|
||||
"items": [
|
||||
{
|
||||
"label": "Developer docs",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Concepts, guides, API refs, examples."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Blog",
|
||||
"color": "#3b82f6",
|
||||
"items": [
|
||||
{
|
||||
"label": "Monthly updates",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Roadmap progress & deep dives."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Marketing announcement",
|
||||
"color": "#93c5fd",
|
||||
"items": [
|
||||
{
|
||||
"label": "v0.001 launch comms",
|
||||
"start": "Q4 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Press kit, partners, social plan."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Public consultation",
|
||||
"color": "#8b5cf6",
|
||||
"subcategories": [
|
||||
{
|
||||
"name": "RFCs & governance",
|
||||
"color": "#7c3aed",
|
||||
"items": [
|
||||
{
|
||||
"label": "Open RFCs",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q3 2025",
|
||||
"notes": "Drafts for primitives, JSON protocol, and sync."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Community calls",
|
||||
"items": [
|
||||
{
|
||||
"label": "Monthly calls",
|
||||
"start": "Q2 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "AMA + roadmap checkpoints."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Validator onboarding",
|
||||
"color": "#6d28d9",
|
||||
"items": [
|
||||
{
|
||||
"label": "Validator program",
|
||||
"start": "Q3 2025",
|
||||
"end": "Q4 2025",
|
||||
"notes": "Eligibility, staking requirements, and tooling."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
],
|
||||
|
||||
"changelog": [
|
||||
{
|
||||
"date": "2025-09-18",
|
||||
"text": "Playground prototype shipped internally.",
|
||||
"notes": "Supports sharing runs via URL; rough edges expected."
|
||||
},
|
||||
{
|
||||
"date": "2025-09-10",
|
||||
"text": "Primitives spec v1 finalized.",
|
||||
"notes": "Breaking changes locked; migration guide drafted."
|
||||
},
|
||||
{
|
||||
"date": "2025-08-20",
|
||||
"text": "XO Engine scheduler prototype complete.",
|
||||
"notes": "Deterministic tick loop under load tests."
|
||||
},
|
||||
{
|
||||
"date": "2025-07-12",
|
||||
"text": "Docs site scaffold deployed.",
|
||||
"notes": "Nav, theming, and live search integrated."
|
||||
},
|
||||
{
|
||||
"date": "2025-06-01",
|
||||
"text": "Roadmap tracks created for Stack, Wallet, Demos, Documentation, and Public consultation.",
|
||||
"notes": "Initial planning artifacts and owners assigned."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
---
|
||||
layout: page
|
||||
sidebar: false
|
||||
aside: false
|
||||
---
|
||||
|
||||
<div class="rm-page">
|
||||
<h1 class="rm-title">XO Roadmap</h1>
|
||||
<p class="rm-instructions">
|
||||
Drag the slider to pan the timeline.
|
||||
Click bars, milestones, or releases for details.
|
||||
</p>
|
||||
|
||||
<div class="rm-topbar">
|
||||
<div>
|
||||
<label class="rm-hint">Slide timeline:</label>
|
||||
<input type="range" id="rm-slider" min="0" max="0" step="1" value="0" disabled>
|
||||
<span class="rm-hint" id="rm-posLabel">(loading…)</span>
|
||||
</div>
|
||||
<span class="rm-hint" id="rm-sliderHint"></span>
|
||||
<span class="rm-error" id="rm-err"></span>
|
||||
</div>
|
||||
|
||||
<div class="rm-wrap">
|
||||
<div class="rm-roadgrid" id="rm-roadgrid">
|
||||
<div style="padding:16px" class="rm-hint">Loading roadmap…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Changelog -->
|
||||
<div class="rm-changelog" id="rm-changelog" style="display:none">
|
||||
<div class="rm-chg-head">
|
||||
<h2>Changelog</h2>
|
||||
<span class="rm-hint">Most recent first</span>
|
||||
</div>
|
||||
<div class="rm-chg-list" id="rm-chgList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="rm-modal" id="rm-modal" role="dialog" aria-modal="true" aria-labelledby="rm-modalTitle">
|
||||
<div class="rm-modal-card" role="document" tabindex="-1">
|
||||
<div class="rm-modal-head">
|
||||
<div class="rm-modal-title" id="rm-modalTitle"></div>
|
||||
<button class="rm-btn" id="rm-modalClose" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="rm-modal-body" id="rm-modalBody"></div>
|
||||
<div class="rm-modal-actions">
|
||||
<button class="rm-btn rm-primary" id="rm-modalOk">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import rawCandidate from './roadmap-data.md?raw'
|
||||
|
||||
/* ---------- tolerant JSON-from-Markdown loader ---------- */
|
||||
async function loadDataText() {
|
||||
if (typeof rawCandidate === 'string' && rawCandidate.trim().length) return rawCandidate
|
||||
try {
|
||||
const res = await fetch('roadmap-data.md', { cache: 'no-cache' })
|
||||
if (res.ok) return await res.text()
|
||||
} catch {}
|
||||
throw new Error('Could not read roadmap-data.md (import and fetch both failed).')
|
||||
}
|
||||
|
||||
function parseJsonFromMarkdown(mdText){
|
||||
const md = String(mdText ?? '').replace(/\r\n/g, '\n').trim();
|
||||
if (md.startsWith('{') || md.startsWith('[')) return JSON.parse(md);
|
||||
|
||||
const specific = [
|
||||
/```[ \t]*json(?:c|5)?[ \t]*\n([\s\S]*?)\n```/i,
|
||||
/~~~[ \t]*json(?:c|5)?[ \t]*\n([\s\S]*?)\n~~~/i
|
||||
];
|
||||
for (const re of specific) {
|
||||
const m = re.exec(md);
|
||||
if (m) return JSON.parse(m[1]);
|
||||
}
|
||||
|
||||
const anyFence = /(```+|~~~+)[^\n]*\n([\s\S]*?)\n\1/gm;
|
||||
let m;
|
||||
while ((m = anyFence.exec(md))) {
|
||||
const content = m[2].trim();
|
||||
if (content.startsWith('{') || content.startsWith('[')) return JSON.parse(content);
|
||||
}
|
||||
|
||||
throw new Error('No JSON found in roadmap-data.md. Use raw JSON or a ```json fence.');
|
||||
}
|
||||
|
||||
/* ---------- quarter helpers (loose & robust) ---------- */
|
||||
function parseQuarter(q) {
|
||||
const s = String(q ?? '').trim();
|
||||
let m = /^Q([1-4])\s+(\d{4})$/.exec(s);
|
||||
if (m) return { q:+m[1], y:+m[2] };
|
||||
m = /^Q([1-4])\s+(\d{2})$/.exec(s);
|
||||
if (m) {
|
||||
const yy = +m[2];
|
||||
const full = yy <= 69 ? 2000 + yy : 1900 + yy;
|
||||
return { q:+m[1], y:full };
|
||||
}
|
||||
throw new Error('Bad quarter: ' + q);
|
||||
}
|
||||
const normQuarter = (q) => { const {q:qq,y} = parseQuarter(q); return `Q${qq} ${y}`; };
|
||||
const cmpQuarter = (a,b)=>{const A=parseQuarter(a),B=parseQuarter(b);return A.y===B.y?A.q-B.q:A.y-B.y;};
|
||||
const nextQuarter = q => { const {q:qq,y}=parseQuarter(q); return qq===4 ? `Q1 ${y+1}` : `Q${qq+1} ${y}`; };
|
||||
const spanQuarters = (s,e)=>{const out=[];let cur=normQuarter(s), end=normQuarter(e); while(true){out.push(cur); if(cur===end)break; cur=nextQuarter(cur);} return out;};
|
||||
const clamp = (x,a,b)=>Math.max(a,Math.min(b,x));
|
||||
function shortQuarterLabel(q){
|
||||
if (typeof window!=='undefined' && window.matchMedia('(max-width: 520px)').matches) {
|
||||
const { q:qq, y } = parseQuarter(q);
|
||||
return `Q${qq} '${String(y).slice(-2)}`;
|
||||
}
|
||||
return normQuarter(q);
|
||||
}
|
||||
function currentQuarterString(){
|
||||
const d=new Date();
|
||||
const m=d.getUTCMonth();
|
||||
const y=d.getUTCFullYear();
|
||||
const q=Math.floor(m/3)+1;
|
||||
return `Q${q} ${y}`;
|
||||
}
|
||||
function quarterStartDate(qstr){
|
||||
const {q,y} = parseQuarter(qstr);
|
||||
const month=(q-1)*3;
|
||||
return new Date(Date.UTC(y, month, 1));
|
||||
}
|
||||
|
||||
/* ---------- color helpers (subcategory shading) ---------- */
|
||||
/* Accept #rrggbb or #rgb; return {h,s,l} 0..360 / 0..1 / 0..1 */
|
||||
function hexToHsl(hex){
|
||||
let s = String(hex||'').trim();
|
||||
if(!s) throw new Error('no hex');
|
||||
if(s.startsWith('var(')) throw new Error('css var not supported in shade');
|
||||
if(s[0]==='#') s = s.slice(1);
|
||||
if(s.length===3) s = s.split('').map(c=>c+c).join('');
|
||||
if(s.length!==6) throw new Error('bad hex');
|
||||
const r = parseInt(s.slice(0,2),16)/255;
|
||||
const g = parseInt(s.slice(2,4),16)/255;
|
||||
const b = parseInt(s.slice(4,6),16)/255;
|
||||
const max = Math.max(r,g,b), min = Math.min(r,g,b);
|
||||
let h, l = (max+min)/2, d = max-min, sH=0;
|
||||
if(d===0){ h=0; sH=0; }
|
||||
else{
|
||||
sH = l>0.5 ? d/(2-max-min) : d/(max+min);
|
||||
switch(max){
|
||||
case r: h=(g-b)/d + (g<b?6:0); break;
|
||||
case g: h=(b-r)/d + 2; break;
|
||||
case b: h=(r-g)/d + 4; break;
|
||||
}
|
||||
h*=60;
|
||||
}
|
||||
return {h, s:sH, l};
|
||||
}
|
||||
function hslToHex(h,s,l){
|
||||
const c = (1 - Math.abs(2*l-1))*s;
|
||||
const x = c*(1 - Math.abs((h/60)%2 - 1));
|
||||
const m = l - c/2;
|
||||
let r=0,g=0,b=0;
|
||||
if(0<=h && h<60){ r=c; g=x; }
|
||||
else if(60<=h && h<120){ r=x; g=c; }
|
||||
else if(120<=h && h<180){ g=c; b=x; }
|
||||
else if(180<=h && h<240){ g=x; b=c; }
|
||||
else if(240<=h && h<300){ r=x; b=c; }
|
||||
else{ r=c; b=x; }
|
||||
const rr = Math.round((r+m)*255).toString(16).padStart(2,'0');
|
||||
const gg = Math.round((g+m)*255).toString(16).padStart(2,'0');
|
||||
const bb = Math.round((b+m)*255).toString(16).padStart(2,'0');
|
||||
return `#${rr}${gg}${bb}`;
|
||||
}
|
||||
/* Slight shade from a base hex color, stable per subcategory index */
|
||||
function shadeFromBaseHex(baseHex, index=0){
|
||||
try{
|
||||
const {h,s,l} = hexToHsl(baseHex);
|
||||
// create a small, pleasant offset based on index (cycles every 5)
|
||||
const i = (index % 5 + 5) % 5;
|
||||
const lDelta = [-0.06, -0.02, 0.02, 0.06, 0.1][i]; // lightness tweak
|
||||
const sDelta = [-0.05, -0.03, 0, 0.03, 0.05][i]; // saturation tweak
|
||||
const nh = h;
|
||||
const ns = clamp(s + sDelta, 0, 1);
|
||||
const nl = clamp(l + lDelta, 0, 1);
|
||||
return hslToHex(nh, ns, nl);
|
||||
}catch{
|
||||
return baseHex; // fallback to base
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- globals ---------- */
|
||||
let DATA=null;
|
||||
let ALL_QS=[];
|
||||
let startIdx=0;
|
||||
let winSize=6;
|
||||
|
||||
/* dynamic window size based on available width */
|
||||
function getLabelWidthPx(rmRoot){
|
||||
const val = getComputedStyle(rmRoot).getPropertyValue('--label-w').trim() || '220px';
|
||||
const n = parseFloat(val);
|
||||
return Number.isFinite(n) ? n : 220;
|
||||
}
|
||||
function computeWinSize(rmRoot){
|
||||
const containerWidth = rmRoot.querySelector('.rm-wrap')?.clientWidth || window.innerWidth;
|
||||
const minPx = containerWidth >= 1200 ? 170 : containerWidth >= 1000 ? 160 : containerWidth >= 700 ? 130 : containerWidth >= 520 ? 110 : 95;
|
||||
return Math.max(3, Math.floor((containerWidth - getLabelWidthPx(rmRoot)) / minPx));
|
||||
}
|
||||
|
||||
/* ---------- modal wiring ---------- */
|
||||
function wireModal(rmRoot){
|
||||
const modalEl = rmRoot.querySelector('#rm-modal');
|
||||
const modalCard = rmRoot.querySelector('.rm-modal-card');
|
||||
const modalTit = rmRoot.querySelector('#rm-modalTitle');
|
||||
const modalBody = rmRoot.querySelector('#rm-modalBody');
|
||||
const btnClose = rmRoot.querySelector('#rm-modalClose');
|
||||
const btnOk = rmRoot.querySelector('#rm-modalOk');
|
||||
|
||||
const api = {
|
||||
open(title, lines){
|
||||
if (!modalEl) return;
|
||||
modalTit.textContent = title || '';
|
||||
modalBody.innerHTML = '';
|
||||
(lines || [])
|
||||
.filter(x => x && String(x[1] ?? '').trim() !== '')
|
||||
.forEach(([label, value])=>{
|
||||
const p=document.createElement('p');
|
||||
const strong=document.createElement('strong'); strong.textContent = label + ': ';
|
||||
const span=document.createElement('span'); span.textContent = value;
|
||||
p.appendChild(strong); p.appendChild(span);
|
||||
modalBody.appendChild(p);
|
||||
});
|
||||
modalEl.classList.add('show');
|
||||
document.body.style.overflow = 'hidden';
|
||||
modalCard?.focus();
|
||||
},
|
||||
close(){
|
||||
if (!modalEl) return;
|
||||
modalEl.classList.remove('show');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
};
|
||||
|
||||
btnClose?.addEventListener('click', api.close);
|
||||
btnOk?.addEventListener('click', api.close);
|
||||
window.addEventListener('keydown', e=>{ if(e.key==='Escape') api.close(); });
|
||||
modalEl?.addEventListener('click', e=>{
|
||||
if (!e.target.closest('.rm-modal-card')) api.close();
|
||||
});
|
||||
|
||||
return api;
|
||||
}
|
||||
|
||||
/* ---------- collect global quarter range ---------- */
|
||||
function collectQuarterRange(data){
|
||||
const pushQ = (arr, q) => {
|
||||
try { if(q!=null && String(q).trim()) arr.push(normQuarter(q)); } catch {}
|
||||
};
|
||||
const qs = [];
|
||||
|
||||
(data.milestones||[]).forEach(m => pushQ(qs, m.quarter));
|
||||
(data.releases||[]).forEach(r => pushQ(qs, r.quarter));
|
||||
(data.categories||[]).forEach(cat=>{
|
||||
(cat.subcategories||[]).forEach(sc=>{
|
||||
(sc.items||[]).forEach(it=>{
|
||||
try{
|
||||
let s = normQuarter(it.start);
|
||||
let e = normQuarter(it.end);
|
||||
if (cmpQuarter(s, e) > 0){ const t=s; s=e; e=t; }
|
||||
pushQ(qs, s); pushQ(qs, e);
|
||||
}catch{}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!qs.length) return null;
|
||||
qs.sort(cmpQuarter);
|
||||
return { min: qs[0], max: qs[qs.length-1] };
|
||||
}
|
||||
|
||||
/* ---------- boot ---------- */
|
||||
onMounted(async () => {
|
||||
const rmRoot = document.querySelector('.rm-page');
|
||||
if (!rmRoot) return;
|
||||
|
||||
const grid = rmRoot.querySelector('#rm-roadgrid');
|
||||
const slider = rmRoot.querySelector('#rm-slider');
|
||||
const posLbl = rmRoot.querySelector('#rm-posLabel');
|
||||
const hint = rmRoot.querySelector('#rm-sliderHint');
|
||||
const errBox = rmRoot.querySelector('#rm-err');
|
||||
|
||||
const modal = wireModal(rmRoot);
|
||||
|
||||
function render(){
|
||||
if(!DATA || ALL_QS.length===0) return;
|
||||
|
||||
const quarters = ALL_QS.slice(startIdx, startIdx + winSize);
|
||||
const qcount = quarters.length;
|
||||
|
||||
posLbl.textContent = quarters[0] + ' → ' + quarters[quarters.length-1];
|
||||
|
||||
grid.innerHTML='';
|
||||
|
||||
const LABEL_W = getLabelWidthPx(rmRoot);
|
||||
const MIN_COL = 140;
|
||||
grid.style.minWidth = (LABEL_W + qcount * MIN_COL) + 'px';
|
||||
|
||||
const header=document.createElement('div');
|
||||
header.className='rm-quarters';
|
||||
header.style.gridTemplateColumns = `var(--label-w) repeat(${qcount}, minmax(${MIN_COL}px, 1fr))`;
|
||||
header.innerHTML = `<div class="rm-qcell">Timeline</div>` +
|
||||
quarters.map(q=>`<div class="rm-qcell">${shortQuarterLabel(q)}</div>`).join('');
|
||||
grid.appendChild(header);
|
||||
|
||||
const leftPct = qi => (qi/qcount)*100;
|
||||
const widthPctFromIdx = (si,ei) => ((ei - si + 1)/qcount)*100;
|
||||
|
||||
function clampToWindow(start,end){
|
||||
let sIdx = ALL_QS.indexOf(normQuarter(start));
|
||||
let eIdx = ALL_QS.indexOf(normQuarter(end));
|
||||
if (sIdx === -1 || eIdx === -1) return null;
|
||||
const winS = startIdx;
|
||||
const winE = startIdx + qcount - 1;
|
||||
const si = Math.max(sIdx, winS);
|
||||
const ei = Math.min(eIdx, winE);
|
||||
if(ei < winS || si > winE) return null;
|
||||
return { si: si - winS, ei: ei - winS };
|
||||
}
|
||||
|
||||
/* CATEGORIES */
|
||||
(DATA.categories||[]).forEach(cat=>{
|
||||
const catColor = cat.color || 'var(--vp-c-brand-1, #3b82f6)';
|
||||
|
||||
const subs = (cat.subcategories||[]);
|
||||
const ROW_H = 44;
|
||||
let totalRows = 0;
|
||||
const layouts = [];
|
||||
|
||||
subs.forEach((sc, scIdx)=>{
|
||||
const segments = [];
|
||||
(sc.items||[]).forEach(it=>{
|
||||
let s = normQuarter(it.start);
|
||||
let e = normQuarter(it.end);
|
||||
if (cmpQuarter(s,e) > 0){ const t=s; s=e; e=t; }
|
||||
|
||||
const clip = clampToWindow(s, e);
|
||||
if(!clip) return;
|
||||
segments.push({
|
||||
si: clip.si, ei: clip.ei,
|
||||
label: it.label, start: s, end: e,
|
||||
notes: it.notes || '',
|
||||
scName: sc.name, catName: cat.name,
|
||||
scIndex: scIdx
|
||||
});
|
||||
});
|
||||
|
||||
segments.sort((a,b)=> a.si===b.si ? a.ei-b.ei : a.si-b.si);
|
||||
const rowEnds = [];
|
||||
segments.forEach(seg=>{
|
||||
let placed=false;
|
||||
for(let r=0;r<rowEnds.length;r++){
|
||||
if(seg.si > rowEnds[r]){ seg.row=r; rowEnds[r]=seg.ei; placed=true; break; }
|
||||
}
|
||||
if(!placed){ seg.row=rowEnds.length; rowEnds.push(seg.ei); }
|
||||
});
|
||||
|
||||
// empty subcategories contribute 0 rows (no gap)
|
||||
const rowsNeeded = rowEnds.length;
|
||||
layouts.push({ sc, rowsNeeded, segments });
|
||||
totalRows += rowsNeeded;
|
||||
});
|
||||
|
||||
const laneHeight = Math.max(52, 8 + totalRows*ROW_H + 6);
|
||||
const row=document.createElement('div');
|
||||
row.className='rm-row';
|
||||
row.innerHTML = `<div class="rm-label"><span class="rm-catdot" style="background:${catColor}"></span>${cat.name}</div><div class="rm-lane rm-grid" style="min-height:${laneHeight}px"></div>`;
|
||||
grid.appendChild(row);
|
||||
const lane=row.lastElementChild;
|
||||
lane.style.setProperty('--qcount', qcount);
|
||||
|
||||
let baseRowOffset = 0;
|
||||
layouts.forEach(({sc, rowsNeeded, segments}, scIdx)=>{
|
||||
// Use explicit subcategory color if present, otherwise derive a shade from cat color
|
||||
const subColor = (sc.color && typeof sc.color === 'string' && sc.color.trim())
|
||||
? sc.color.trim()
|
||||
: shadeFromBaseHex(catColor, scIdx);
|
||||
|
||||
segments.forEach(seg=>{
|
||||
const left = leftPct(seg.si);
|
||||
const width = widthPctFromIdx(seg.si, seg.ei);
|
||||
const top = 8 + (baseRowOffset + seg.row) * 44;
|
||||
|
||||
const bar=document.createElement('div');
|
||||
bar.className='rm-bar';
|
||||
bar.style.background=subColor;
|
||||
bar.style.left=`${left}%`;
|
||||
bar.style.width=`${width}%`;
|
||||
bar.style.top=`${top}px`;
|
||||
bar.textContent=`${seg.scName} — ${seg.label}`;
|
||||
bar.addEventListener('click', ()=>{
|
||||
modal.open(seg.label, [
|
||||
['Category', seg.catName],
|
||||
['Subcategory', seg.scName],
|
||||
['Timeline', `${seg.start} → ${seg.end}`],
|
||||
['Notes', seg.notes]
|
||||
]);
|
||||
});
|
||||
lane.appendChild(bar);
|
||||
});
|
||||
baseRowOffset += rowsNeeded;
|
||||
});
|
||||
});
|
||||
|
||||
/* MILESTONES */
|
||||
if((DATA.milestones||[]).length){
|
||||
const M_ROW_H = 22;
|
||||
const quartersSet = new Set(quarters.map(q=>normQuarter(q)));
|
||||
const pins = (DATA.milestones||[])
|
||||
.map(m => {
|
||||
try { return { quarter: normQuarter(m.quarter), label: `★ ${m.label}`, rawLabel: m.label, notes: m.notes || '' }; }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter(p => quartersSet.has(p.quarter))
|
||||
.map(p => ({...p, qi: quarters.indexOf(p.quarter)}));
|
||||
|
||||
const rows = [];
|
||||
pins.forEach(p=>{
|
||||
let placed=false;
|
||||
for (let r=0;r<rows.length;r++){
|
||||
const last = rows[r][rows[r].length-1];
|
||||
if (p.qi > last.qi) { rows[r].push(p); placed=true; break; }
|
||||
}
|
||||
if (!placed) rows.push([p]);
|
||||
});
|
||||
|
||||
const laneHeight = Math.max(34, 8 + rows.length * M_ROW_H + 6);
|
||||
const row=document.createElement('div');
|
||||
row.className='rm-row';
|
||||
row.innerHTML=`<div class="rm-label">Milestones</div><div class="rm-lane rm-grid" style="min-height:${laneHeight}px"></div>`;
|
||||
grid.appendChild(row);
|
||||
const lane=row.lastElementChild;
|
||||
lane.style.setProperty('--qcount', qcount);
|
||||
|
||||
rows.forEach((rowPins, ri)=>{
|
||||
rowPins.forEach(p=>{
|
||||
const pin=document.createElement('div');
|
||||
pin.className='rm-pin rm-milestone';
|
||||
pin.style.left = `${(p.qi/qcount)*100 + (100/qcount)/2}%`;
|
||||
pin.style.top = `${8 + ri * M_ROW_H}px`;
|
||||
pin.textContent = p.label;
|
||||
pin.addEventListener('click', ()=>{
|
||||
modal.open(p.rawLabel, [['Type','Milestone'],['Quarter', p.quarter], ['Notes', p.notes]]);
|
||||
});
|
||||
lane.appendChild(pin);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* RELEASES */
|
||||
{
|
||||
const R_ROW_H = 22;
|
||||
const quartersSet = new Set(quarters.map(q=>normQuarter(q)));
|
||||
const pins = (DATA.releases||[])
|
||||
.map(r => {
|
||||
try { return { quarter: normQuarter(r.quarter), label: `♦ ${r.label}`, rawLabel:r.label, notes: r.notes || '' }; }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter(p => quartersSet.has(p.quarter))
|
||||
.map(p => ({...p, qi: quarters.indexOf(p.quarter)}));
|
||||
|
||||
const rows = [];
|
||||
pins.forEach(p=>{
|
||||
let placed=false;
|
||||
for (let r=0;r<rows.length;r++){
|
||||
const last = rows[r][rows[r].length-1];
|
||||
if (p.qi > last.qi) { rows[r].push(p); placed=true; break; }
|
||||
}
|
||||
if (!placed) rows.push([p]);
|
||||
});
|
||||
|
||||
const laneHeight = Math.max(34, 8 + rows.length * R_ROW_H + 6);
|
||||
const row=document.createElement('div');
|
||||
row.className='rm-row';
|
||||
row.innerHTML=`<div class="rm-label">Releases</div><div class="rm-lane rm-grid" style="min-height:${laneHeight}px"></div>`;
|
||||
grid.appendChild(row);
|
||||
const lane=row.lastElementChild;
|
||||
lane.style.setProperty('--qcount', qcount);
|
||||
|
||||
rows.forEach((rowPins, ri)=>{
|
||||
rowPins.forEach(p=>{
|
||||
const pin=document.createElement('div');
|
||||
pin.className='rm-pin rm-release';
|
||||
pin.style.left = `${(p.qi/qcount)*100 + (100/qcount)/2}%`;
|
||||
pin.style.top = `${8 + ri * R_ROW_H}px`;
|
||||
pin.textContent = p.label;
|
||||
pin.addEventListener('click', ()=>{
|
||||
modal.open(p.rawLabel, [['Type','Release'],['Quarter', p.quarter], ['Notes', p.notes]]);
|
||||
});
|
||||
lane.appendChild(pin);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Update slider state each render
|
||||
const max = Math.max(0, ALL_QS.length - winSize);
|
||||
slider.max = String(max);
|
||||
slider.disabled = max === 0;
|
||||
hint.textContent = max === 0 ? '(all visible)' : '';
|
||||
}
|
||||
|
||||
/* ---------- changelog ---------- */
|
||||
function renderChangelog(){
|
||||
const holder = rmRoot.querySelector('#rm-changelog');
|
||||
const listEl = rmRoot.querySelector('#rm-chgList');
|
||||
const entries = (DATA && Array.isArray(DATA.changelog)) ? [...DATA.changelog] : [];
|
||||
|
||||
if(!entries.length){
|
||||
holder.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
holder.style.display = 'block';
|
||||
|
||||
function toDate(e){
|
||||
if(e.date){ const d=new Date(e.date); if(!isNaN(d)) return d; }
|
||||
if(e.quarter){ try{ return quarterStartDate(e.quarter);}catch{} }
|
||||
return new Date(0);
|
||||
}
|
||||
entries.sort((a,b)=> toDate(b) - toDate(a));
|
||||
|
||||
listEl.innerHTML = '';
|
||||
entries.forEach((e)=>{
|
||||
const row=document.createElement('div');
|
||||
row.className='rm-chg-item';
|
||||
|
||||
const dateLabel = e.date ? new Date(e.date).toISOString().slice(0,10) :
|
||||
(e.quarter ? normQuarter(e.quarter) : '');
|
||||
row.innerHTML = `
|
||||
<div class="rm-chg-date">${dateLabel}</div>
|
||||
<div class="rm-chg-text">
|
||||
${e.text || ''}
|
||||
${e.notes ? ` — <span class="rm-chg-link">details</span>` : ``}
|
||||
</div>
|
||||
`;
|
||||
const link = row.querySelector('.rm-chg-link');
|
||||
if(link){
|
||||
link.addEventListener('click', ()=>{
|
||||
modal.open('Changelog', [
|
||||
['When', dateLabel || '—'],
|
||||
['Entry', e.text || '—'],
|
||||
['Notes', e.notes || '']
|
||||
]);
|
||||
});
|
||||
}else{
|
||||
row.querySelector('.rm-chg-text').addEventListener('click', ()=>{
|
||||
modal.open('Changelog', [
|
||||
['When', dateLabel || '—'],
|
||||
['Entry', e.text || '—']
|
||||
]);
|
||||
});
|
||||
}
|
||||
listEl.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
try{
|
||||
const rawText = await loadDataText();
|
||||
DATA = parseJsonFromMarkdown(rawText);
|
||||
|
||||
// Theme pin overrides (optional)
|
||||
if(DATA?.colors){
|
||||
if(DATA.colors.release) rmRoot.style.setProperty('--release', DATA.colors.release);
|
||||
if(DATA.colors.milestone) rmRoot.style.setProperty('--milestone', DATA.colors.milestone);
|
||||
}
|
||||
|
||||
// Build full quarter range
|
||||
const range = collectQuarterRange(DATA);
|
||||
if(!range){
|
||||
grid.innerHTML='<div style="padding:16px">No timeline data.</div>';
|
||||
renderChangelog();
|
||||
return;
|
||||
}
|
||||
ALL_QS = spanQuarters(range.min, range.max);
|
||||
|
||||
// Compute initial window size
|
||||
winSize = computeWinSize(rmRoot);
|
||||
|
||||
// Center CURRENT quarter in the window when possible
|
||||
const curQ = currentQuarterString();
|
||||
const curIdx = ALL_QS.indexOf(normQuarter(curQ));
|
||||
const maxStart = Math.max(0, ALL_QS.length - winSize);
|
||||
startIdx = (curIdx === -1) ? 0 : clamp(curIdx - Math.floor(winSize/2), 0, maxStart);
|
||||
|
||||
// Slider handler
|
||||
slider.value = String(startIdx);
|
||||
slider.addEventListener('input', e=>{
|
||||
startIdx = parseInt(e.target.value,10) || 0;
|
||||
render();
|
||||
});
|
||||
|
||||
// Recompute on resize (keep current left label visible)
|
||||
let resizeTimer=null;
|
||||
window.addEventListener('resize', ()=>{
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer=setTimeout(()=>{
|
||||
const prevLeftLabel = ALL_QS[startIdx] || null;
|
||||
winSize = computeWinSize(rmRoot);
|
||||
const maxStart = Math.max(0, ALL_QS.length - winSize);
|
||||
if(prevLeftLabel){
|
||||
const idx = ALL_QS.indexOf(prevLeftLabel);
|
||||
startIdx = idx === -1 ? clamp(startIdx, 0, maxStart) : clamp(idx, 0, maxStart);
|
||||
}else{
|
||||
startIdx = clamp(startIdx, 0, maxStart);
|
||||
}
|
||||
slider.value = String(startIdx);
|
||||
render();
|
||||
}, 120);
|
||||
});
|
||||
|
||||
render();
|
||||
renderChangelog();
|
||||
}catch(e){
|
||||
errBox.textContent = e.message || String(e);
|
||||
grid.innerHTML = `<div style="padding:16px;color:var(--vp-c-danger-1, #ef4444)">Failed to load <code>roadmap-data.md</code>: ${e.message}</div>`;
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ---------- SCOPED styles: only under .rm-page ---------- */
|
||||
.rm-page{
|
||||
/* Follow VitePress theme tokens for light/dark */
|
||||
--bg: var(--vp-c-bg);
|
||||
--bg-soft: var(--vp-c-bg-soft, #f6f6f7);
|
||||
--line: var(--vp-c-divider);
|
||||
--text: var(--vp-c-text-1);
|
||||
--text-2: var(--vp-c-text-2);
|
||||
--release: var(--release, #ef4444);
|
||||
--milestone: var(--milestone, #7c3aed);
|
||||
--label-w: 220px;
|
||||
|
||||
max-width: min(1200px, 92vw);
|
||||
margin: 0 auto;
|
||||
padding: 20px 18px;
|
||||
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial;
|
||||
}
|
||||
|
||||
.rm-title{ margin:0 0 6px; font-size:22px; }
|
||||
.rm-instructions{
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.rm-topbar{ display:flex; gap:14px; align-items:center; flex-wrap:wrap; margin:0 0 12px; }
|
||||
.rm-hint{ font-size:12px; color: var(--text-2); }
|
||||
.rm-error{ color: var(--vp-c-danger-1, #b91c1c); }
|
||||
#rm-posLabel{ min-width:160px; display:inline-block; }
|
||||
#rm-slider{ width:280px; }
|
||||
|
||||
.rm-wrap{
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: var(--bg);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.rm-roadgrid{} /* min-width set in JS */
|
||||
|
||||
.rm-quarters{
|
||||
display:grid;
|
||||
border-bottom:1px solid var(--line);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.rm-qcell{
|
||||
padding:10px 8px; text-align:center; font-weight:600; border-left:1px solid var(--line);
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
}
|
||||
.rm-qcell:first-child{
|
||||
border-left:none; text-align:left; padding-left:14px; color: var(--text-2);
|
||||
border-right:1px solid var(--line);
|
||||
}
|
||||
|
||||
.rm-row{ display:grid; grid-template-columns:var(--label-w) 1fr; border-bottom:1px solid var(--line); }
|
||||
.rm-row:last-child{ border-bottom:none; }
|
||||
.rm-label{
|
||||
display:flex; align-items:center; gap:10px; padding:12px 14px; font-weight:700;
|
||||
background: var(--bg-soft); border-right:1px solid var(--line); white-space:nowrap;
|
||||
}
|
||||
.rm-catdot{
|
||||
width:12px; height:12px; border-radius:9999px; flex:0 0 12px;
|
||||
box-shadow:0 0 0 2px var(--bg), 0 0 0 1px rgba(0,0,0,.1) inset;
|
||||
}
|
||||
.rm-lane{ position:relative; background: var(--bg); }
|
||||
.rm-lane.rm-grid::before{
|
||||
content:""; position:absolute; inset:0;
|
||||
background:repeating-linear-gradient(to right,
|
||||
transparent 0,
|
||||
transparent calc(100%/var(--qcount) - 1px),
|
||||
color-mix(in srgb, var(--line) 100%, transparent 60%) calc(100%/var(--qcount) - 1px),
|
||||
color-mix(in srgb, var(--line) 100%, transparent 60%) calc(100%/var(--qcount)));
|
||||
pointer-events:none;
|
||||
}
|
||||
|
||||
.rm-bar{
|
||||
position:absolute; height:32px; border-radius:8px; display:flex; align-items:center; padding:0 10px;
|
||||
font-weight:600; font-size:13px; color:#fff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
box-shadow:0 1px 0 rgba(0,0,0,.06),0 6px 16px rgba(0,0,0,.06); cursor:pointer;
|
||||
}
|
||||
|
||||
.rm-pin{ position:absolute; transform:translateX(-50%); font-weight:700; white-space:nowrap; background:transparent; cursor:pointer; }
|
||||
.rm-release{ color: var(--release); }
|
||||
.rm-milestone{ color: var(--milestone); }
|
||||
|
||||
.rm-changelog{ margin-top:18px; }
|
||||
.rm-chg-head{ display:flex; align-items:center; gap:10px; margin:12px 0 8px; }
|
||||
.rm-chg-head h2{ margin:0; font-size:18px; }
|
||||
.rm-chg-list{ background: var(--bg); border:1px solid var(--line); border-radius:12px; overflow:hidden; }
|
||||
.rm-chg-item{ display:grid; grid-template-columns:140px 1fr; gap:12px; padding:10px 12px; border-top:1px solid var(--line); align-items:center; }
|
||||
.rm-chg-item:first-child{ border-top:none; }
|
||||
.rm-chg-date{ color: var(--text-2); font-weight:600; white-space:nowrap; }
|
||||
.rm-chg-text{ color: var(--text); }
|
||||
.rm-chg-link{ cursor:pointer; color: var(--vp-c-brand-1, #2563eb); font-weight:600; }
|
||||
|
||||
/* Modal */
|
||||
.rm-modal{ position:fixed; inset:0; background:rgba(0,0,0,.45); display:none; align-items:center; justify-content:center; padding:20px; z-index:9999; }
|
||||
.rm-modal.show{ display:flex; }
|
||||
.rm-modal-card{
|
||||
background: var(--bg); color: var(--text);
|
||||
border:1px solid var(--line); border-radius:12px; max-width:600px; width:min(92vw,600px);
|
||||
box-shadow:0 12px 42px rgba(0,0,0,.25); outline: none; overflow:hidden;
|
||||
}
|
||||
.rm-modal-head{ display:flex; align-items:center; justify-content:space-between; padding:12px 16px; border-bottom:1px solid var(--line); }
|
||||
.rm-modal-title{ font-weight:700; }
|
||||
.rm-modal-body{ padding:16px; }
|
||||
.rm-modal-body p{ margin:8px 0; }
|
||||
.rm-modal-actions{ display:flex; justify-content:flex-end; gap:10px; padding:12px 16px; border-top:1px solid var(--line); }
|
||||
.rm-btn{ appearance:none; border:1px solid var(--line); background: var(--bg-soft); color: var(--text); border-radius:8px; padding:8px 12px; cursor:pointer; }
|
||||
.rm-primary{ background: var(--vp-c-brand-1, #111827); color:#fff; border-color: var(--vp-c-brand-1, #111827); }
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1000px){
|
||||
.rm-page{ max-width: min(1000px, 94vw); }
|
||||
}
|
||||
@media (max-width: 700px){
|
||||
.rm-page{ --label-w:170px; max-width: 96vw; }
|
||||
.rm-qcell{ font-size:13px; padding:8px 6px; }
|
||||
#rm-slider{ width:200px; }
|
||||
}
|
||||
@media (max-width: 520px){
|
||||
.rm-page{ --label-w:140px; max-width: 98vw; padding-left: 12px; padding-right: 12px; }
|
||||
.rm-qcell{ font-size:12px; }
|
||||
.rm-label{ font-size:13px; }
|
||||
.rm-bar{ height:28px; }
|
||||
.rm-chg-item{ grid-template-columns:110px 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -110,12 +110,14 @@ export class BCMRHttp extends BaseBCMR {
|
||||
);
|
||||
|
||||
// Validate the payload body.
|
||||
try{
|
||||
try {
|
||||
const metadata = MetadataRegistrySchema.parse(await response.json());
|
||||
|
||||
return metadata;
|
||||
} catch(error: unknown) {
|
||||
throw new Error(`Unable to parse the metadata registry format. Detailed error: ${error}`);
|
||||
} catch (error: unknown) {
|
||||
throw new Error(
|
||||
`Unable to parse the metadata registry format. Detailed error: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,10 +83,15 @@ export function findAllKeyOcurrences(
|
||||
|
||||
const keyValues: Array<unknown> = [];
|
||||
|
||||
function traverse(currentObj: Record<string | number | symbol, unknown>, depth: number = 0) {
|
||||
function traverse(
|
||||
currentObj: Record<string | number | symbol, unknown>,
|
||||
depth: number = 0,
|
||||
) {
|
||||
// Stop if depth exceeds the amount of 'allowedNestedLevels'.
|
||||
if (depth > allowedNestedLevels)
|
||||
throw new Error(`Maximum nesting level of ${allowedNestedLevels} reached.`);
|
||||
throw new Error(
|
||||
`Maximum nesting level of ${allowedNestedLevels} reached.`,
|
||||
);
|
||||
|
||||
// Stop when find a non object value.
|
||||
if (typeof currentObj !== 'object' || currentObj === null) return;
|
||||
|
||||
@@ -41,6 +41,8 @@ describe('BCMRComposite', () => {
|
||||
test('should fail if cantegoryId is not a registered entry', async () => {
|
||||
const invalidCategoryId = 'asd123';
|
||||
|
||||
await expect(bcmrComposite.getTokenData(invalidCategoryId)).rejects.toThrow();
|
||||
await expect(
|
||||
bcmrComposite.getTokenData(invalidCategoryId),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user