Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ jobs:
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check
- run: pnpm lint:i18n
- run: pnpm test:a11y
- run: pnpm build
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@
"test:a11y": "vitest run src/__tests__/a11y.test.tsx",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint:i18n": "node scripts/lint-i18n.mjs",
"generate:og": "node scripts/generate-og.mjs",
"prepare": "husky"
},
"dependencies": {
"@stellar/stellar-sdk": "^13.3.0",
"@wraith-protocol/sdk": "^1.4.5",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.18.0"
},
"devDependencies": {
Expand Down
30 changes: 30 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions scripts/generate-og.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Post-build script: reads dist/index.html and emits per-locale variants with
* locale-specific <title>, meta description, og:locale, og:title, og:description,
* twitter:title, and twitter:description tags.
*
* Usage: node scripts/generate-og.mjs
* Run after: pnpm build
*
* Output:
* dist/en/index.html
* dist/es/index.html
*/

import { readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import en from '../src/i18n/en.json' assert { type: 'json' };
import es from '../src/i18n/es.json' assert { type: 'json' };

const __dirname = dirname(fileURLToPath(import.meta.url));
const distDir = join(__dirname, '..', 'dist');
const srcHtml = join(distDir, 'index.html');

const locales = [
{ code: 'en', strings: en, ogLocale: 'en_US' },
{ code: 'es', strings: es, ogLocale: 'es_ES' },
];

let html;
try {
html = readFileSync(srcHtml, 'utf8');
} catch {
console.error('dist/index.html not found. Run `pnpm build` first.');
process.exit(1);
}

for (const { code, strings, ogLocale } of locales) {
const og = strings.og;

let out = html;

// <title>
out = out.replace(/<title>[^<]*<\/title>/, `<title>${og.siteTitle}</title>`);

// meta description
out = out.replace(
/(<meta\s+name="description"\s+content=")[^"]*(")/,
`$1${og.metaDescription}$2`,
);

// og:locale
out = out.replace(
/(<meta\s+property="og:locale"\s+content=")[^"]*(")/,
`$1${ogLocale}$2`,
);

// og:title
out = out.replace(
/(<meta\s+property="og:title"\s+content=")[^"]*(")/,
`$1${og.ogTitle}$2`,
);

// og:description
out = out.replace(
/(<meta\s+property="og:description"\s+content=")[^"]*(")/,
`$1${og.ogDescription}$2`,
);

// twitter:title
out = out.replace(
/(<meta\s+name="twitter:title"\s+content=")[^"]*(")/,
`$1${og.ogTitle}$2`,
);

// twitter:description
out = out.replace(
/(<meta\s+name="twitter:description"\s+content=")[^"]*(")/,
`$1${og.ogDescription}$2`,
);

// html lang attribute
out = out.replace(/(<html\s[^>]*lang=")[^"]*(")/i, `$1${code}$2`);

const outDir = join(distDir, code);
mkdirSync(outDir, { recursive: true });
writeFileSync(join(outDir, 'index.html'), out, 'utf8');
console.log(`generate:og — wrote dist/${code}/index.html`);
}

console.log('generate:og — done.');
62 changes: 62 additions & 0 deletions scripts/lint-i18n.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Scans src/components/*.tsx for JSX text nodes that look like human-readable
* multi-word strings not wrapped in a t() call. Exits 1 if any are found.
*
* Heuristic: a JSX text node that:
* - Contains two or more words (letters only, no < > { } characters)
* - Is not purely whitespace, punctuation, or a single token like "↗"
* is flagged as a potential hardcoded string.
*
* False-positives in code blocks / aria strings passed directly are expected
* to be suppressed by keeping them inside t() calls.
*/

import { readFileSync, readdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const componentsDir = join(__dirname, '..', 'src', 'components');

// Matches bare JSX text between tags: >Some readable text<
// Must contain at least two letter-words separated by a space.
// We skip lines that are inside attribute strings (attr="…") or t('…') calls.
const JSX_TEXT_RE = />([^<>{}\n]+)</g;
// A "human-readable" fragment has at least two alphabetic words
const MULTI_WORD_RE = /[A-Za-z]{2,}[\sÀ-ɏ]+[A-Za-z]{2,}/;

const files = readdirSync(componentsDir).filter((f) => f.endsWith('.tsx'));

let violations = 0;

for (const file of files) {
const filePath = join(componentsDir, file);
const src = readFileSync(filePath, 'utf8');
const lines = src.split('\n');

lines.forEach((line, idx) => {
// Skip lines that already call t(…) — they're intentionally translated
if (line.includes('t(') || line.includes('{t(')) return;
// Skip import lines, comments, className strings, aria- attributes
if (/^\s*(import|\/\/|\/\*|\*|className=|aria-|title=)/.test(line)) return;

let match;
const re = new RegExp(JSX_TEXT_RE.source, 'g');
while ((match = re.exec(line)) !== null) {
const text = match[1].trim();
if (MULTI_WORD_RE.test(text)) {
console.error(
`${file}:${idx + 1}: hardcoded JSX string: "${text}"`,
);
violations++;
}
}
});
}

if (violations > 0) {
console.error(`\n${violations} hardcoded string(s) found. Wrap them in t().`);
process.exit(1);
} else {
console.log(`lint:i18n — no hardcoded JSX strings found in ${files.length} component(s).`);
}
57 changes: 28 additions & 29 deletions src/components/Architecture.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,43 @@
import { useTranslation } from 'react-i18next';
import { useInView } from '../hooks/useInView';

const steps = [
{
num: '1',
label: 'SENDER',
description:
"Derives a stealth address from the recipient's meta-address using a fresh ephemeral key.",
code: 'deriveStealthAddress()',
border: true,
},
{
num: '2',
label: 'ANNOUNCER',
description:
'On-chain announcement event logs the payment so the receiver can find it without interaction.',
code: 'Announcement event',
border: true,
},
{
num: '3',
label: 'RECEIVER',
description:
'Scans announcements with their viewing key, derives the spending key, and withdraws at will.',
code: 'scan() → withdraw()',
border: false,
},
];

export default function Architecture() {
const { t } = useTranslation();
const { ref, isInView } = useInView({ threshold: 0.1 });

const steps = [
{
num: '1',
label: t('architecture.sender.label'),
description: t('architecture.sender.description'),
code: 'deriveStealthAddress()',
border: true,
},
{
num: '2',
label: t('architecture.announcer.label'),
description: t('architecture.announcer.description'),
code: 'Announcement event',
border: true,
},
{
num: '3',
label: t('architecture.receiver.label'),
description: t('architecture.receiver.description'),
code: 'scan() → withdraw()',
border: false,
},
];

return (
<section ref={ref} className="border-t border-outline-variant-30 px-6 py-24 md:px-12">
<div className="mx-auto flex max-w-[1344px] flex-col items-center gap-12">
<div className="flex flex-col items-center gap-3" data-reveal={isInView}>
<span className="font-mono text-[10px] font-semibold uppercase tracking-[2px] text-outline">
How It Works
{t('architecture.eyebrow')}
</span>
<h2 className="font-heading text-[28px] font-bold tracking-[-1.2px] text-on-surface sm:text-[40px]">
Announce. Derive. Scan.
{t('architecture.heading')}
</h2>
</div>

Expand Down
Loading
Loading