├── .npmrc ├── .env.example ├── src ├── pages │ ├── 404.astro │ ├── @[username].astro │ └── index.astro ├── components │ ├── profile │ │ ├── index.ts │ │ ├── profile.tsx │ │ └── profile.module.css │ ├── container │ │ ├── index.ts │ │ ├── container.module.css │ │ └── container.tsx │ ├── section.astro │ ├── hero.astro │ ├── what.astro │ └── steps.astro ├── styles │ ├── variables │ │ ├── index.css │ │ ├── typography.css │ │ └── color.css │ ├── global.css │ ├── base │ │ └── base.css │ └── fonts.css ├── env.d.ts ├── helpers │ ├── array.ts │ ├── number.ts │ ├── string.ts │ ├── styles.ts │ ├── slug.ts │ ├── date.ts │ └── random.ts ├── database │ ├── drizzle.ts │ └── schema.ts ├── lib │ ├── source.ts │ └── profile.ts ├── layouts │ └── layout.astro └── validators │ └── profile.ts ├── .eslintignore ├── .prettierignore ├── .stylelintignore ├── public ├── og.png ├── robots.txt ├── fonts │ ├── geist-v1-latin-500.woff2 │ ├── geist-v16-latin-500.woff2 │ ├── lora-v36-latin-500.woff2 │ ├── geist-v1-latin-regular.woff2 │ ├── lora-v36-latin-regular.woff2 │ ├── geist-v16-latin-regular.woff2 │ ├── ibm-plex-mono-v19-latin-500.woff2 │ ├── ibm-plex-mono-v19-latin-italic.woff2 │ ├── ibm-plex-mono-v19-latin-regular.woff2 │ └── ibm-plex-mono-v19-latin-500italic.woff2 ├── favicon.svg └── schema.json ├── .czrc ├── .commitlintrc.json ├── .editorconfig ├── .husky ├── pre-commit └── commit-msg ├── docs ├── README.md ├── setup.md └── schema.md ├── postcss.config.cjs ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── .lintstagedrc.json ├── migrations ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_dusty_chat.sql ├── tsconfig.json ├── .gitignore ├── drizzle.config.ts ├── astro.config.mjs ├── .stylelintrc.json ├── scripts └── build-schema.ts ├── .versionrc.json ├── template └── bio.json ├── LICENSE ├── README.md ├── .eslintrc.json └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL= 2 | -------------------------------------------------------------------------------- /src/pages/404.astro: -------------------------------------------------------------------------------- 1 |

404

