diff --git a/.github/workflows/cf-pages.yml b/.github/workflows/cf-pages.yml
index 85694ae..b43c59d 100644
--- a/.github/workflows/cf-pages.yml
+++ b/.github/workflows/cf-pages.yml
@@ -5,7 +5,7 @@ on:
branches:
- main
paths:
- - 'html/**'
+ - 'web/**'
workflow_dispatch:
jobs:
@@ -19,12 +19,25 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: npm install
+ working-directory: web
+
+ - name: Build
+ run: npm run build
+ working-directory: web
+
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
- directory: html
+ directory: web/dist/aiat
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
diff --git a/.gitignore b/.gitignore
index daca568..d85a12e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,6 @@ deploy.yml
claude.md
embedded.mobileprovision
.env
-html.zip
+web/dist
+node_modules
+package-lock.json
diff --git a/html/about/support/app.html b/html/about/support/app.html
deleted file mode 100644
index 241d905..0000000
--- a/html/about/support/app.html
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
- App Info - Aiat
-
-
-
-
-
-
-
-
-
-
Aiat is a social networking application based on AT Protocol. Connect with your community on syu.is.
-
-
-
-
App Information
-
-
-
-
-
Supported OS
-
iOS 26.0+
-
-
-
-
-
-
-
-
-
Bitcoin
-
- ₿
- 3BqHXxraZyBapyNpJmniJDh9zqzuB8aoRr
- copy
-
-
-
-
-
-
-
-
diff --git a/html/about/support/help.html b/html/about/support/help.html
deleted file mode 100644
index 72da246..0000000
--- a/html/about/support/help.html
+++ /dev/null
@@ -1,100 +0,0 @@
-
-
-
-
-
- Help - syu.is
-
-
-
-
-
-
- About syu.is
- syu.is is a social networking service built on the AT Protocol (Authenticated Transfer Protocol). It allows users to share content, connect with others, and participate in a decentralized social network.
-
- Frequently Asked Questions
-
-
-
What is the AT Protocol?
-
The AT Protocol is a decentralized social networking protocol that allows users to own their data and identity. It enables federation between different services while maintaining user control.
-
-
-
-
How do I create an account?
-
You can create an account by downloading the app or visiting the website. You'll need to provide an email address and choose a username.
-
-
-
-
How do I reset my password?
-
You can reset your password through the login screen by selecting "Forgot Password" and following the instructions sent to your email.
-
-
-
-
How do I delete my account?
-
You can delete your account through Settings > Account. Please note that account deletion is permanent and cannot be undone.
-
-
-
-
How do I report abuse or inappropriate content?
-
You can report content by using the report function available on each post. Our moderation team will review reports and take appropriate action.
-
-
- Contact
-
-
- Related Links
-
-
-
-
-
diff --git a/html/about/support/license.html b/html/about/support/license.html
deleted file mode 100644
index c61217a..0000000
--- a/html/about/support/license.html
+++ /dev/null
@@ -1,66 +0,0 @@
-
-
-
-
-
- License - syu.is
-
-
-
-
-
-
- Aiat (iOS/Android App)
- This application is based on the Bluesky Social App, which is open source software.
-
- Open Source Licenses
- This app uses the following open source software:
-
- Bluesky Social App
- Licensed under the MIT License
- https://github.com/bluesky-social/social-app
-
- AT Protocol
- Licensed under the MIT License / Apache 2.0
- https://github.com/bluesky-social/atproto
-
- Third Party Libraries
- This application includes various third-party libraries, each with their own licenses. For a complete list, please see the application's source code repository.
-
-
-
-
diff --git a/html/about/support/privacy.html b/html/about/support/privacy.html
deleted file mode 100644
index 9ab6925..0000000
--- a/html/about/support/privacy.html
+++ /dev/null
@@ -1,92 +0,0 @@
-
-
-
-
-
- Privacy Policy - syu.is
-
-
-
-
-
-
- 1. Introduction
- This Privacy Policy explains how syu.is collects, uses, and protects your personal information when you use our service.
-
- 2. Information We Collect
- We collect the following types of information:
-
- Account Information: Email address, username, and profile information you provide
- Content: Posts, messages, and other content you create on the platform
- Usage Data: Information about how you interact with our service
- Device Information: Browser type, operating system, and device identifiers
-
-
- 3. How We Use Your Information
- We use your information to:
-
- Provide and maintain our service
- Improve and personalize your experience
- Communicate with you about the service
- Ensure security and prevent abuse
-
-
- 4. Data Sharing
- As part of the AT Protocol federation, your public content may be shared with other servers in the network. We do not sell your personal information to third parties.
-
- 5. Data Security
- We implement appropriate security measures to protect your personal information. However, no method of transmission over the Internet is 100% secure.
-
- 6. Your Rights
- You have the right to:
-
- Access your personal data
- Request correction of your data
- Request deletion of your account
- Export your data
-
-
- 7. Cookies
- We use cookies and similar technologies to maintain your session and improve your experience.
-
- 8. Changes to This Policy
- We may update this Privacy Policy from time to time. We will notify you of any significant changes.
-
- 9. Contact
- For privacy-related questions, please visit our Help page .
-
-
-
-
diff --git a/html/about/support/tos.html b/html/about/support/tos.html
deleted file mode 100644
index 3784cb1..0000000
--- a/html/about/support/tos.html
+++ /dev/null
@@ -1,84 +0,0 @@
-
-
-
-
-
- Terms of Service - syu.is
-
-
-
-
-
-
- 1. Introduction
- Welcome to syu.is. By using our service, you agree to these terms. Please read them carefully.
-
- 2. Service Description
- syu.is is a social networking service built on the AT Protocol. We provide a platform for users to share content and connect with others.
-
- 3. User Responsibilities
- As a user of syu.is, you agree to:
-
- Provide accurate information when creating an account
- Keep your account credentials secure
- Not use the service for illegal activities
- Respect other users and their content
- Comply with applicable laws and regulations
-
-
- 4. Content Guidelines
- Users are responsible for the content they post. Prohibited content includes:
-
- Illegal content
- Harassment or abuse
- Spam or misleading information
- Content that violates others' rights
-
-
- 5. Privacy
- Your privacy is important to us. Please review our Privacy Policy to understand how we handle your data.
-
- 6. Disclaimer
- The service is provided "as is" without warranties of any kind. We are not liable for any damages arising from your use of the service.
-
- 7. Changes to Terms
- We may update these terms from time to time. Continued use of the service after changes constitutes acceptance of the new terms.
-
- 8. Contact
- For questions about these terms, please visit our Help page .
-
-
-
-
diff --git a/html/index.html b/html/index.html
deleted file mode 100644
index a9f880a..0000000
--- a/html/index.html
+++ /dev/null
@@ -1,135 +0,0 @@
-
-
-
-
-
- App Info - Aiat
-
-
-
-
-
-
-
-
-
-
Aiat is a social networking application based on AT Protocol. Connect with your community on syu.is.
-
-
-
-
App Information
-
-
-
-
-
Supported OS
-
iOS 26.0+
-
-
-
-
-
-
-
-
-
Bitcoin
-
- ₿
- 3BqHXxraZyBapyNpJmniJDh9zqzuB8aoRr
- copy
-
-
-
-
-
-
-
-
diff --git a/ios/setup.zsh b/ios/setup.zsh
index e9b4b64..838f9c4 100755
--- a/ios/setup.zsh
+++ b/ios/setup.zsh
@@ -204,20 +204,35 @@ function ios-setup-clone() {
echo "Repository found: $target_dir"
}
-# Generate bskyweb templates from html/ source
-# html/ is the source of truth, bskyweb templates are generated
+# Generate bskyweb templates from web/ build output
+# web/ is the source of truth, bskyweb templates are generated from dist/aiat/
function ios-generate-bskyweb-templates() {
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
- echo "🌐 Generating bskyweb templates from html/..."
+ echo "🌐 Generating bskyweb templates from web/..."
- local html_src="$d/html/about/support"
+ local web_dir="$d/../web"
local templates="$target_dir/bskyweb/templates"
- local static_src="$d/html/static"
local static_out="$target_dir/bskyweb/static"
- # Check if html source exists
- if [ ! -d "$html_src" ]; then
- echo "⚠️ html/about/support not found, skipping template generation"
+ # Check if web directory exists
+ if [ ! -d "$web_dir" ]; then
+ echo "⚠️ web/ directory not found, skipping template generation"
+ return 1
+ fi
+
+ # Build web
+ echo " Building web..."
+ cd "$web_dir"
+ npm install --silent 2>/dev/null
+ npm run build --silent 2>/dev/null
+ cd "$d"
+
+ local dist_src="$web_dir/dist/aiat"
+ local static_src="$web_dir/dist/aiat/static"
+
+ # Check if build output exists
+ if [ ! -d "$dist_src" ]; then
+ echo "⚠️ web/dist/aiat not found, build may have failed"
return 1
fi
@@ -225,22 +240,18 @@ function ios-generate-bskyweb-templates() {
mkdir -p "$templates"
mkdir -p "$static_out"
- # Convert html/ to bskyweb templates
+ # Convert web/dist/aiat/ to bskyweb templates
+ # Structure: tos/index.html -> about-tos.html
# Add {{ staticCDNHost }} prefix to /static/ paths
- for html_file in privacy.html license.html tos.html help.html app.html; do
- if [ -f "$html_src/$html_file" ]; then
- local template_name="about-${html_file}"
+ for page in privacy license tos help app; do
+ local src_file="$dist_src/$page/index.html"
+ if [ -f "$src_file" ]; then
+ local template_name="about-${page}.html"
sed 's|href="/static/|href="{{ staticCDNHost }}/static/|g; s|src="/static/|src="{{ staticCDNHost }}/static/|g' \
- "$html_src/$html_file" > "$templates/$template_name"
+ "$src_file" > "$templates/$template_name"
fi
done
- # Also generate about-app.html from index.html if exists
- if [ -f "$d/html/index.html" ]; then
- sed 's|href="/static/|href="{{ staticCDNHost }}/static/|g; s|src="/static/|src="{{ staticCDNHost }}/static/|g' \
- "$d/html/index.html" > "$templates/about-app.html"
- fi
-
# Copy static assets
if [ -d "$static_src" ]; then
cp -f "$static_src/"* "$static_out/" 2>/dev/null
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..44e4a09
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "app-pages",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsx src/build.ts",
+ "watch": "tsx watch src/build.ts",
+ "serve": "npx serve dist",
+ "serve:aiat": "npx serve dist/aiat -p 3000",
+ "serve:aicard": "npx serve dist/aicard -p 3001",
+ "serve:airse": "npx serve dist/airse -p 3002",
+ "dev": "npm run build && npx concurrently \"npm run watch\" \"npm run serve:aiat\"",
+ "dev:aiat": "npm run build && npx concurrently \"npm run watch\" \"npm run serve:aiat\"",
+ "dev:aicard": "npm run build && npx concurrently \"npm run watch\" \"npm run serve:aicard\"",
+ "dev:airse": "npm run build && npx concurrently \"npm run watch\" \"npm run serve:airse\""
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "concurrently": "^9.0.0",
+ "serve": "^14.0.0",
+ "tsx": "^4.0.0",
+ "typescript": "^5.0.0"
+ },
+ "dependencies": {
+ "marked": "^15.0.0",
+ "gray-matter": "^4.0.3"
+ }
+}
diff --git a/html/static/app.png b/web/src/assets/aiat/app.png
similarity index 100%
rename from html/static/app.png
rename to web/src/assets/aiat/app.png
diff --git a/web/src/assets/aiat/icon.png b/web/src/assets/aiat/icon.png
new file mode 100644
index 0000000..f8f45fb
Binary files /dev/null and b/web/src/assets/aiat/icon.png differ
diff --git a/web/src/assets/aicard/app.png b/web/src/assets/aicard/app.png
new file mode 100644
index 0000000..69a6840
Binary files /dev/null and b/web/src/assets/aicard/app.png differ
diff --git a/web/src/assets/aicard/icon.png b/web/src/assets/aicard/icon.png
new file mode 100644
index 0000000..69a6840
Binary files /dev/null and b/web/src/assets/aicard/icon.png differ
diff --git a/web/src/assets/airse/app.png b/web/src/assets/airse/app.png
new file mode 100644
index 0000000..5a74926
Binary files /dev/null and b/web/src/assets/airse/app.png differ
diff --git a/web/src/assets/airse/icon.png b/web/src/assets/airse/icon.png
new file mode 100644
index 0000000..5a74926
Binary files /dev/null and b/web/src/assets/airse/icon.png differ
diff --git a/html/static/favicon.png b/web/src/assets/common/favicon.png
similarity index 100%
rename from html/static/favicon.png
rename to web/src/assets/common/favicon.png
diff --git a/web/src/build.ts b/web/src/build.ts
new file mode 100644
index 0000000..83e273f
--- /dev/null
+++ b/web/src/build.ts
@@ -0,0 +1,124 @@
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, copyFileSync, existsSync } from 'fs'
+import { join, dirname } from 'path'
+import { fileURLToPath } from 'url'
+import { marked } from 'marked'
+import matter from 'gray-matter'
+import type { SiteConfig, PageMeta } from './types.js'
+import { appLayout, legalLayout, oauthCallback, clientMetadata } from './templates/index.js'
+
+const __dirname = dirname(fileURLToPath(import.meta.url))
+const sitesDir = join(__dirname, 'sites')
+const contentDir = join(__dirname, 'content/pages')
+const assetsDir = join(__dirname, 'assets')
+const distDir = join(__dirname, '..', 'dist')
+
+// Load all site configs
+function loadSites(): SiteConfig[] {
+ const files = readdirSync(sitesDir).filter(f => f.endsWith('.json'))
+ return files.map(f => {
+ const content = readFileSync(join(sitesDir, f), 'utf-8')
+ return JSON.parse(content) as SiteConfig
+ })
+}
+
+// Load all markdown pages
+function loadPages(): Array<{ meta: PageMeta & { path: string }, content: string }> {
+ const files = readdirSync(contentDir).filter(f => f.endsWith('.md'))
+ return files.map(f => {
+ const raw = readFileSync(join(contentDir, f), 'utf-8')
+ const { data, content } = matter(raw)
+ return {
+ meta: data as PageMeta & { path: string },
+ content: marked(content) as string
+ }
+ })
+}
+
+// Ensure directory exists
+function ensureDir(filePath: string): void {
+ mkdirSync(dirname(filePath), { recursive: true })
+}
+
+// Copy assets for a site
+function copyAssets(site: SiteConfig): void {
+ const siteAssetsDir = join(assetsDir, site.id)
+ const commonAssetsDir = join(assetsDir, 'common')
+ const outDir = join(distDir, site.id, 'static')
+
+ ensureDir(join(outDir, '_'))
+
+ // Copy common assets first
+ if (existsSync(commonAssetsDir)) {
+ const files = readdirSync(commonAssetsDir)
+ for (const file of files) {
+ copyFileSync(join(commonAssetsDir, file), join(outDir, file))
+ console.log(` -> static/${file} (common)`)
+ }
+ }
+
+ // Copy site-specific assets (overrides common)
+ if (existsSync(siteAssetsDir)) {
+ const files = readdirSync(siteAssetsDir)
+ for (const file of files) {
+ copyFileSync(join(siteAssetsDir, file), join(outDir, file))
+ console.log(` -> static/${file}`)
+ }
+ }
+}
+
+// Build site
+function buildSite(site: SiteConfig, pages: ReturnType): void {
+ const siteDir = join(distDir, site.id)
+
+ console.log(`Building ${site.name} (${site.domain})...`)
+
+ // Build pages from markdown
+ for (const page of pages) {
+ const outPath = join(siteDir, page.meta.path)
+ ensureDir(outPath)
+
+ let html: string
+ if (page.meta.template === 'app') {
+ html = appLayout(site, page.content)
+ } else {
+ html = legalLayout(site, page.meta.title, page.content)
+ }
+
+ writeFileSync(outPath, html)
+ console.log(` -> ${page.meta.path}`)
+ }
+
+ // Build OAuth callback
+ const callbackPath = join(siteDir, 'oauth/callback/index.html')
+ ensureDir(callbackPath)
+ writeFileSync(callbackPath, oauthCallback(site))
+ console.log(` -> oauth/callback/index.html`)
+
+ // Build client metadata
+ const metadataPath = join(siteDir, '.well-known/client-metadata.json')
+ ensureDir(metadataPath)
+ writeFileSync(metadataPath, clientMetadata(site))
+ console.log(` -> .well-known/client-metadata.json`)
+
+ // Copy assets
+ copyAssets(site)
+}
+
+// Main
+function main(): void {
+ console.log('App Pages Builder\n')
+
+ const sites = loadSites()
+ const pages = loadPages()
+
+ console.log(`Found ${sites.length} sites, ${pages.length} pages\n`)
+
+ for (const site of sites) {
+ buildSite(site, pages)
+ console.log('')
+ }
+
+ console.log('Done!')
+}
+
+main()
diff --git a/web/src/content/pages/app.md b/web/src/content/pages/app.md
new file mode 100644
index 0000000..d27753b
--- /dev/null
+++ b/web/src/content/pages/app.md
@@ -0,0 +1,5 @@
+---
+title: App Info
+template: app
+path: app/index.html
+---
diff --git a/web/src/content/pages/help.md b/web/src/content/pages/help.md
new file mode 100644
index 0000000..2ba3804
--- /dev/null
+++ b/web/src/content/pages/help.md
@@ -0,0 +1,39 @@
+---
+title: Help
+template: legal
+path: help/index.html
+---
+
+## Getting Started
+
+1. Download the app from the App Store
+2. Sign in with your AT Protocol handle
+3. Start connecting with your community
+
+## Authentication
+
+This app uses AT Protocol OAuth for secure authentication. Your credentials are never stored on our servers.
+
+## Features
+
+- **Feed**: View posts from people you follow
+- **Profile**: Customize your profile and view your posts
+- **Search**: Find users and content
+
+## Troubleshooting
+
+### Login Issues
+
+- Ensure your handle is correct
+- Check your internet connection
+- Try logging out and back in
+
+### App Crashes
+
+- Update to the latest version
+- Restart the app
+- If issues persist, contact support
+
+## Contact Support
+
+For additional help, please visit our Git repository or contact us through AT Protocol.
diff --git a/web/src/content/pages/index.md b/web/src/content/pages/index.md
new file mode 100644
index 0000000..75d5f34
--- /dev/null
+++ b/web/src/content/pages/index.md
@@ -0,0 +1,5 @@
+---
+title: App Info
+template: app
+path: index.html
+---
diff --git a/web/src/content/pages/license.md b/web/src/content/pages/license.md
new file mode 100644
index 0000000..ee09f3e
--- /dev/null
+++ b/web/src/content/pages/license.md
@@ -0,0 +1,29 @@
+---
+title: License
+template: legal
+path: license/index.html
+---
+
+## Software License
+
+This application is open source software.
+
+## Third-Party Libraries
+
+This app uses the following open source libraries:
+
+- **AT Protocol** - MIT License
+- **React Native** - MIT License
+- **Expo** - MIT License
+
+## Assets
+
+App icons and graphics are proprietary unless otherwise noted.
+
+## Attribution
+
+Built with AT Protocol by syui.
+
+## Contact
+
+For licensing questions, please contact us through our Git repository.
diff --git a/web/src/content/pages/privacy.md b/web/src/content/pages/privacy.md
new file mode 100644
index 0000000..9f7a5be
--- /dev/null
+++ b/web/src/content/pages/privacy.md
@@ -0,0 +1,35 @@
+---
+title: Privacy Policy
+template: legal
+path: privacy/index.html
+---
+
+## Information We Collect
+
+This application collects minimal information necessary for its operation:
+
+- **Account Information**: Your AT Protocol handle and DID for authentication
+- **Usage Data**: Basic app usage statistics to improve the service
+
+## How We Use Your Information
+
+- To provide and maintain the service
+- To authenticate your identity
+- To improve user experience
+
+## Data Storage
+
+Your data is stored securely on AT Protocol compatible servers. We do not sell or share your personal information with third parties.
+
+## Your Rights
+
+You have the right to:
+- Access your personal data
+- Delete your account and associated data
+- Export your data
+
+## Contact
+
+If you have questions about this privacy policy, please contact us through our Git repository.
+
+*Last updated: 2025-01-15*
diff --git a/web/src/content/pages/tos.md b/web/src/content/pages/tos.md
new file mode 100644
index 0000000..3d5cfdf
--- /dev/null
+++ b/web/src/content/pages/tos.md
@@ -0,0 +1,40 @@
+---
+title: Terms of Service
+template: legal
+path: tos/index.html
+---
+
+## Acceptance of Terms
+
+By using this application, you agree to these Terms of Service.
+
+## Use of Service
+
+You agree to use this service in accordance with:
+- Applicable laws and regulations
+- AT Protocol community guidelines
+- Respectful behavior towards other users
+
+## User Content
+
+You retain ownership of content you create. By posting content, you grant us a license to display it within the service.
+
+## Prohibited Activities
+
+- Harassment or abuse of other users
+- Spam or automated abuse
+- Attempting to compromise the service
+
+## Termination
+
+We reserve the right to terminate accounts that violate these terms.
+
+## Disclaimer
+
+This service is provided "as is" without warranties of any kind.
+
+## Changes
+
+We may update these terms from time to time. Continued use constitutes acceptance of changes.
+
+*Last updated: 2025-01-15*
diff --git a/web/src/sites/aiat.json b/web/src/sites/aiat.json
new file mode 100644
index 0000000..d43259c
--- /dev/null
+++ b/web/src/sites/aiat.json
@@ -0,0 +1,13 @@
+{
+ "id": "aiat",
+ "name": "Aiat",
+ "domain": "at.syui.ai",
+ "scheme": "aiat",
+ "version": "1.111.0",
+ "category": "Social",
+ "description": "Aiat is a social networking application based on AT Protocol. Connect with your community on syu.is.",
+ "backLink": "syu.is",
+ "icon": "/static/app.png",
+ "os": "iOS 26.0+",
+ "price": "Free"
+}
diff --git a/web/src/sites/aicard.json b/web/src/sites/aicard.json
new file mode 100644
index 0000000..beef462
--- /dev/null
+++ b/web/src/sites/aicard.json
@@ -0,0 +1,13 @@
+{
+ "id": "aicard",
+ "name": "Aicard",
+ "domain": "card.syui.ai",
+ "scheme": "aicard",
+ "version": "1.0.0",
+ "category": "Game",
+ "description": "Aicard is a card collection game integrated with AT Protocol.",
+ "backLink": "syui.ai",
+ "icon": "/static/app.png",
+ "os": "iOS 26.0+",
+ "price": "Free"
+}
diff --git a/web/src/sites/airse.json b/web/src/sites/airse.json
new file mode 100644
index 0000000..f2b0626
--- /dev/null
+++ b/web/src/sites/airse.json
@@ -0,0 +1,13 @@
+{
+ "id": "airse",
+ "name": "Airse",
+ "domain": "rse.syui.ai",
+ "scheme": "airse",
+ "version": "1.0.0",
+ "category": "Game",
+ "description": "Airse is an adventure game built with Unreal Engine, featuring AT Protocol integration.",
+ "backLink": "syui.ai",
+ "icon": "/static/app.png",
+ "os": "Windows / macOS",
+ "price": "Free"
+}
diff --git a/web/src/templates/index.ts b/web/src/templates/index.ts
new file mode 100644
index 0000000..ac716f2
--- /dev/null
+++ b/web/src/templates/index.ts
@@ -0,0 +1,3 @@
+export { layout, appLayout, legalLayout } from './layout.js'
+export { oauthCallback, clientMetadata } from './oauth.js'
+export { baseStyles } from './styles.js'
diff --git a/web/src/templates/layout.ts b/web/src/templates/layout.ts
new file mode 100644
index 0000000..ad33caa
--- /dev/null
+++ b/web/src/templates/layout.ts
@@ -0,0 +1,105 @@
+import type { SiteConfig } from '../types.js'
+import { baseStyles } from './styles.js'
+
+export function layout(site: SiteConfig, title: string, content: string): string {
+ return `
+
+
+
+
+ ${title} - ${site.name}
+
+
+
+
+
+ ${content}
+
+
+`
+}
+
+export function appLayout(site: SiteConfig, content: string): string {
+ return layout(site, 'App Info', `
+
+
+
+
+
+
App Information
+
+
+
Category
+
${site.category}
+
+
+
Supported OS
+
${site.os}
+
+
+
Price
+
${site.price}
+
+
+
+
+
+
+
+
Bitcoin
+
+ ₿
+ 3BqHXxraZyBapyNpJmniJDh9zqzuB8aoRr
+ copy
+
+
+
+ ${content}
+
+
+`)
+}
+
+export function legalLayout(site: SiteConfig, title: string, content: string): string {
+ return layout(site, title, `
+
+
${title}
+ ${content}
+
+`)
+}
diff --git a/web/src/templates/oauth.ts b/web/src/templates/oauth.ts
new file mode 100644
index 0000000..37c57d6
--- /dev/null
+++ b/web/src/templates/oauth.ts
@@ -0,0 +1,46 @@
+import type { SiteConfig } from '../types.js'
+
+export function oauthCallback(site: SiteConfig): string {
+ return `
+
+
+ Redirecting...
+
+
+
+ Redirecting to app...
+ If nothing happens, click
+ here to continue in browser.
+
+`
+}
+
+export function clientMetadata(site: SiteConfig): string {
+ return JSON.stringify({
+ client_id: `https://${site.domain}/.well-known/client-metadata.json`,
+ client_name: site.name,
+ client_uri: `https://${site.domain}`,
+ logo_uri: `https://${site.domain}/static/icon.png`,
+ redirect_uris: [
+ `https://${site.domain}/oauth/callback`
+ ],
+ scope: 'atproto transition:generic',
+ grant_types: [
+ 'authorization_code',
+ 'refresh_token'
+ ],
+ response_types: [
+ 'code'
+ ],
+ token_endpoint_auth_method: 'none',
+ application_type: 'web',
+ dpop_bound_access_tokens: true
+ }, null, 2)
+}
diff --git a/web/src/templates/styles.ts b/web/src/templates/styles.ts
new file mode 100644
index 0000000..6fa052a
--- /dev/null
+++ b/web/src/templates/styles.ts
@@ -0,0 +1,51 @@
+export const baseStyles = `
+* { box-sizing: border-box; margin: 0; padding: 0; }
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ line-height: 1.6;
+ color: #1a1a1a;
+ background: #fff;
+ padding: 20px;
+ max-width: 800px;
+ margin: 0 auto;
+}
+@media (prefers-color-scheme: dark) {
+ body { background: #000; color: #e0e0e0; }
+ a { color: #6bb3ff; }
+ h1, h2, h3 { color: #fff; }
+ .section { background: #1a1a1a; }
+ .info-item { background: #2a2a2a; }
+}
+.header { margin-bottom: 32px; }
+.back-link { display: inline-block; margin-bottom: 16px; font-size: 14px; color: #0066cc; text-decoration: none; }
+.back-link:hover { text-decoration: underline; }
+.app-header { text-align: center; margin-bottom: 32px; }
+.app-icon { width: 80px; height: 80px; border-radius: 18px; margin-bottom: 12px; }
+.app-name { font-size: 24px; font-weight: bold; margin-bottom: 4px; }
+.app-version { font-size: 14px; color: #666; }
+.section { background: #f5f5f5; border-radius: 16px; padding: 20px; margin-bottom: 16px; }
+.section-title { font-size: 13px; font-weight: 600; color: #999; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; }
+.description { font-size: 15px; line-height: 22px; }
+.info-grid { display: flex; flex-wrap: wrap; gap: 8px; }
+.info-item { flex: 1; min-width: 45%; text-align: center; background: #e8e8e8; border-radius: 12px; padding: 12px; }
+.info-label { font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
+.info-value { font-size: 16px; font-weight: 600; }
+.developer-name { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
+.link-row { display: flex; align-items: center; padding: 12px 0; border-top: 1px solid rgba(0,0,0,0.1); }
+.link-icon { font-size: 14px; font-weight: 600; color: #666; width: 70px; }
+.link-value { flex: 1; font-size: 14px; color: #0084ff; text-decoration: none; }
+.link-value:hover { text-decoration: underline; }
+.link-arrow { font-size: 16px; color: #ccc; }
+.bitcoin-row { display: flex; align-items: center; background: rgba(247, 147, 26, 0.08); border-radius: 12px; padding: 14px; gap: 10px; }
+.bitcoin-label { font-size: 18px; font-weight: 600; color: #f7931a; }
+.bitcoin-address { flex: 1; font-size: 11px; font-family: monospace; color: #666; word-break: break-all; }
+.copy-btn { font-size: 12px; color: #999; cursor: pointer; min-width: 50px; text-align: right; }
+.copy-btn:hover { color: #0084ff; }
+.footer { text-align: center; margin-top: 32px; padding-top: 20px; border-top: 1px solid #ddd; }
+.copyright { font-size: 12px; color: #999; }
+.content h1 { font-size: 24px; margin-bottom: 16px; }
+.content h2 { font-size: 18px; margin: 24px 0 12px; }
+.content p { margin-bottom: 12px; }
+.content ul { margin: 12px 0; padding-left: 24px; }
+.content li { margin-bottom: 8px; }
+`
diff --git a/web/src/types.ts b/web/src/types.ts
new file mode 100644
index 0000000..6419477
--- /dev/null
+++ b/web/src/types.ts
@@ -0,0 +1,18 @@
+export interface SiteConfig {
+ id: string
+ name: string
+ domain: string
+ scheme: string
+ version: string
+ category: string
+ description: string
+ backLink: string
+ icon: string
+ os: string
+ price: string
+}
+
+export interface PageMeta {
+ title: string
+ template?: 'default' | 'app' | 'legal'
+}
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..84fadc1
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "esModuleInterop": true,
+ "strict": true,
+ "resolveJsonModule": true,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}