2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .output/ 4 | -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/og.png -------------------------------------------------------------------------------- /src/components/profile/index.ts: -------------------------------------------------------------------------------- 1 | export { Profile } from './profile'; 2 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "./node_modules/cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /src/components/container/index.ts: -------------------------------------------------------------------------------- 1 | export { Container } from './container'; 2 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /src/styles/variables/index.css: -------------------------------------------------------------------------------- 1 | @import 'color.css'; 2 | @import 'typography.css'; 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | 4 | Sitemap: https://opn.bio/sitemap-index.xml 5 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /src/helpers/array.ts: -------------------------------------------------------------------------------- 1 | export function reverseArray(arr: T[]): T[] { 2 | return [...arr].reverse(); 3 | } 4 | -------------------------------------------------------------------------------- /src/styles/global.css: -------------------------------------------------------------------------------- 1 | @import 'variables/index.css'; 2 | @import 'base/base.css'; 3 | @import 'fonts.css'; 4 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ### OPN Docs 2 | 3 | 1. [Setup Your OPN Profile →](setup.md) 4 | 2. [`bio.json` Schema →](schema.md) 5 | -------------------------------------------------------------------------------- /public/fonts/geist-v1-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/geist-v1-latin-500.woff2 -------------------------------------------------------------------------------- /public/fonts/geist-v16-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/geist-v16-latin-500.woff2 -------------------------------------------------------------------------------- /public/fonts/lora-v36-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/lora-v36-latin-500.woff2 -------------------------------------------------------------------------------- /public/fonts/geist-v1-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/geist-v1-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/lora-v36-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/lora-v36-latin-regular.woff2 -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {}, 4 | 'postcss-nesting': {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/fonts/geist-v16-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/geist-v16-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/ibm-plex-mono-v19-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/ibm-plex-mono-v19-latin-500.woff2 -------------------------------------------------------------------------------- /public/fonts/ibm-plex-mono-v19-latin-italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/ibm-plex-mono-v19-latin-italic.woff2 -------------------------------------------------------------------------------- /public/fonts/ibm-plex-mono-v19-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/ibm-plex-mono-v19-latin-regular.woff2 -------------------------------------------------------------------------------- /public/fonts/ibm-plex-mono-v19-latin-500italic.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/remvze/opn/HEAD/public/fonts/ibm-plex-mono-v19-latin-500italic.woff2 -------------------------------------------------------------------------------- /src/helpers/number.ts: -------------------------------------------------------------------------------- 1 | export function padNumber(number: number, maxLength: number = 2): string { 2 | return number.toString().padStart(maxLength, '0'); 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-astro"], 3 | "singleQuote": true, 4 | "arrowParens": "avoid", 5 | "endOfLine": "lf", 6 | "tabWidth": 2, 7 | "semi": true 8 | } 9 | -------------------------------------------------------------------------------- /src/components/container/container.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 90%; 3 | max-width: 480px; 4 | margin: 0 auto; 5 | 6 | &.wide { 7 | max-width: 800px; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers/string.ts: -------------------------------------------------------------------------------- 1 | export function truncateString(str: string, num: number) { 2 | if (str.length > num) { 3 | return str.slice(0, num) + '...'; 4 | } else { 5 | return str; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "astro-build.astro-vscode", 4 | "esbenp.prettier-vscode", 5 | "dbaeumer.vscode-eslint", 6 | "stylelint.vscode-stylelint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.{ts,tsx,js,jsx}": "eslint --fix", 3 | "*.{json,md}": "prettier --write", 4 | "*.css": "stylelint --fix", 5 | "*.astro": ["eslint --fix", "stylelint --fix"], 6 | "*.html": ["prettier --write", "stylelint --fix"] 7 | } 8 | -------------------------------------------------------------------------------- /src/helpers/styles.ts: -------------------------------------------------------------------------------- 1 | type className = undefined | null | false | string; 2 | 3 | export function cn(...classNames: Array): string { 4 | const className = classNames.filter(className => !!className).join(' '); 5 | 6 | return className; 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1765185707964, 9 | "tag": "0000_dusty_chat", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/slug.ts: -------------------------------------------------------------------------------- 1 | export function createSlug(address: string, date: string) { 2 | const normalizedAddress = address.replaceAll('.', '-').replaceAll('/', '-'); 3 | const normalizedDate = date.replaceAll('/', '-'); 4 | 5 | return `${normalizedDate}-${normalizedAddress}`.toLowerCase(); 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "compilerOptions": { 4 | "strictNullChecks": true, 5 | "jsx": "react-jsx", 6 | "jsxImportSource": "react", 7 | "baseUrl": "./src", 8 | "paths": { 9 | "@/*": ["./*"], 10 | }, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /src/database/drizzle.ts: -------------------------------------------------------------------------------- 1 | import { getSecret } from 'astro:env/server'; // eslint-disable-line 2 | 3 | import { drizzle } from 'drizzle-orm/neon-http'; 4 | import { neon } from '@neondatabase/serverless'; 5 | 6 | const sql = neon(getSecret('DATABASE_URL')!); 7 | 8 | export const db = drizzle({ client: sql }); 9 | -------------------------------------------------------------------------------- /src/helpers/date.ts: -------------------------------------------------------------------------------- 1 | export function formatDate(date: Date): string { 2 | const year = date.getFullYear().toString().slice(-2); 3 | const month = date.toLocaleString('en-US', { month: 'short' }); 4 | const day = date.getDate().toString().padStart(2, '0'); 5 | 6 | return `${month} ${day}, ${year}`; 7 | } 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.production 19 | 20 | # macOS-specific files 21 | .DS_Store 22 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'dotenv'; 2 | import { defineConfig } from 'drizzle-kit'; 3 | 4 | config({ path: '.env' }); 5 | 6 | export default defineConfig({ 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL!, 9 | }, 10 | dialect: 'postgresql', 11 | out: './migrations', 12 | schema: './src/database/schema.ts', 13 | }); 14 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'astro/config'; 2 | 3 | import react from '@astrojs/react'; 4 | import sitemap from '@astrojs/sitemap'; 5 | import cloudflare from '@astrojs/cloudflare'; 6 | 7 | export default defineConfig({ 8 | adapter: cloudflare(), 9 | integrations: [react(), sitemap()], 10 | output: 'server', 11 | site: 'https://opn.bio', 12 | }); 13 | -------------------------------------------------------------------------------- /migrations/0000_dusty_chat.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "profiles_table" ( 2 | "created_at" timestamp DEFAULT now() NOT NULL, 3 | "id" serial PRIMARY KEY NOT NULL, 4 | "is_active" boolean DEFAULT true NOT NULL, 5 | "updated_at" timestamp NOT NULL, 6 | "username" text NOT NULL, 7 | "visits" integer DEFAULT 0 NOT NULL, 8 | CONSTRAINT "profiles_table_username_unique" UNIQUE("username") 9 | ); 10 | -------------------------------------------------------------------------------- /src/components/container/container.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from '@/helpers/styles'; 2 | 3 | import styles from './container.module.css'; 4 | 5 | interface ContainerProps { 6 | children: React.ReactNode; 7 | wide?: boolean; 8 | } 9 | 10 | export function Container({ children, wide }: ContainerProps) { 11 | return ( 12 |
{children}
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/components/section.astro: -------------------------------------------------------------------------------- 1 | --- 2 | interface Props { 3 | title: string; 4 | } 5 | 6 | const { title } = Astro.props; 7 | --- 8 | 9 |
10 |

{title}

11 |
12 | 13 |
14 |
15 | 16 | 28 | -------------------------------------------------------------------------------- /src/styles/base/base.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | padding: 0; 6 | margin: 0; 7 | font: inherit; 8 | } 9 | 10 | html { 11 | scroll-behavior: smooth; 12 | } 13 | 14 | body { 15 | font-family: var(--font-sans); 16 | font-size: var(--font-base); 17 | color: var(--color-foreground); 18 | background-color: var(--color-neutral-50); 19 | } 20 | 21 | ::selection { 22 | color: var(--color-foreground); 23 | background-color: var(--color-neutral-300); 24 | } 25 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-recess-order", 5 | "stylelint-config-html", 6 | "stylelint-prettier/recommended" 7 | ], 8 | 9 | "rules": { 10 | "import-notation": "string", 11 | "selector-class-pattern": null, 12 | "no-descending-specificity": null 13 | }, 14 | 15 | "overrides": [ 16 | { 17 | "files": ["*.astro"], 18 | "rules": { 19 | "prettier/prettier": null 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "eslint.validate": [ 4 | "javascript", 5 | "javascriptreact", 6 | "typescript", 7 | "typescriptreact", 8 | "astro" 9 | ], 10 | "stylelint.validate": ["css", "html", "astro"], 11 | "editor.defaultFormatter": "esbenp.prettier-vscode", 12 | "editor.formatOnSave": true, 13 | "editor.codeActionsOnSave": { 14 | "source.fixAll.eslint": "explicit", 15 | "source.fixAll.stylelint": "explicit" 16 | }, 17 | "[javascript][javascriptreact][typescript][typescriptreact][astro]": { 18 | "editor.formatOnSave": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/pages/@[username].astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from '@/layouts/layout.astro'; 3 | import { Profile } from '@/components/profile'; 4 | import { getSource } from '@/lib/source'; 5 | 6 | const username = Astro.params.username?.toLowerCase(); 7 | 8 | if (!username) { 9 | return Astro.rewrite('/404'); 10 | } 11 | 12 | const source = await getSource(username); 13 | 14 | if (!source) { 15 | return Astro.rewrite('/404'); 16 | } 17 | --- 18 | 19 | 20 | 26 | 27 | -------------------------------------------------------------------------------- /src/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { 2 | boolean, 3 | integer, 4 | pgTable, 5 | serial, 6 | text, 7 | timestamp, 8 | } from 'drizzle-orm/pg-core'; 9 | 10 | export const profilesTable = pgTable('profiles_table', { 11 | createdAt: timestamp('created_at').notNull().defaultNow(), 12 | id: serial('id').primaryKey(), 13 | isActive: boolean('is_active').notNull().default(true), 14 | updatedAt: timestamp('updated_at') 15 | .notNull() 16 | .$onUpdate(() => new Date()), 17 | username: text('username').notNull().unique(), 18 | visits: integer('visits').notNull().default(0), 19 | }); 20 | 21 | export type InsertProfile = typeof profilesTable.$inferInsert; 22 | export type SelectProfile = typeof profilesTable.$inferSelect; 23 | -------------------------------------------------------------------------------- /scripts/build-schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Build script to generate JSON Schema from Zod schemas. 3 | * 4 | * Outputs: public/schema.json 5 | * 6 | * Usage: npm run build:schema 7 | */ 8 | 9 | import { writeFileSync } from 'node:fs'; 10 | import { toJSONSchema } from 'zod/mini'; 11 | 12 | import { ProfileSchema } from '../src/validators/profile.ts'; 13 | 14 | const jsonSchema = toJSONSchema(ProfileSchema); 15 | 16 | const schema = { 17 | $id: 'https://opn.bio/schema.json', 18 | description: 'Schema for bio.json profile configuration.', 19 | title: 'OPN Profile Schema', 20 | ...jsonSchema, 21 | }; 22 | 23 | writeFileSync('./public/schema.json', JSON.stringify(schema, null, 2) + '\n'); 24 | 25 | console.log('✓ Generated public/schema.json'); 26 | -------------------------------------------------------------------------------- /src/helpers/random.ts: -------------------------------------------------------------------------------- 1 | export function random(min: number, max: number): number { 2 | return Math.random() * (max - min) + min; 3 | } 4 | 5 | export function randomInt(min: number, max: number): number { 6 | return Math.floor(random(min, max)); 7 | } 8 | 9 | export function pick(array: Array): T { 10 | const randomIndex = randomInt(0, array.length); 11 | 12 | return array[randomIndex]; 13 | } 14 | 15 | export function pickMany(array: Array, count: number): Array { 16 | const shuffled = shuffle(array); 17 | 18 | return shuffled.slice(0, count); 19 | } 20 | 21 | export function shuffle(array: Array): Array { 22 | return array 23 | .map(value => ({ sort: Math.random(), value })) 24 | .sort((a, b) => a.sort - b.sort) 25 | .map(({ value }) => value); 26 | } 27 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { 4 | "type": "feat", 5 | "section": "✨ Features" 6 | }, 7 | { 8 | "type": "fix", 9 | "section": "🐛 Bug Fixes" 10 | }, 11 | { 12 | "type": "chore", 13 | "hidden": false, 14 | "section": "🚚 Chores" 15 | }, 16 | { 17 | "type": "docs", 18 | "hidden": false, 19 | "section": "📝 Documentation" 20 | }, 21 | { 22 | "type": "style", 23 | "hidden": false, 24 | "section": "💄 Styling" 25 | }, 26 | { 27 | "type": "refactor", 28 | "hidden": false, 29 | "section": "♻️ Code Refactoring" 30 | }, 31 | { 32 | "type": "perf", 33 | "hidden": false, 34 | "section": "⚡️ Performance Improvements" 35 | }, 36 | { 37 | "type": "test", 38 | "hidden": false, 39 | "section": "✅ Testing" 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/variables/typography.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-sans: 'Geist', sans-serif; 3 | --font-serif: 'Lora', serif; 4 | --font-mono: 'IBM Plex Mono', monospace; 5 | 6 | /* Font Sizes */ 7 | --font-base-size: 1rem; 8 | --font-pos-ratio: 1.2; 9 | --font-neg-ratio: 1.125; 10 | --font-3xlg: calc(var(--font-xxlg) * var(--font-pos-ratio)); 11 | --font-2xlg: calc(var(--font-xlg) * var(--font-pos-ratio)); 12 | --font-xlg: calc(var(--font-lg) * var(--font-pos-ratio)); 13 | --font-lg: calc(var(--font-md) * var(--font-pos-ratio)); 14 | --font-md: calc(var(--font-base) * var(--font-pos-ratio)); 15 | --font-base: var(--font-base-size); 16 | --font-sm: calc(var(--font-base) / var(--font-neg-ratio)); 17 | --font-xsm: calc(var(--font-sm) / var(--font-neg-ratio)); 18 | --font-2xsm: calc(var(--font-xsm) / var(--font-neg-ratio)); 19 | --font-3xsm: calc(var(--font-xxsm) / var(--font-neg-ratio)); 20 | } 21 | -------------------------------------------------------------------------------- /template/bio.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "name": "Your Name", 4 | "description": "A Short Description", 5 | "sections": [ 6 | { 7 | "title": "About Me", 8 | "type": "text", 9 | "content": "A longer description about you." 10 | }, 11 | { 12 | "title": "Experience", 13 | "type": "list", 14 | "items": [ 15 | { 16 | "title": "Company One", 17 | "description": "Software Engineer.", 18 | "url": "https://example.com", 19 | "date": "2025–Present" 20 | } 21 | ] 22 | }, 23 | { 24 | "title": "Socials", 25 | "type": "links", 26 | "links": [ 27 | { 28 | "title": "Website", 29 | "url": "https://example.com" 30 | }, 31 | { 32 | "title": "GitHub", 33 | "url": "https://github.com/example" 34 | } 35 | ] 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Container } from '@/components/container'; 3 | import Layout from '@/layouts/layout.astro'; 4 | import Hero from '@/components/hero.astro'; 5 | import Steps from '@/components/steps.astro'; 6 | import What from '@/components/what.astro'; 7 | --- 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | Check OPN on GitHub. 17 |
18 |
19 |
20 | 21 | 36 | -------------------------------------------------------------------------------- /src/components/hero.astro: -------------------------------------------------------------------------------- 1 |
2 | 3 |

OPN.bio

4 |

Your open-source bio page.

5 |
6 | 7 | 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 MAZE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/source.ts: -------------------------------------------------------------------------------- 1 | import { incrementOrCreateVisit, markInactive } from './profile'; 2 | 3 | function getSourceUrl(username: string, branch: string) { 4 | return `https://raw.githubusercontent.com/${username}/.opn/refs/heads/${branch}/bio.json`; 5 | } 6 | 7 | async function fetchSource(username: string, branch: string) { 8 | const url = getSourceUrl(username, branch); 9 | const res = await fetch(url); 10 | 11 | return { status: res.status, url }; 12 | } 13 | 14 | export async function getSource(username: string) { 15 | const main = await fetchSource(username, 'main'); 16 | 17 | if (main.status === 200 || main.status === 429) { 18 | const visits = await incrementOrCreateVisit(username); 19 | 20 | return { url: main.url, visits }; 21 | } 22 | 23 | const master = await fetchSource(username, 'master'); 24 | 25 | if (master.status === 200 || master.status === 429) { 26 | const visits = await incrementOrCreateVisit(username); 27 | 28 | return { url: master.url, visits }; 29 | } 30 | 31 | if (main.status === 404 && master.status === 404) { 32 | await markInactive(username); 33 | 34 | return null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/styles/variables/color.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-neutral-50: #09090b; 3 | --color-neutral-100: #18181b; 4 | --color-neutral-200: #27272a; 5 | --color-neutral-300: #3f3f46; 6 | --color-neutral-400: #52525b; 7 | --color-neutral-500: #71717a; 8 | --color-neutral-600: #a1a1aa; 9 | --color-neutral-700: #d4d4d8; 10 | --color-neutral-800: #e4e4e7; 11 | --color-neutral-900: #f4f4f5; 12 | --color-neutral-950: #fafafa; 13 | 14 | /* Light */ 15 | --color-neutral-light-950: #09090b; 16 | --color-neutral-light-900: #18181b; 17 | --color-neutral-light-800: #27272a; 18 | --color-neutral-light-700: #3f3f46; 19 | --color-neutral-light-600: #52525b; 20 | --color-neutral-light-500: #71717a; 21 | --color-neutral-light-400: #a1a1aa; 22 | --color-neutral-light-300: #d4d4d8; 23 | --color-neutral-light-200: #e4e4e7; 24 | --color-neutral-light-100: #f4f4f5; 25 | --color-neutral-light-50: #fafafa; 26 | 27 | /* Foreground */ 28 | --color-foreground: var(--color-neutral-950); 29 | --color-foreground-subtle: var(--color-neutral-600); 30 | --color-foreground-subtler: var(--color-neutral-500); 31 | 32 | /* Foreground Light */ 33 | --color-foreground-light: var(--color-neutral-light-950); 34 | --color-foreground-subtle-light: var(--color-neutral-light-600); 35 | --color-foreground-subtler-light: var(--color-neutral-light-500); 36 | } 37 | -------------------------------------------------------------------------------- /src/layouts/layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import '@/styles/global.css'; 3 | 4 | interface Props { 5 | description?: string; 6 | og?: string; 7 | title?: string; 8 | } 9 | 10 | const title = Astro.props.title 11 | ? `${Astro.props.title} — OPN` 12 | : 'OPN: Your Open-Source Bio Page'; 13 | 14 | const description = Astro.props.description || 'Your open-source bio page.'; 15 | 16 | const og = Astro.props.og || '/og.png'; 17 | --- 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {title} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

OPN

3 |

Your open-source bio page.

4 | Visit OPN | Docs | Template 5 |
6 | 7 | --- 8 | 9 | ### 🪶 What Is OPN? 10 | 11 | **OPN** is an open-source bio platform that reads your profile data directly from a GitHub repository. 12 | 13 | All you need is: 14 | 15 | - A **public repo** named `.opn` 16 | - A `bio.json` file inside it 17 | 18 | Your bio page becomes instantly available at: 19 | 20 | ``` 21 | https://opn.bio/@ 22 | ``` 23 | 24 | Because your data lives entirely in your own repo, you **fully own and control it**. If you delete the repo, your profile disappears: no accounts, no lock-in. 25 | 26 | --- 27 | 28 | ### ⚙️ How It Works 29 | 30 | 1. Create a new public repository named `.opn` 31 | 2. Add a `bio.json` file inside it (you can copy it from [here](https://github.com/remvze/opn/blob/main/template/bio.json)) 32 | 3. Fill in your data according to the [schema](https://github.com/remvze/opn/blob/main/docs/schema.md) 33 | 34 | Then visit your profile at: 35 | 36 | ``` 37 | https://opn.bio/@ 38 | ``` 39 | 40 | --- 41 | 42 | ### 🧩 Example 43 | 44 | - **Page:** [opn.bio/@remvze](https://opn.bio/@remvze) 45 | - **Source:** [github.com/remvze/.opn](https://github.com/remvze/.opn) 46 | -------------------------------------------------------------------------------- /src/components/what.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Section from './section.astro'; 3 | --- 4 | 5 |
6 |

7 | OPN is an open-source bio page that lives on your GitHub. Instead 8 | of creating yet another account, all you need is a public repo named 9 | .opn with a simple bio.json file inside. From there, 10 | your profile is generated automatically and stays fully in your control. (Example: 11 | opn.bio/@remvze) 12 |

13 |
14 | 15 | 49 | -------------------------------------------------------------------------------- /src/lib/profile.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/database/drizzle'; 2 | import { profilesTable } from '@/database/schema'; 3 | import { eq, sql } from 'drizzle-orm'; 4 | 5 | export async function findByUsername(username: string) { 6 | const result = await db 7 | .select() 8 | .from(profilesTable) 9 | .where(eq(profilesTable.username, username)) 10 | .limit(1); 11 | 12 | const record = result[0]; 13 | 14 | return record || null; 15 | } 16 | 17 | export async function incrementVisits(username: string) { 18 | const [updated] = await db 19 | .update(profilesTable) 20 | .set({ isActive: true, visits: sql`${profilesTable.visits} + 1` }) 21 | .where(eq(profilesTable.username, username)) 22 | .returning(); 23 | 24 | if (!updated) throw new Error(`Profile with username ${username} not found`); 25 | 26 | return updated.visits; 27 | } 28 | 29 | export async function incrementOrCreateVisit(username: string) { 30 | const profile = await findByUsername(username); 31 | 32 | if (!profile) { 33 | await db.insert(profilesTable).values({ username, visits: 1 }).returning(); 34 | 35 | return 1; 36 | } else { 37 | const visits = await incrementVisits(username); 38 | 39 | return visits; 40 | } 41 | } 42 | 43 | export async function markInactive(username: string) { 44 | const profile = await findByUsername(username); 45 | 46 | if (!profile) return; 47 | 48 | await db 49 | .update(profilesTable) 50 | .set({ isActive: false }) 51 | .where(eq(profilesTable.username, username)); 52 | } 53 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | ## Setup Your OPN Profile 2 | 3 | Setting up your OPN profile is quick and account-free. Just follow these steps: 4 | 5 | ### 1. Create a `.opn` Repository 6 | 7 | Create a public repository in your GitHub account with the name: 8 | 9 | ``` 10 | .opn 11 | ``` 12 | 13 | > **Note**: The name must be exactly `.opn`, including the dot at the beginning. 14 | 15 | ### 2. Add a `bio.json` File 16 | 17 | Inside the `.opn` repository, create a file named: 18 | 19 | ``` 20 | bio.json 21 | ``` 22 | 23 | This file will contain all the data shown on your OPN profile. 24 | 25 | ### 3. Fill in Your Bio Data 26 | 27 | Here’s a basic example of what your `bio.json` might look like: 28 | 29 | ```json 30 | { 31 | "version": 1, 32 | "name": "Your Name", 33 | "description": "A short description", 34 | "sections": [ 35 | { 36 | "title": "Socials", 37 | "type": "links", 38 | "links": [ 39 | { 40 | "title": "Website", 41 | "url": "https://example.com" 42 | }, 43 | { 44 | "title": "GitHub", 45 | "url": "https://github.com/example" 46 | } 47 | ] 48 | } 49 | ] 50 | } 51 | ``` 52 | 53 | > You can customize this with more fields and sections; see the [full schema](/schema.md) for details. 54 | 55 | ### You're Live! 56 | 57 | Once your `.opn` repo and `bio.json` file are public, your OPN profile is live at: 58 | 59 | ``` 60 | https://opn.bio/@your-github-username 61 | ``` 62 | 63 | To update your profile, simply edit your `bio.json` file, no need to do anything else. 64 | 65 | > **Note**: GitHub may cache your `bio.json` file, so changes might take a few minutes to appear on your OPN profile. 66 | -------------------------------------------------------------------------------- /migrations/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "e38469e0-173c-462b-aaf8-7159c5e44ef2", 3 | "prevId": "00000000-0000-0000-0000-000000000000", 4 | "version": "7", 5 | "dialect": "postgresql", 6 | "tables": { 7 | "public.profiles_table": { 8 | "name": "profiles_table", 9 | "schema": "", 10 | "columns": { 11 | "created_at": { 12 | "name": "created_at", 13 | "type": "timestamp", 14 | "primaryKey": false, 15 | "notNull": true, 16 | "default": "now()" 17 | }, 18 | "id": { 19 | "name": "id", 20 | "type": "serial", 21 | "primaryKey": true, 22 | "notNull": true 23 | }, 24 | "is_active": { 25 | "name": "is_active", 26 | "type": "boolean", 27 | "primaryKey": false, 28 | "notNull": true, 29 | "default": true 30 | }, 31 | "updated_at": { 32 | "name": "updated_at", 33 | "type": "timestamp", 34 | "primaryKey": false, 35 | "notNull": true 36 | }, 37 | "username": { 38 | "name": "username", 39 | "type": "text", 40 | "primaryKey": false, 41 | "notNull": true 42 | }, 43 | "visits": { 44 | "name": "visits", 45 | "type": "integer", 46 | "primaryKey": false, 47 | "notNull": true, 48 | "default": 0 49 | } 50 | }, 51 | "indexes": {}, 52 | "foreignKeys": {}, 53 | "compositePrimaryKeys": {}, 54 | "uniqueConstraints": { 55 | "profiles_table_username_unique": { 56 | "name": "profiles_table_username_unique", 57 | "nullsNotDistinct": false, 58 | "columns": ["username"] 59 | } 60 | }, 61 | "policies": {}, 62 | "checkConstraints": {}, 63 | "isRLSEnabled": false 64 | } 65 | }, 66 | "enums": {}, 67 | "schemas": {}, 68 | "sequences": {}, 69 | "roles": {}, 70 | "policies": {}, 71 | "views": {}, 72 | "_meta": { 73 | "columns": {}, 74 | "schemas": {}, 75 | "tables": {} 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/validators/profile.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod/mini'; 2 | 3 | const ListSectionSchema = z.object({ 4 | items: z.array( 5 | z.object({ 6 | date: z.optional(z.string({ error: 'Date must be a string.' })), 7 | description: z.optional( 8 | z.string({ error: 'Description must be a string.' }), 9 | ), 10 | title: z.string({ error: 'Item title is required.' }), 11 | url: z.optional(z.string({ error: 'URL must be a string.' })), 12 | }), 13 | { error: 'Items must be an array.' }, 14 | ), 15 | title: z.string({ error: 'Section title is required.' }), 16 | type: z.literal('list', { error: "Type must be 'list'." }), 17 | }); 18 | 19 | const TextSectionSchema = z.object({ 20 | content: z.string({ error: 'Content is required.' }), 21 | title: z.string({ error: 'Section title is required.' }), 22 | type: z.literal('text', { error: "Type must be 'text'." }), 23 | }); 24 | 25 | const LinksSectionSchema = z.object({ 26 | links: z.array( 27 | z.object({ 28 | title: z.string({ error: 'Link title is required.' }), 29 | url: z.string({ error: 'Link URL is required.' }), 30 | }), 31 | { error: 'Links must be an array.' }, 32 | ), 33 | title: z.string({ error: 'Section title is required.' }), 34 | type: z.literal('links', { error: "Type must be 'links'." }), 35 | }); 36 | 37 | const SectionSchema = z.union( 38 | [ListSectionSchema, TextSectionSchema, LinksSectionSchema], 39 | { error: 'Section must be a valid list, text, or links section.' }, 40 | ); 41 | 42 | const ProfileSchema = z.object({ 43 | description: z.string({ error: 'Profile description is required.' }), 44 | name: z.string({ error: 'Profile name is required.' }), 45 | sections: z.optional( 46 | z.array(SectionSchema, { 47 | error: 'Sections must be an array of valid sections.', 48 | }), 49 | ), 50 | style: z.optional( 51 | z.object({ 52 | font: z.optional(z.union([z.literal('serif'), z.literal('sans-serif')])), 53 | theme: z.optional(z.union([z.literal('dark'), z.literal('light')])), 54 | }), 55 | ), 56 | version: z.literal(1, { error: 'Version must be 1.' }), 57 | }); 58 | 59 | export { ProfileSchema }; 60 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | 4 | "env": { 5 | "browser": true, 6 | "amd": true, 7 | "node": true, 8 | "es2022": true 9 | }, 10 | 11 | "parser": "@typescript-eslint/parser", 12 | 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module", 16 | "ecmaFeatures": { 17 | "jsx": true 18 | } 19 | }, 20 | 21 | "extends": [ 22 | "eslint:recommended", 23 | "plugin:@typescript-eslint/recommended", 24 | "plugin:typescript-sort-keys/recommended", 25 | "plugin:import/recommended", 26 | "plugin:react/recommended", 27 | "plugin:react/jsx-runtime", 28 | "plugin:jsx-a11y/recommended", 29 | "plugin:react-hooks/recommended", 30 | "plugin:astro/recommended", 31 | "prettier" 32 | ], 33 | 34 | "plugins": [ 35 | "@typescript-eslint", 36 | "typescript-sort-keys", 37 | "sort-keys-fix", 38 | "sort-destructure-keys", 39 | "prettier" 40 | ], 41 | 42 | "rules": { 43 | "prettier/prettier": "error", 44 | "sort-keys-fix/sort-keys-fix": ["warn", "asc"], 45 | "sort-destructure-keys/sort-destructure-keys": "warn", 46 | "@typescript-eslint/triple-slash-reference": "off", 47 | "jsx-a11y/no-static-element-interactions": "off", 48 | "react/jsx-sort-props": [ 49 | "warn", 50 | { 51 | "callbacksLast": true, 52 | "multiline": "last" 53 | } 54 | ] 55 | }, 56 | 57 | "settings": { 58 | "react": { 59 | "version": "detect" 60 | }, 61 | 62 | "import/parsers": { 63 | "@typescript-eslint/parser": [".ts", ".tsx", ".js", ".jsx"] 64 | }, 65 | 66 | "import/resolver": { 67 | "typescript": true, 68 | "node": true, 69 | "alias": { 70 | "extensions": [".js", ".jsx", ".ts", ".tsx", ".d.ts"], 71 | "map": [["@", "./src"]] 72 | } 73 | } 74 | }, 75 | 76 | "overrides": [ 77 | { 78 | "files": ["**/*.astro"], 79 | "parser": "astro-eslint-parser", 80 | 81 | "parserOptions": { 82 | "parser": "@typescript-eslint/parser", 83 | "extraFileExtensions": [".astro"] 84 | }, 85 | 86 | "rules": { 87 | "prettier/prettier": "error", 88 | "react/no-unknown-property": "off", 89 | "react/jsx-key": "off" 90 | }, 91 | 92 | "globals": { 93 | "Astro": "readonly" 94 | } 95 | }, 96 | 97 | { 98 | "files": ["**/*.astro/*.js"], 99 | "rules": { 100 | "prettier/prettier": "off" 101 | } 102 | } 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opn", 3 | "type": "module", 4 | "version": "0.0.1", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "build:schema": "node --experimental-strip-types scripts/build-schema.ts", 10 | "preview": "astro preview", 11 | "astro": "astro", 12 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro", 13 | "lint:fix": "npm run lint -- --fix", 14 | "lint:style": "stylelint ./**/*.{css,astro,html}", 15 | "lint:style:fix": "npm run lint:style -- --fix", 16 | "format": "prettier . --write", 17 | "prepare": "husky install", 18 | "commit": "git-cz", 19 | "release": "standard-version --no-verify", 20 | "release:major": "npm run release -- --release-as major", 21 | "release:minor": "npm run release -- --release-as minor", 22 | "release:patch": "npm run release -- --release-as patch", 23 | "db:generate": "drizzle-kit generate", 24 | "db:migrate": "drizzle-kit migrate", 25 | "db:studio": "drizzle-kit studio" 26 | }, 27 | "dependencies": { 28 | "@astrojs/cloudflare": "12.6.12", 29 | "@astrojs/react": "4.4.2", 30 | "@astrojs/sitemap": "3.6.0", 31 | "@neondatabase/serverless": "1.0.2", 32 | "@types/react": "^18.2.48", 33 | "@types/react-dom": "^18.2.18", 34 | "astro": "5.16.1", 35 | "dotenv": "17.2.3", 36 | "drizzle-orm": "0.45.0", 37 | "framer-motion": "11.5.4", 38 | "react": "^18.2.0", 39 | "react-dom": "^18.2.0", 40 | "react-icons": "5.0.1", 41 | "zod": "4.0.10" 42 | }, 43 | "devDependencies": { 44 | "@commitlint/cli": "18.4.4", 45 | "@commitlint/config-conventional": "18.4.4", 46 | "@typescript-eslint/eslint-plugin": "6.19.0", 47 | "@typescript-eslint/parser": "6.19.0", 48 | "astro-eslint-parser": "0.16.2", 49 | "autoprefixer": "10.4.17", 50 | "clipboardy": "4.0.0", 51 | "commitizen": "4.3.0", 52 | "cz-conventional-changelog": "3.3.0", 53 | "drizzle-kit": "0.31.4", 54 | "eslint": "8.56.0", 55 | "eslint-config-prettier": "9.1.0", 56 | "eslint-import-resolver-alias": "1.1.2", 57 | "eslint-import-resolver-typescript": "3.6.1", 58 | "eslint-plugin-astro": "0.31.3", 59 | "eslint-plugin-import": "2.29.1", 60 | "eslint-plugin-jsx-a11y": "6.8.0", 61 | "eslint-plugin-prettier": "5.1.3", 62 | "eslint-plugin-react": "7.33.2", 63 | "eslint-plugin-react-hooks": "4.6.0", 64 | "eslint-plugin-sort-destructure-keys": "1.5.0", 65 | "eslint-plugin-sort-keys-fix": "1.1.2", 66 | "eslint-plugin-typescript-sort-keys": "3.1.0", 67 | "husky": "8.0.3", 68 | "lint-staged": "15.2.0", 69 | "postcss-html": "1.6.0", 70 | "postcss-nesting": "12.0.2", 71 | "prettier": "3.2.4", 72 | "prettier-plugin-astro": "0.13.0", 73 | "standard-version": "9.5.0", 74 | "stylelint": "16.2.0", 75 | "stylelint-config-html": "1.1.0", 76 | "stylelint-config-recess-order": "4.4.0", 77 | "stylelint-config-standard": "36.0.0", 78 | "stylelint-prettier": "5.0.0" 79 | }, 80 | "optionalDependencies": { 81 | "@rollup/rollup-linux-x64-gnu": "4.9.5" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/styles/fonts.css: -------------------------------------------------------------------------------- 1 | /* geist-regular - latin */ 2 | @font-face { 3 | font-family: Geist; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url('/fonts/geist-v16-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 7 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 8 | } 9 | 10 | /* geist-500 - latin */ 11 | @font-face { 12 | font-family: Geist; 13 | font-style: normal; 14 | font-weight: 500; 15 | src: url('/fonts/geist-v16-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 16 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 17 | } 18 | 19 | /* ibm-plex-mono-regular - latin */ 20 | @font-face { 21 | font-family: 'IBM Plex Mono'; 22 | font-style: normal; 23 | font-weight: 400; 24 | src: url('/fonts/ibm-plex-mono-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 25 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 26 | } 27 | 28 | /* ibm-plex-mono-italic - latin */ 29 | @font-face { 30 | font-family: 'IBM Plex Mono'; 31 | font-style: italic; 32 | font-weight: 400; 33 | src: url('/fonts/ibm-plex-mono-v19-latin-italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 34 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 35 | } 36 | 37 | /* ibm-plex-mono-500 - latin */ 38 | @font-face { 39 | font-family: 'IBM Plex Mono'; 40 | font-style: normal; 41 | font-weight: 500; 42 | src: url('/fonts/ibm-plex-mono-v19-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 43 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 44 | } 45 | 46 | /* ibm-plex-mono-500italic - latin */ 47 | @font-face { 48 | font-family: 'IBM Plex Mono'; 49 | font-style: italic; 50 | font-weight: 500; 51 | src: url('/fonts/ibm-plex-mono-v19-latin-500italic.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 52 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 53 | } 54 | 55 | /* lora-regular - latin */ 56 | @font-face { 57 | font-family: Lora; 58 | font-style: normal; 59 | font-weight: 400; 60 | src: url('/fonts/lora-v36-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 61 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 62 | } 63 | 64 | /* lora-500 - latin */ 65 | @font-face { 66 | font-family: Lora; 67 | font-style: normal; 68 | font-weight: 500; 69 | src: url('/fonts/lora-v36-latin-500.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */ 70 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 71 | } 72 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | ## `bio.json` Schema 2 | 3 | Your `bio.json` file defines what appears on your OPN profile. Below is a breakdown of its structure and how to use each part. 4 | 5 | ### Top-Level Fields 6 | 7 | | Field | Type | Required | Description | 8 | | ------------- | ------ | -------- | ------------------------------------------ | 9 | | `name` | string | ✅ | Your full name (or display name). | 10 | | `description` | string | ✅ | A short tagline or summary of who you are. | 11 | | `sections` | array | ❌ | Optional content blocks for your profile. | 12 | | `style` | object | ❌ | Customize font and theme (light/dark). | 13 | 14 | --- 15 | 16 | ### Sections 17 | 18 | The `sections` field is an array of content blocks. Each block must be one of the following `type`s: 19 | 20 | #### 1. **List Section** 21 | 22 | Displays a list of items, such as work experience, projects, publications, etc. 23 | 24 | ```json 25 | { 26 | "title": "Projects", 27 | "type": "list", 28 | "items": [ 29 | { 30 | "title": "Project One", 31 | "description": "An open-source project.", 32 | "url": "https://github.com/yourname/one", 33 | "date": "2025" 34 | } 35 | ] 36 | } 37 | ``` 38 | 39 | | Field | Type | Required | Description | 40 | | --------------------- | ------ | -------- | -------------------------------- | 41 | | `title` | string | ✅ | Section title (e.g. "Projects"). | 42 | | `type` | string | ✅ | Must be `"list"`. | 43 | | `items` | array | ✅ | List of entries in the section. | 44 | | `items[].title` | string | ✅ | Title of the entry. | 45 | | `items[].description` | string | ❌ | Short description. | 46 | | `items[].url` | string | ❌ | Optional link. | 47 | | `items[].date` | string | ❌ | Optional date (e.g. year). | 48 | 49 | --- 50 | 51 | #### 2. **Text Section** 52 | 53 | Displays a block of plain textn. 54 | 55 | ```json 56 | { 57 | "title": "About Me", 58 | "type": "text", 59 | "content": "I'm a developer who loves building open tools for the web." 60 | } 61 | ``` 62 | 63 | | Field | Type | Required | Description | 64 | | --------- | ------ | -------- | ----------------- | 65 | | `title` | string | ✅ | Section title. | 66 | | `type` | string | ✅ | Must be `"text"`. | 67 | | `content` | string | ✅ | The text content. | 68 | 69 | --- 70 | 71 | #### 3. **Links Section** 72 | 73 | Displays a group of external links, like social media or portfolios. 74 | 75 | ```json 76 | { 77 | "title": "Connect", 78 | "type": "links", 79 | "links": [ 80 | { 81 | "title": "GitHub", 82 | "url": "https://github.com/yourname" 83 | } 84 | ] 85 | } 86 | ``` 87 | 88 | | Field | Type | Required | Description | 89 | | --------------- | ------ | -------- | -------------------- | 90 | | `title` | string | ✅ | Section title. | 91 | | `type` | string | ✅ | Must be `"links"`. | 92 | | `links` | array | ✅ | List of links. | 93 | | `links[].title` | string | ✅ | Label for the link. | 94 | | `links[].url` | string | ✅ | The URL of the link. | 95 | 96 | --- 97 | 98 | ### Style (Optional) 99 | 100 | Customize your profile’s appearance. 101 | 102 | ```json 103 | "style": { 104 | "font": "serif", 105 | "theme": "dark" 106 | } 107 | ``` 108 | 109 | | Field | Type | Options | Description | 110 | | ------- | ------ | ------------------------- | --------------------- | 111 | | `font` | string | `"serif"`, `"sans-serif"` | Choose a font style. | 112 | | `theme` | string | `"light"`, `"dark"` | Choose a color theme. | 113 | -------------------------------------------------------------------------------- /public/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$id": "https://opn.bio/schema.json", 3 | "description": "Schema for bio.json profile configuration.", 4 | "title": "OPN Profile Schema", 5 | "$schema": "https://json-schema.org/draft/2020-12/schema", 6 | "type": "object", 7 | "properties": { 8 | "description": { 9 | "type": "string" 10 | }, 11 | "name": { 12 | "type": "string" 13 | }, 14 | "sections": { 15 | "type": "array", 16 | "items": { 17 | "anyOf": [ 18 | { 19 | "type": "object", 20 | "properties": { 21 | "items": { 22 | "type": "array", 23 | "items": { 24 | "type": "object", 25 | "properties": { 26 | "date": { 27 | "type": "string" 28 | }, 29 | "description": { 30 | "type": "string" 31 | }, 32 | "title": { 33 | "type": "string" 34 | }, 35 | "url": { 36 | "type": "string" 37 | } 38 | }, 39 | "required": [ 40 | "title" 41 | ], 42 | "additionalProperties": false 43 | } 44 | }, 45 | "title": { 46 | "type": "string" 47 | }, 48 | "type": { 49 | "type": "string", 50 | "const": "list" 51 | } 52 | }, 53 | "required": [ 54 | "items", 55 | "title", 56 | "type" 57 | ], 58 | "additionalProperties": false 59 | }, 60 | { 61 | "type": "object", 62 | "properties": { 63 | "content": { 64 | "type": "string" 65 | }, 66 | "title": { 67 | "type": "string" 68 | }, 69 | "type": { 70 | "type": "string", 71 | "const": "text" 72 | } 73 | }, 74 | "required": [ 75 | "content", 76 | "title", 77 | "type" 78 | ], 79 | "additionalProperties": false 80 | }, 81 | { 82 | "type": "object", 83 | "properties": { 84 | "links": { 85 | "type": "array", 86 | "items": { 87 | "type": "object", 88 | "properties": { 89 | "title": { 90 | "type": "string" 91 | }, 92 | "url": { 93 | "type": "string" 94 | } 95 | }, 96 | "required": [ 97 | "title", 98 | "url" 99 | ], 100 | "additionalProperties": false 101 | } 102 | }, 103 | "title": { 104 | "type": "string" 105 | }, 106 | "type": { 107 | "type": "string", 108 | "const": "links" 109 | } 110 | }, 111 | "required": [ 112 | "links", 113 | "title", 114 | "type" 115 | ], 116 | "additionalProperties": false 117 | } 118 | ] 119 | } 120 | }, 121 | "style": { 122 | "type": "object", 123 | "properties": { 124 | "font": { 125 | "anyOf": [ 126 | { 127 | "type": "string", 128 | "const": "serif" 129 | }, 130 | { 131 | "type": "string", 132 | "const": "sans-serif" 133 | } 134 | ] 135 | }, 136 | "theme": { 137 | "anyOf": [ 138 | { 139 | "type": "string", 140 | "const": "dark" 141 | }, 142 | { 143 | "type": "string", 144 | "const": "light" 145 | } 146 | ] 147 | } 148 | }, 149 | "additionalProperties": false 150 | }, 151 | "version": { 152 | "type": "number", 153 | "const": 1 154 | } 155 | }, 156 | "required": [ 157 | "description", 158 | "name", 159 | "version" 160 | ], 161 | "additionalProperties": false 162 | } 163 | -------------------------------------------------------------------------------- /src/components/profile/profile.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { MdArrowOutward } from 'react-icons/md'; 3 | import { IoEye } from 'react-icons/io5'; 4 | 5 | import { Container } from '../container'; 6 | 7 | import styles from './profile.module.css'; 8 | import { cn } from '@/helpers/styles'; 9 | import { ProfileSchema } from '@/validators/profile'; 10 | import type { z } from 'zod/mini'; 11 | import { padNumber } from '@/helpers/number'; 12 | 13 | interface ProfileProps { 14 | source: string; 15 | username: string; 16 | visits: number; 17 | } 18 | 19 | export function Profile({ source, visits }: ProfileProps) { 20 | const [profile, setProfile] = useState | null>( 21 | null, 22 | ); 23 | const [errors, setErrors] = useState< 24 | Array<{ message: string; path: string }> 25 | >([]); 26 | const [error, setError] = useState(''); 27 | 28 | const fetchProfile = useCallback(async () => { 29 | const res = await fetch(source); 30 | 31 | if (!res.ok) return setError('Profile not found.'); 32 | 33 | const data = await res.json(); 34 | 35 | const parsed = ProfileSchema.safeParse(data); 36 | 37 | if (!parsed.success) { 38 | setErrors( 39 | parsed.error.issues.map(issue => ({ 40 | message: issue.message, 41 | path: issue.path.join(' → '), 42 | })), 43 | ); 44 | } else { 45 | setProfile(parsed.data); 46 | } 47 | }, [source]); 48 | 49 | useEffect(() => { 50 | fetchProfile().catch(() => 51 | setError('Something went wrong. The JSON file might be invalid.'), 52 | ); 53 | }, [fetchProfile]); 54 | 55 | useEffect(() => { 56 | if (profile?.name) { 57 | document.title = `${profile.name} — OPN`; 58 | } 59 | 60 | if (profile?.style?.theme === 'light') { 61 | document.body.style.background = 'var(--color-neutral-950)'; 62 | } 63 | }, [profile]); 64 | 65 | if (error) { 66 | return ( 67 |
68 | 69 |

Error: {error}

70 |
71 |
72 | ); 73 | } 74 | 75 | if (errors.length > 0) { 76 | return ( 77 | 78 |
79 |

Wrong Format:

80 | 81 | {errors.map((error, i) => ( 82 |

83 | [{error.path}]: {error.message} 84 |

85 | ))} 86 |
87 |
88 | ); 89 | } 90 | 91 | if (!profile) return null; 92 | 93 | return ( 94 |
100 | 101 |
102 | 103 |
104 |

{profile.name}

105 |

{profile.description}

106 | 107 |
108 | 109 | 110 | 111 | {padNumber(visits, 4)} 112 |
113 |
114 |
115 | {profile.sections && 116 | profile.sections.map((section, index) => ( 117 |
118 | {section.type === 'list' ? ( 119 |
120 | {section.items.map((item, index) => ( 121 |
122 | {item.url ? ( 123 | 124 | {item.title} 125 | 126 | 127 | 128 | 129 | ) : ( 130 |

{item.title}

131 | )} 132 | 133 | {item.description && ( 134 |

135 | {item.description} 136 |

137 | )} 138 | 139 | {item.date && ( 140 |

({item.date})

141 | )} 142 |
143 | ))} 144 |
145 | ) : section.type === 'text' ? ( 146 |

{section.content}

147 | ) : section.type === 'links' ? ( 148 |
149 | {section.links.map((link, index) => ( 150 | 151 | {link.title} 152 | 153 | ))} 154 |
155 | ) : null} 156 |
157 | ))} 158 |
159 |
160 | Created using OPN. 161 |
162 | 163 |
164 | ); 165 | } 166 | 167 | function Section({ 168 | children, 169 | title, 170 | }: { 171 | children: React.ReactNode; 172 | title: string; 173 | }) { 174 | return ( 175 |
176 |

177 | {title}
178 |

179 |
{children}
180 |
181 | ); 182 | } 183 | -------------------------------------------------------------------------------- /src/components/steps.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Section from './section.astro'; 3 | --- 4 | 5 |
6 |
    7 |
  1. 8 |
    9 | 01 10 |
    11 |

    Create a repository

    12 |

    13 | Create a public repository in your GitHub account called 14 | .opn 15 |

    16 |
  2. 17 |
  3. 18 |
    19 | 02 20 |
    21 |

    Add a bio file

    22 |

    23 | Add a bio.json file inside your .opn repository. 24 | You can copy the base from here. 29 |

    30 |
  4. 31 |
  5. 32 |
    33 | 03 34 |
    35 |

    Enter your info

    36 |

    37 | Based on the schema, fill bio.json with your information. 42 |

    43 |
  6. 44 |
  7. 45 |
    46 | 04 47 |
    48 |

    Your profile is ready

    49 |

    50 | Your bio page will be generated automatically and available at opn.bio/@your-github-username. 53 |

    54 |
  8. 55 |
56 |
57 | 58 | 209 | -------------------------------------------------------------------------------- /src/components/profile/profile.module.css: -------------------------------------------------------------------------------- 1 | .light { 2 | & .header { 3 | & .logo { 4 | background-color: var(--color-neutral-light-950); 5 | } 6 | 7 | & .name { 8 | color: var(--color-foreground-light); 9 | } 10 | 11 | & .description { 12 | color: var(--color-foreground-subtle-light); 13 | } 14 | 15 | & .profileVisits { 16 | color: var(--color-foreground-subtler-light); 17 | border-color: var(--color-neutral-light-200); 18 | 19 | & strong { 20 | color: var(--color-foreground-subtle-light); 21 | } 22 | } 23 | } 24 | 25 | & .socials { 26 | & a { 27 | color: var(--color-foreground-light); 28 | text-decoration-color: var(--color-foreground-subtler-light); 29 | } 30 | } 31 | 32 | & .section { 33 | & > .title { 34 | color: var(--color-foreground-light); 35 | } 36 | 37 | & > .content { 38 | & .text { 39 | color: var(--color-foreground-subtle-light); 40 | } 41 | } 42 | } 43 | 44 | & .items { 45 | & .item { 46 | & .title { 47 | color: var(--color-foreground-light); 48 | 49 | &a { 50 | text-decoration-color: var(--color-foreground-subtler-light); 51 | } 52 | } 53 | 54 | & .date { 55 | color: var(--color-foreground-subtler-light); 56 | } 57 | 58 | & .description { 59 | color: var(--color-foreground-subtle-light); 60 | } 61 | } 62 | } 63 | 64 | & .footer { 65 | color: var(--color-foreground-subtle-light); 66 | 67 | & a { 68 | color: var(--color-foreground-light); 69 | } 70 | } 71 | } 72 | 73 | .serif { 74 | font-family: var(--font-serif); 75 | } 76 | 77 | .singleError { 78 | display: flex; 79 | align-items: center; 80 | justify-content: center; 81 | height: 100dvh; 82 | 83 | & .errorText { 84 | font-size: var(--font-sm); 85 | text-align: center; 86 | } 87 | } 88 | 89 | .logo { 90 | position: sticky; 91 | top: 20px; 92 | width: 20px; 93 | height: 20px; 94 | margin-top: 120px; 95 | background: var(--color-neutral-950); 96 | border-radius: 50%; 97 | mix-blend-mode: exclusion; 98 | } 99 | 100 | .header { 101 | padding: 40px 0 60px; 102 | 103 | & .name { 104 | font-weight: 500; 105 | color: var(--color-foreground); 106 | } 107 | 108 | & .description { 109 | margin-top: 4px; 110 | color: var(--color-foreground-subtle); 111 | } 112 | 113 | & .profileVisits { 114 | position: relative; 115 | display: inline-flex; 116 | column-gap: 4px; 117 | align-items: center; 118 | padding: 6px 12px; 119 | margin-top: 20px; 120 | font-family: var(--font-mono); 121 | font-size: var(--font-2xsm); 122 | line-height: 0; 123 | color: var(--color-foreground-subtler); 124 | text-decoration: none; 125 | border: 1px solid var(--color-neutral-200); 126 | border-radius: 1000px; 127 | 128 | & strong { 129 | font-weight: 500; 130 | color: var(--color-foreground-subtle); 131 | } 132 | } 133 | } 134 | 135 | .bio { 136 | & .heading { 137 | margin-bottom: 16px; 138 | font-weight: 500; 139 | } 140 | 141 | & .content { 142 | line-height: 1.6; 143 | color: var(--color-foreground-subtle); 144 | } 145 | } 146 | 147 | .socials { 148 | display: flex; 149 | flex-wrap: wrap; 150 | gap: 8px 20px; 151 | 152 | & a { 153 | line-height: 1.6; 154 | color: var(--color-foreground); 155 | text-decoration: underline; 156 | text-decoration-style: dotted; 157 | text-decoration-color: var(--color-foreground-subtler); 158 | text-underline-offset: 4px; 159 | } 160 | } 161 | 162 | .section { 163 | &:not(:last-of-type) { 164 | padding-bottom: 80px; 165 | } 166 | 167 | & > .title { 168 | display: flex; 169 | column-gap: 20px; 170 | align-items: center; 171 | margin-bottom: 32px; 172 | font-weight: 500; 173 | } 174 | 175 | & > .content { 176 | & .text { 177 | line-height: 1.6; 178 | color: var(--color-foreground-subtle); 179 | } 180 | } 181 | } 182 | 183 | .items { 184 | & .item { 185 | &:not(:last-of-type) { 186 | margin-bottom: 28px; 187 | } 188 | 189 | & .title { 190 | font-weight: 400; 191 | color: var(--color-foreground); 192 | 193 | &a { 194 | text-decoration: underline; 195 | text-decoration-style: dotted; 196 | text-decoration-color: var(--color-foreground-subtler); 197 | text-underline-offset: 4px; 198 | 199 | & span { 200 | display: inline-block; 201 | margin-left: 2px; 202 | font-size: var(--font-sm); 203 | opacity: 0; 204 | transition: 0.2s; 205 | transition-delay: 0.1s; 206 | transform: translateY(1px) scale(0.5); 207 | transform-origin: bottom left; 208 | } 209 | 210 | &:hover { 211 | & span { 212 | opacity: 1; 213 | transform: translateY(1px) scale(1); 214 | } 215 | } 216 | } 217 | } 218 | 219 | & .date { 220 | margin-top: 4px; 221 | font-size: var(--font-xsm); 222 | color: var(--color-foreground-subtler); 223 | } 224 | 225 | & .description { 226 | margin-top: 4px; 227 | line-height: 1.6; 228 | color: var(--color-foreground-subtle); 229 | } 230 | } 231 | } 232 | 233 | .footer { 234 | padding-bottom: 50px; 235 | margin-top: 80px; 236 | font-size: var(--font-sm); 237 | color: var(--color-foreground-subtle); 238 | 239 | & a { 240 | font-weight: 500; 241 | color: var(--color-foreground); 242 | text-decoration: none; 243 | } 244 | } 245 | 246 | .errors { 247 | padding: 120px 0; 248 | 249 | & .title { 250 | margin-bottom: 20px; 251 | font-weight: 500; 252 | color: var(--color-foreground); 253 | } 254 | 255 | & .error { 256 | font-size: var(--font-sm); 257 | color: var(--color-foreground-subtle); 258 | 259 | &:not(:last-of-type) { 260 | margin-bottom: 12px; 261 | } 262 | 263 | & span { 264 | font-family: var(--font-mono); 265 | color: var(--color-foreground); 266 | } 267 | } 268 | } 269 | --------------------------------------------------------------------------------