Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

wrap create-wisp around wispctl, doc updates, redirects is a package, final pass on landing page

+927 -1428
+6 -2
README.md
··· 24 24 25 25 - **`/apps/main-app`** - Main backend (OAuth, site management, custom domains) 26 26 - **`/apps/hosting-service`** - Microservice that serves cached sites from disk 27 - - **`/cli`** - Rust CLI for direct PDS uploads 27 + - **`/cli`** - CLI for direct PDS uploads as well as serving with firehose updates 28 + - **`/rust-cli`** - Deprecated Rust CLI for direct PDS uploads with firehose updates 28 29 - **`/apps/main-app/public`** - React frontend 29 30 - **`/packages`** - Shared packages 30 31 ··· 61 62 62 63 # CLI 63 64 cd cli 64 - cargo build 65 + bun install 66 + bun run index.ts 67 + bun build 68 + node dist/index.js 65 69 ``` 66 70 67 71 ## Features
+2 -2
agents.md
··· 97 97 - place.wisp.fs has a required site field 98 98 - place.wisp.fs#subfs has an optional flat field that place.wisp.subfs#subfs doesn't have 99 99 100 - The project is a monorepo. The package handler it uses for the typescript side is Bun. For the Rust cli, it is cargo. 100 + The project is a monorepo. The package handler it uses is bun. Please when you want to add a package, which is never unless told to, do bun add ..., please do not try to edit package.json yourself. 101 101 102 102 ### Typescript Bun Workspace Layout 103 103 ··· 133 133 134 134 ### CLI 135 135 136 - **`cli/`** - Rust CLI using Jacquard (AT Protocol library) 136 + **`cli/`** - TypeScript CLI using commander, clack. 137 137 - Direct PDS uploads without interacting with main-app 138 138 - Can also do the same firehose watching, caching, and serving hosting-service does, just without domain management 139 139
+6 -8
apps/hosting-service/src/lib/redirects.test.ts packages/@wisp/fs-utils/src/redirects.test.ts
··· 1 - import { describe, it, expect } from 'bun:test' 1 + import { describe, it, expect } from 'bun:test'; 2 2 import { parseRedirectsFile, matchRedirectRule } from './redirects'; 3 3 4 4 describe('parseRedirectsFile', () => { ··· 185 185 /jobs/* /careers/:splat 186 186 `; 187 187 const rules = parseRedirectsFile(content); 188 - 188 + 189 189 const match1 = matchRedirectRule('/jobs/customer-ninja', rules); 190 190 expect(match1?.targetPath).toBe('/careers/support'); 191 - 191 + 192 192 const match2 = matchRedirectRule('/jobs/developer', rules); 193 193 expect(match2?.targetPath).toBe('/careers/developer'); 194 194 }); 195 195 196 196 it('should handle SPA routing pattern', () => { 197 197 const rules = parseRedirectsFile('/* /index.html 200'); 198 - 199 - // Should match any path 198 + 200 199 const match1 = matchRedirectRule('/about', rules); 201 200 expect(match1).toBeTruthy(); 202 201 expect(match1?.targetPath).toBe('/index.html'); 203 202 expect(match1?.status).toBe(200); 204 - 203 + 205 204 const match2 = matchRedirectRule('/users/123/profile', rules); 206 205 expect(match2).toBeTruthy(); 207 206 expect(match2?.targetPath).toBe('/index.html'); 208 207 expect(match2?.status).toBe(200); 209 - 208 + 210 209 const match3 = matchRedirectRule('/', rules); 211 210 expect(match3).toBeTruthy(); 212 211 expect(match3?.targetPath).toBe('/index.html'); 213 212 }); 214 213 }); 215 -
+12 -437
apps/hosting-service/src/lib/redirects.ts
··· 1 1 import { readFile } from 'fs/promises'; 2 2 import { existsSync } from 'fs'; 3 + import { parseRedirectsFile, type RedirectRule } from '@wisp/fs-utils'; 3 4 4 - export interface RedirectRule { 5 - from: string; 6 - to: string; 7 - status: number; 8 - force: boolean; 9 - conditions?: { 10 - country?: string[]; 11 - language?: string[]; 12 - role?: string[]; 13 - cookie?: string[]; 14 - }; 15 - // For pattern matching 16 - fromPattern?: RegExp; 17 - fromParams?: string[]; // Named parameters from the pattern 18 - queryParams?: Record<string, string>; // Expected query parameters 19 - } 20 - 21 - export interface RedirectMatch { 22 - rule: RedirectRule; 23 - targetPath: string; 24 - status: number; 25 - } 26 - 27 - // Maximum number of redirect rules to prevent DoS attacks 28 - const MAX_REDIRECT_RULES = 1000; 29 - 30 - /** 31 - * Parse a _redirects file into an array of redirect rules 32 - */ 33 - export function parseRedirectsFile(content: string): RedirectRule[] { 34 - const lines = content.split('\n'); 35 - const rules: RedirectRule[] = []; 36 - 37 - for (let lineNum = 0; lineNum < lines.length; lineNum++) { 38 - const lineRaw = lines[lineNum]; 39 - if (!lineRaw) continue; 40 - 41 - const line = lineRaw.trim(); 42 - 43 - // Skip empty lines and comments 44 - if (!line || line.startsWith('#')) { 45 - continue; 46 - } 47 - 48 - // Enforce max rules limit 49 - if (rules.length >= MAX_REDIRECT_RULES) { 50 - console.warn(`Redirect rules limit reached (${MAX_REDIRECT_RULES}), ignoring remaining rules`); 51 - break; 52 - } 53 - 54 - try { 55 - const rule = parseRedirectLine(line); 56 - if (rule && rule.fromPattern) { 57 - rules.push(rule); 58 - } 59 - } catch (err) { 60 - console.warn(`Failed to parse redirect rule on line ${lineNum + 1}: ${line}`, err); 61 - } 62 - } 63 - 64 - return rules; 65 - } 66 - 67 - /** 68 - * Parse a single redirect rule line 69 - * Format: /from [query_params] /to [status] [conditions] 70 - */ 71 - function parseRedirectLine(line: string): RedirectRule | null { 72 - // Split by whitespace, but respect quoted strings (though not commonly used) 73 - const parts = line.split(/\s+/); 74 - 75 - if (parts.length < 2) { 76 - return null; 77 - } 78 - 79 - let idx = 0; 80 - const from = parts[idx++]; 81 - 82 - if (!from) { 83 - return null; 84 - } 85 - 86 - let status = 301; // Default status 87 - let force = false; 88 - const conditions: NonNullable<RedirectRule['conditions']> = {}; 89 - const queryParams: Record<string, string> = {}; 90 - 91 - // Parse query parameters that come before the destination path 92 - // They look like: key=:value (and don't start with /) 93 - while (idx < parts.length) { 94 - const part = parts[idx]; 95 - if (!part) { 96 - idx++; 97 - continue; 98 - } 99 - 100 - // If it starts with / or http, it's the destination path 101 - if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) { 102 - break; 103 - } 104 - 105 - // If it contains = and comes before the destination, it's a query param 106 - if (part.includes('=')) { 107 - const splitIndex = part.indexOf('='); 108 - const key = part.slice(0, splitIndex); 109 - const value = part.slice(splitIndex + 1); 110 - 111 - if (key && value) { 112 - queryParams[key] = value; 113 - } 114 - idx++; 115 - } else { 116 - // Not a query param, must be destination or something else 117 - break; 118 - } 119 - } 120 - 121 - // Next part should be the destination 122 - if (idx >= parts.length) { 123 - return null; 124 - } 125 - 126 - const to = parts[idx++]; 127 - if (!to) { 128 - return null; 129 - } 130 - 131 - // Parse remaining parts for status code and conditions 132 - for (let i = idx; i < parts.length; i++) { 133 - const part = parts[i]; 134 - 135 - if (!part) continue; 136 - 137 - // Check for status code (with optional ! for force) 138 - if (/^\d+!?$/.test(part)) { 139 - if (part.endsWith('!')) { 140 - force = true; 141 - status = parseInt(part.slice(0, -1)); 142 - } else { 143 - status = parseInt(part); 144 - } 145 - continue; 146 - } 147 - 148 - // Check for condition parameters (Country=, Language=, Role=, Cookie=) 149 - if (part.includes('=')) { 150 - const splitIndex = part.indexOf('='); 151 - const key = part.slice(0, splitIndex); 152 - const value = part.slice(splitIndex + 1); 153 - 154 - if (!key || !value) continue; 155 - 156 - const keyLower = key.toLowerCase(); 157 - 158 - if (keyLower === 'country') { 159 - conditions.country = value.split(',').map(v => v.trim().toLowerCase()); 160 - } else if (keyLower === 'language') { 161 - conditions.language = value.split(',').map(v => v.trim().toLowerCase()); 162 - } else if (keyLower === 'role') { 163 - conditions.role = value.split(',').map(v => v.trim()); 164 - } else if (keyLower === 'cookie') { 165 - conditions.cookie = value.split(',').map(v => v.trim().toLowerCase()); 166 - } 167 - } 168 - } 169 - 170 - // Parse the 'from' pattern 171 - const { pattern, params } = convertPathToRegex(from); 172 - 173 - return { 174 - from, 175 - to, 176 - status, 177 - force, 178 - conditions: Object.keys(conditions).length > 0 ? conditions : undefined, 179 - queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, 180 - fromPattern: pattern, 181 - fromParams: params, 182 - }; 183 - } 184 - 185 - /** 186 - * Convert a path pattern with placeholders and splats to a regex 187 - * Examples: 188 - * /blog/:year/:month/:day -> captures year, month, day 189 - * /news/* -> captures splat 190 - */ 191 - function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } { 192 - const params: string[] = []; 193 - let regexStr = '^'; 194 - 195 - // Split by query string if present 196 - const pathPart = pattern.split('?')[0] || pattern; 197 - 198 - // Escape special regex characters except * and : 199 - let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 200 - 201 - // Replace :param with named capture groups 202 - escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, paramName) => { 203 - params.push(paramName); 204 - // Match path segment (everything except / and ?) 205 - return '([^/?]+)'; 206 - }); 207 - 208 - // Replace * with splat capture (matches everything including /) 209 - if (escaped.includes('*')) { 210 - escaped = escaped.replace(/\*/g, '(.*)'); 211 - params.push('splat'); 212 - } 213 - 214 - regexStr += escaped; 215 - 216 - // Make trailing slash optional 217 - if (!regexStr.endsWith('.*')) { 218 - regexStr += '/?'; 219 - } 220 - 221 - regexStr += '$'; 222 - 223 - return { 224 - pattern: new RegExp(regexStr), 225 - params, 226 - }; 227 - } 228 - 229 - /** 230 - * Match a request path against redirect rules with loop detection 231 - */ 232 - export function matchRedirectRule( 233 - requestPath: string, 234 - rules: RedirectRule[], 235 - context?: { 236 - queryParams?: Record<string, string>; 237 - headers?: Record<string, string>; 238 - cookies?: Record<string, string>; 239 - }, 240 - visitedPaths: Set<string> = new Set() 241 - ): RedirectMatch | null { 242 - // Normalize path: ensure leading slash, remove trailing slash (except for root) 243 - let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; 244 - 245 - // Detect redirect loops 246 - if (visitedPaths.has(normalizedPath)) { 247 - console.warn(`Redirect loop detected for path: ${normalizedPath}`); 248 - return null; 249 - } 250 - 251 - // Track this path to detect loops 252 - visitedPaths.add(normalizedPath); 253 - 254 - // Limit redirect chain depth to 10 255 - if (visitedPaths.size > 10) { 256 - console.warn(`Redirect chain too deep (>10) for path: ${normalizedPath}`); 257 - return null; 258 - } 259 - 260 - for (const rule of rules) { 261 - // Check query parameter conditions first (if any) 262 - if (rule.queryParams) { 263 - // If rule requires query params but none provided, skip this rule 264 - if (!context?.queryParams) { 265 - continue; 266 - } 267 - 268 - // Check that all required query params are present 269 - // The value in rule.queryParams is either a literal or a placeholder (:name) 270 - const queryMatches = Object.entries(rule.queryParams).every(([key, expectedValue]) => { 271 - const actualValue = context.queryParams?.[key]; 272 - 273 - // Query param must exist 274 - if (actualValue === undefined) { 275 - return false; 276 - } 277 - 278 - // If expected value is a placeholder (:name), any value is acceptable 279 - // If it's a literal, it must match exactly 280 - if (expectedValue && !expectedValue.startsWith(':')) { 281 - return actualValue === expectedValue; 282 - } 283 - 284 - return true; 285 - }); 286 - 287 - if (!queryMatches) { 288 - continue; 289 - } 290 - } 291 - 292 - // Check conditional redirects (country, language, role, cookie) 293 - if (rule.conditions) { 294 - if (rule.conditions.country && context?.headers) { 295 - const cfCountry = context.headers['cf-ipcountry']; 296 - const xCountry = context.headers['x-country']; 297 - const country = (cfCountry?.toLowerCase() || xCountry?.toLowerCase()); 298 - if (!country || !rule.conditions.country.includes(country)) { 299 - continue; 300 - } 301 - } 302 - 303 - if (rule.conditions.language && context?.headers) { 304 - const acceptLang = context.headers['accept-language']; 305 - if (!acceptLang) { 306 - continue; 307 - } 308 - // Parse accept-language header (simplified) 309 - const langs = acceptLang.split(',').map(l => { 310 - const langPart = l.split(';')[0]; 311 - return langPart ? langPart.trim().toLowerCase() : ''; 312 - }).filter(l => l !== ''); 313 - const hasMatch = rule.conditions.language.some(lang => 314 - langs.some(l => l === lang || l.startsWith(lang + '-')) 315 - ); 316 - if (!hasMatch) { 317 - continue; 318 - } 319 - } 320 - 321 - if (rule.conditions.cookie && context?.cookies) { 322 - const hasCookie = rule.conditions.cookie.some(cookieName => 323 - context.cookies && cookieName in context.cookies 324 - ); 325 - if (!hasCookie) { 326 - continue; 327 - } 328 - } 329 - 330 - // Role-based redirects would need JWT verification - skip for now 331 - if (rule.conditions.role) { 332 - continue; 333 - } 334 - } 335 - 336 - // Match the path pattern 337 - const match = rule.fromPattern?.exec(normalizedPath); 338 - if (!match) { 339 - continue; 340 - } 341 - 342 - // Build the target path by replacing placeholders 343 - let targetPath = rule.to; 344 - 345 - // Replace captured parameters (with URL encoding) 346 - if (rule.fromParams && match.length > 1) { 347 - for (let i = 0; i < rule.fromParams.length; i++) { 348 - const paramName = rule.fromParams[i]; 349 - const paramValue = match[i + 1]; 350 - 351 - if (!paramName || !paramValue) continue; 352 - 353 - // URL encode captured values to prevent invalid URLs 354 - const encodedValue = encodeURIComponent(paramValue); 355 - 356 - if (paramName === 'splat') { 357 - // For splats, preserve slashes by re-decoding them 358 - const splatValue = encodedValue.replace(/%2F/g, '/'); 359 - targetPath = targetPath.replace(':splat', splatValue); 360 - } else { 361 - targetPath = targetPath.replace(`:${paramName}`, encodedValue); 362 - } 363 - } 364 - } 365 - 366 - // Handle query parameter replacements (with URL encoding) 367 - if (rule.queryParams && context?.queryParams) { 368 - for (const [key, placeholder] of Object.entries(rule.queryParams)) { 369 - const actualValue = context.queryParams[key]; 370 - if (actualValue && placeholder && placeholder.startsWith(':')) { 371 - const paramName = placeholder.slice(1); 372 - if (paramName) { 373 - // URL encode query parameter values 374 - const encodedValue = encodeURIComponent(actualValue); 375 - targetPath = targetPath.replace(`:${paramName}`, encodedValue); 376 - } 377 - } 378 - } 379 - } 380 - 381 - // Preserve query string for 200, 301, 302 redirects (unless target already has one) 382 - if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) { 383 - const queryString = Object.entries(context.queryParams) 384 - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) 385 - .join('&'); 386 - if (queryString) { 387 - targetPath += `?${queryString}`; 388 - } 389 - } 390 - 391 - return { 392 - rule, 393 - targetPath, 394 - status: rule.status, 395 - }; 396 - } 397 - 398 - return null; 399 - } 5 + // Re-export everything from the shared package 6 + export { 7 + parseRedirectsFile, 8 + matchRedirectRule, 9 + parseCookies, 10 + parseQueryString, 11 + type RedirectRule, 12 + type RedirectMatch, 13 + type MatchRedirectContext, 14 + } from '@wisp/fs-utils'; 400 15 401 16 /** 402 17 * Load redirect rules from a cached site ··· 404 19 export async function loadRedirectRules(did: string, rkey: string): Promise<RedirectRule[]> { 405 20 const CACHE_DIR = process.env.CACHE_DIR || './cache/sites'; 406 21 const redirectsPath = `${CACHE_DIR}/${did}/${rkey}/_redirects`; 407 - 22 + 408 23 if (!existsSync(redirectsPath)) { 409 24 return []; 410 25 } ··· 417 32 return []; 418 33 } 419 34 } 420 - 421 - /** 422 - * Parse cookies from Cookie header 423 - */ 424 - export function parseCookies(cookieHeader?: string): Record<string, string> { 425 - if (!cookieHeader) return {}; 426 - 427 - const cookies: Record<string, string> = {}; 428 - const parts = cookieHeader.split(';'); 429 - 430 - for (const part of parts) { 431 - const [key, ...valueParts] = part.split('='); 432 - if (key && valueParts.length > 0) { 433 - cookies[key.trim()] = valueParts.join('=').trim(); 434 - } 435 - } 436 - 437 - return cookies; 438 - } 439 - 440 - /** 441 - * Parse query string into object 442 - */ 443 - export function parseQueryString(url: string): Record<string, string> { 444 - const queryStart = url.indexOf('?'); 445 - if (queryStart === -1) return {}; 446 - 447 - const queryString = url.slice(queryStart + 1); 448 - const params: Record<string, string> = {}; 449 - 450 - for (const pair of queryString.split('&')) { 451 - const [key, value] = pair.split('='); 452 - if (key) { 453 - params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; 454 - } 455 - } 456 - 457 - return params; 458 - } 459 -
-759
apps/main-app/public/index.tsx
··· 1 - import React, { useState, useRef, useEffect } from 'react' 2 - import { createRoot } from 'react-dom/client' 3 - import { ArrowRight } from 'lucide-react' 4 - import Layout from '@public/layouts' 5 - import { Button } from '@public/components/ui/button' 6 - import { Card } from '@public/components/ui/card' 7 - import { BlueskyPostList, BlueskyProfile, BlueskyPost, AtProtoProvider, useLatestRecord, type AtProtoStyles, type FeedPostRecord } from 'atproto-ui' 8 - 9 - //Credit to https://tangled.org/@jakelazaroff.com/actor-typeahead 10 - interface Actor { 11 - handle: string 12 - avatar?: string 13 - displayName?: string 14 - } 15 - 16 - interface ActorTypeaheadProps { 17 - children: React.ReactElement<React.InputHTMLAttributes<HTMLInputElement>> 18 - host?: string 19 - rows?: number 20 - onSelect?: (handle: string) => void 21 - autoSubmit?: boolean 22 - } 23 - 24 - const ActorTypeahead: React.FC<ActorTypeaheadProps> = ({ 25 - children, 26 - host = 'https://public.api.bsky.app', 27 - rows = 5, 28 - onSelect, 29 - autoSubmit = false 30 - }) => { 31 - const [actors, setActors] = useState<Actor[]>([]) 32 - const [index, setIndex] = useState(-1) 33 - const [pressed, setPressed] = useState(false) 34 - const [isOpen, setIsOpen] = useState(false) 35 - const containerRef = useRef<HTMLDivElement>(null) 36 - const inputRef = useRef<HTMLInputElement>(null) 37 - const lastQueryRef = useRef<string>('') 38 - const previousValueRef = useRef<string>('') 39 - const preserveIndexRef = useRef(false) 40 - 41 - const handleInput = async (e: React.FormEvent<HTMLInputElement>) => { 42 - const query = e.currentTarget.value 43 - 44 - // Check if the value actually changed (filter out arrow key events) 45 - if (query === previousValueRef.current) { 46 - return 47 - } 48 - previousValueRef.current = query 49 - 50 - if (!query) { 51 - setActors([]) 52 - setIndex(-1) 53 - setIsOpen(false) 54 - lastQueryRef.current = '' 55 - return 56 - } 57 - 58 - // Store the query for this request 59 - const currentQuery = query 60 - lastQueryRef.current = currentQuery 61 - 62 - try { 63 - const url = new URL('xrpc/app.bsky.actor.searchActorsTypeahead', host) 64 - url.searchParams.set('q', query) 65 - url.searchParams.set('limit', `${rows}`) 66 - 67 - const res = await fetch(url) 68 - const json = await res.json() 69 - 70 - // Only update if this is still the latest query 71 - if (lastQueryRef.current === currentQuery) { 72 - setActors(json.actors || []) 73 - // Only reset index if we're not preserving it 74 - if (!preserveIndexRef.current) { 75 - setIndex(-1) 76 - } 77 - preserveIndexRef.current = false 78 - setIsOpen(true) 79 - } 80 - } catch (error) { 81 - console.error('Failed to fetch actors:', error) 82 - if (lastQueryRef.current === currentQuery) { 83 - setActors([]) 84 - setIsOpen(false) 85 - } 86 - } 87 - } 88 - 89 - const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { 90 - const navigationKeys = ['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Enter', 'Escape'] 91 - 92 - // Mark that we should preserve the index for navigation keys 93 - if (navigationKeys.includes(e.key)) { 94 - preserveIndexRef.current = true 95 - } 96 - 97 - if (!isOpen || actors.length === 0) return 98 - 99 - switch (e.key) { 100 - case 'ArrowDown': 101 - e.preventDefault() 102 - setIndex((prev) => { 103 - const newIndex = prev < 0 ? 0 : Math.min(prev + 1, actors.length - 1) 104 - return newIndex 105 - }) 106 - break 107 - case 'PageDown': 108 - e.preventDefault() 109 - setIndex(actors.length - 1) 110 - break 111 - case 'ArrowUp': 112 - e.preventDefault() 113 - setIndex((prev) => { 114 - const newIndex = prev < 0 ? 0 : Math.max(prev - 1, 0) 115 - return newIndex 116 - }) 117 - break 118 - case 'PageUp': 119 - e.preventDefault() 120 - setIndex(0) 121 - break 122 - case 'Escape': 123 - e.preventDefault() 124 - setActors([]) 125 - setIndex(-1) 126 - setIsOpen(false) 127 - break 128 - case 'Enter': 129 - if (index >= 0 && index < actors.length) { 130 - e.preventDefault() 131 - selectActor(actors[index].handle) 132 - } 133 - break 134 - } 135 - } 136 - 137 - const selectActor = (handle: string) => { 138 - if (inputRef.current) { 139 - inputRef.current.value = handle 140 - } 141 - setActors([]) 142 - setIndex(-1) 143 - setIsOpen(false) 144 - onSelect?.(handle) 145 - 146 - // Auto-submit the form if enabled 147 - if (autoSubmit && inputRef.current) { 148 - const form = inputRef.current.closest('form') 149 - if (form) { 150 - // Use setTimeout to ensure the value is set before submission 151 - setTimeout(() => { 152 - form.requestSubmit() 153 - }, 0) 154 - } 155 - } 156 - } 157 - 158 - const handleFocusOut = (e: React.FocusEvent) => { 159 - if (pressed) return 160 - setActors([]) 161 - setIndex(-1) 162 - setIsOpen(false) 163 - } 164 - 165 - // Clone the input element and add our event handlers 166 - const input = React.cloneElement(children, { 167 - ref: (el: HTMLInputElement) => { 168 - inputRef.current = el 169 - // Preserve the original ref if it exists 170 - const originalRef = (children as any).ref 171 - if (typeof originalRef === 'function') { 172 - originalRef(el) 173 - } else if (originalRef) { 174 - originalRef.current = el 175 - } 176 - }, 177 - onInput: (e: React.FormEvent<HTMLInputElement>) => { 178 - handleInput(e) 179 - children.props.onInput?.(e) 180 - }, 181 - onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => { 182 - handleKeyDown(e) 183 - children.props.onKeyDown?.(e) 184 - }, 185 - onBlur: (e: React.FocusEvent<HTMLInputElement>) => { 186 - handleFocusOut(e) 187 - children.props.onBlur?.(e) 188 - }, 189 - autoComplete: 'off' 190 - } as any) 191 - 192 - return ( 193 - <div ref={containerRef} style={{ position: 'relative', display: 'block' }}> 194 - {input} 195 - {isOpen && actors.length > 0 && ( 196 - <ul 197 - style={{ 198 - display: 'flex', 199 - flexDirection: 'column', 200 - position: 'absolute', 201 - left: 0, 202 - marginTop: '4px', 203 - width: '100%', 204 - listStyle: 'none', 205 - overflow: 'hidden', 206 - backgroundColor: 'rgba(255, 255, 255, 0.8)', 207 - backgroundClip: 'padding-box', 208 - backdropFilter: 'blur(12px)', 209 - WebkitBackdropFilter: 'blur(12px)', 210 - border: '1px solid rgba(0, 0, 0, 0.1)', 211 - borderRadius: '8px', 212 - boxShadow: '0 6px 6px -4px rgba(0, 0, 0, 0.2)', 213 - padding: '4px', 214 - margin: 0, 215 - zIndex: 1000 216 - }} 217 - onMouseDown={() => setPressed(true)} 218 - onMouseUp={() => { 219 - setPressed(false) 220 - inputRef.current?.focus() 221 - }} 222 - > 223 - {actors.map((actor, i) => ( 224 - <li key={actor.handle}> 225 - <button 226 - type="button" 227 - onClick={() => selectActor(actor.handle)} 228 - style={{ 229 - all: 'unset', 230 - boxSizing: 'border-box', 231 - display: 'flex', 232 - alignItems: 'center', 233 - gap: '8px', 234 - padding: '6px 8px', 235 - width: '100%', 236 - height: 'calc(1.5rem + 12px)', 237 - borderRadius: '4px', 238 - cursor: 'pointer', 239 - backgroundColor: i === index ? 'color-mix(in oklch, var(--accent) 50%, transparent)' : 'transparent', 240 - transition: 'background-color 0.1s' 241 - }} 242 - onMouseEnter={() => setIndex(i)} 243 - > 244 - <div 245 - style={{ 246 - width: '1.5rem', 247 - height: '1.5rem', 248 - borderRadius: '50%', 249 - backgroundColor: 'var(--muted)', 250 - overflow: 'hidden', 251 - flexShrink: 0 252 - }} 253 - > 254 - {actor.avatar && ( 255 - <img 256 - src={actor.avatar} 257 - alt="" 258 - loading="lazy" 259 - style={{ 260 - display: 'block', 261 - width: '100%', 262 - height: '100%', 263 - objectFit: 'cover' 264 - }} 265 - /> 266 - )} 267 - </div> 268 - <span 269 - style={{ 270 - whiteSpace: 'nowrap', 271 - overflow: 'hidden', 272 - textOverflow: 'ellipsis', 273 - color: '#000000' 274 - }} 275 - > 276 - {actor.handle} 277 - </span> 278 - </button> 279 - </li> 280 - ))} 281 - </ul> 282 - )} 283 - </div> 284 - ) 285 - } 286 - 287 - const LatestPostWithPrefetch: React.FC<{ did: string }> = ({ did }) => { 288 - const { record, rkey, loading } = useLatestRecord<FeedPostRecord>( 289 - did, 290 - 'app.bsky.feed.post' 291 - ) 292 - 293 - if (loading) return <span>Loading…</span> 294 - if (!record || !rkey) return <span>No posts yet.</span> 295 - 296 - return <BlueskyPost did={did} rkey={rkey} record={record} showParent={true} /> 297 - } 298 - 299 - function App() { 300 - const [showForm, setShowForm] = useState(false) 301 - const [checkingAuth, setCheckingAuth] = useState(true) 302 - const [screenshots, setScreenshots] = useState<string[]>([]) 303 - const inputRef = useRef<HTMLInputElement>(null) 304 - 305 - useEffect(() => { 306 - // Check authentication status on mount 307 - const checkAuth = async () => { 308 - try { 309 - const response = await fetch('/api/auth/status', { 310 - credentials: 'include' 311 - }) 312 - const data = await response.json() 313 - if (data.authenticated) { 314 - // User is already authenticated, redirect to editor 315 - window.location.href = '/editor' 316 - return 317 - } 318 - // If not authenticated, clear any stale cookies 319 - document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 320 - } catch (error) { 321 - console.error('Auth check failed:', error) 322 - // Clear cookies on error as well 323 - document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 324 - } finally { 325 - setCheckingAuth(false) 326 - } 327 - } 328 - 329 - checkAuth() 330 - }, []) 331 - 332 - useEffect(() => { 333 - // Fetch screenshots list 334 - const fetchScreenshots = async () => { 335 - try { 336 - const response = await fetch('/api/screenshots') 337 - const data = await response.json() 338 - setScreenshots(data.screenshots || []) 339 - } catch (error) { 340 - console.error('Failed to fetch screenshots:', error) 341 - } 342 - } 343 - 344 - fetchScreenshots() 345 - }, []) 346 - 347 - useEffect(() => { 348 - if (showForm) { 349 - setTimeout(() => inputRef.current?.focus(), 500) 350 - } 351 - }, [showForm]) 352 - 353 - if (checkingAuth) { 354 - return ( 355 - <div className="min-h-screen bg-background flex items-center justify-center"> 356 - <div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div> 357 - </div> 358 - ) 359 - } 360 - 361 - return ( 362 - <> 363 - <div className="w-full min-h-screen flex flex-col"> 364 - {/* Header */} 365 - <header className="w-full border-b border-border/40 bg-background/80 backdrop-blur-sm sticky top-0 z-50"> 366 - <div className="max-w-6xl w-full mx-auto px-4 h-16 flex items-center justify-between"> 367 - <div className="flex items-center gap-2"> 368 - <img src="/transparent-full-size-ico.png" alt="wisp.place" className="w-8 h-8" /> 369 - <span className="text-lg font-semibold text-foreground"> 370 - wisp.place 371 - </span> 372 - </div> 373 - <div className="flex items-center gap-4"> 374 - <a 375 - href="https://docs.wisp.place" 376 - target="_blank" 377 - rel="noopener noreferrer" 378 - className="text-sm text-muted-foreground hover:text-foreground transition-colors" 379 - > 380 - Read the Docs 381 - </a> 382 - <Button 383 - variant="outline" 384 - size="sm" 385 - className="btn-hover-lift" 386 - onClick={() => setShowForm(true)} 387 - > 388 - Sign In 389 - </Button> 390 - </div> 391 - </div> 392 - </header> 393 - 394 - {/* Hero Section */} 395 - <section className="container mx-auto px-4 py-24 md:py-36"> 396 - <div className="max-w-4xl mx-auto text-center"> 397 - {/* Main Headline */} 398 - <h1 className="animate-fade-in-up animate-delay-100 text-5xl md:text-7xl font-bold mb-2 leading-tight tracking-tight"> 399 - Deploy Anywhere. 400 - </h1> 401 - <h1 className="animate-fade-in-up animate-delay-200 text-5xl md:text-7xl font-bold mb-8 leading-tight tracking-tight text-gradient-animate"> 402 - For Free. Forever. 403 - </h1> 404 - 405 - {/* Subheadline */} 406 - <p className="animate-fade-in-up animate-delay-300 text-lg md:text-xl text-muted-foreground mb-12 leading-relaxed max-w-2xl mx-auto"> 407 - The easiest way to deploy and orchestrate static sites. 408 - Push updates instantly. Host on our infrastructure or yours. 409 - All powered by AT Protocol. 410 - </p> 411 - 412 - {/* CTA Buttons */} 413 - <div className="animate-fade-in-up animate-delay-400 max-w-lg mx-auto relative"> 414 - <div 415 - className={`transition-all duration-500 ease-in-out ${showForm 416 - ? 'opacity-0 -translate-y-5 pointer-events-none absolute inset-0' 417 - : 'opacity-100 translate-y-0' 418 - }`} 419 - > 420 - <div className="flex flex-col sm:flex-row gap-3 justify-center"> 421 - <Button 422 - size="lg" 423 - className="bg-foreground text-background hover:bg-foreground/90 text-base px-6 py-5 btn-hover-lift" 424 - onClick={() => setShowForm(true)} 425 - > 426 - <span className="mr-2 font-bold">@</span> 427 - Deploy with AT 428 - </Button> 429 - <Button 430 - variant="outline" 431 - size="lg" 432 - className="text-base px-6 py-5 btn-hover-lift" 433 - asChild 434 - > 435 - <a href="https://docs.wisp.place/cli/" target="_blank" rel="noopener noreferrer"> 436 - <span className="font-mono mr-2 text-muted-foreground">&gt;_</span> 437 - Install wisp-cli 438 - </a> 439 - </Button> 440 - </div> 441 - </div> 442 - 443 - <div 444 - className={`transition-all duration-500 ease-in-out ${showForm 445 - ? 'opacity-100 translate-y-0' 446 - : 'opacity-0 translate-y-5 pointer-events-none absolute inset-0' 447 - }`} 448 - > 449 - <form 450 - onSubmit={async (e) => { 451 - e.preventDefault() 452 - try { 453 - const handle = 454 - inputRef.current?.value 455 - const res = await fetch( 456 - '/api/auth/signin', 457 - { 458 - method: 'POST', 459 - headers: { 460 - 'Content-Type': 461 - 'application/json' 462 - }, 463 - body: JSON.stringify({ 464 - handle 465 - }) 466 - } 467 - ) 468 - if (!res.ok) 469 - throw new Error( 470 - 'Request failed' 471 - ) 472 - const data = await res.json() 473 - if (data.url) { 474 - window.location.href = data.url 475 - } else { 476 - alert('Unexpected response') 477 - } 478 - } catch (error) { 479 - console.error( 480 - 'Login failed:', 481 - error 482 - ) 483 - // Clear any invalid cookies 484 - document.cookie = 'did=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax' 485 - alert('Authentication failed') 486 - } 487 - }} 488 - className="space-y-3" 489 - > 490 - <ActorTypeahead 491 - autoSubmit={true} 492 - onSelect={(handle) => { 493 - if (inputRef.current) { 494 - inputRef.current.value = handle 495 - } 496 - }} 497 - > 498 - <input 499 - ref={inputRef} 500 - type="text" 501 - name="handle" 502 - placeholder="Enter your handle (e.g., alice.bsky.social)" 503 - className="w-full py-4 px-4 text-lg bg-input border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" 504 - /> 505 - </ActorTypeahead> 506 - <button 507 - type="submit" 508 - className="w-full bg-foreground text-background hover:bg-foreground/90 font-semibold py-4 px-6 text-lg rounded-lg inline-flex items-center justify-center transition-colors btn-hover-lift" 509 - > 510 - Continue 511 - <ArrowRight className="ml-2 w-5 h-5" /> 512 - </button> 513 - </form> 514 - </div> 515 - </div> 516 - </div> 517 - </section> 518 - 519 - {/* How It Works */} 520 - <section className="container mx-auto px-4 py-16 bg-muted/30"> 521 - <div className="max-w-3xl mx-auto text-center"> 522 - <h2 className="text-3xl md:text-4xl font-bold mb-8"> 523 - How it works 524 - </h2> 525 - <div className="space-y-6 text-left"> 526 - <div className="flex gap-4 items-start"> 527 - <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 528 - 01 529 - </div> 530 - <div> 531 - <h3 className="text-xl font-semibold mb-2"> 532 - Drop in your files 533 - </h3> 534 - <p className="text-muted-foreground"> 535 - Upload your site through our dashboard or push with the CLI. 536 - Everything gets stored directly in your AT Protocol account. 537 - </p> 538 - </div> 539 - </div> 540 - <div className="flex gap-4 items-start"> 541 - <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 542 - 02 543 - </div> 544 - <div> 545 - <h3 className="text-xl font-semibold mb-2"> 546 - We handle the rest 547 - </h3> 548 - <p className="text-muted-foreground"> 549 - Your site goes live instantly on our global CDN. 550 - Custom domains, HTTPS, caching—all automatic. 551 - </p> 552 - </div> 553 - </div> 554 - <div className="flex gap-4 items-start"> 555 - <div className="text-4xl font-bold text-accent/40 min-w-[60px]"> 556 - 03 557 - </div> 558 - <div> 559 - <h3 className="text-xl font-semibold mb-2"> 560 - Push updates instantly 561 - </h3> 562 - <p className="text-muted-foreground"> 563 - Ship changes in seconds. Update through the dashboard, 564 - run wisp-cli deploy, or wire up your CI/CD pipeline. 565 - </p> 566 - </div> 567 - </div> 568 - </div> 569 - </div> 570 - </section> 571 - 572 - {/* Site Gallery */} 573 - <section id="gallery" className="container mx-auto px-4 py-20"> 574 - <div className="text-center mb-16"> 575 - <h2 className="text-4xl md:text-5xl font-bold mb-4 text-balance"> 576 - Join 80+ sites just like yours: 577 - </h2> 578 - </div> 579 - 580 - <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-5xl mx-auto"> 581 - {screenshots.map((filename, i) => { 582 - // Remove .png extension 583 - const baseName = filename.replace('.png', '') 584 - 585 - // Construct site URL from filename 586 - let siteUrl: string 587 - if (baseName.startsWith('sites_wisp_place_did_plc_')) { 588 - // Handle format: sites_wisp_place_did_plc_{identifier}_{sitename} 589 - const match = baseName.match(/^sites_wisp_place_did_plc_([a-z0-9]+)_(.+)$/) 590 - if (match) { 591 - const [, identifier, sitename] = match 592 - siteUrl = `https://sites.wisp.place/did:plc:${identifier}/${sitename}` 593 - } else { 594 - siteUrl = '#' 595 - } 596 - } else { 597 - // Handle format: domain_tld or subdomain_domain_tld 598 - // Replace underscores with dots 599 - siteUrl = `https://${baseName.replace(/_/g, '.')}` 600 - } 601 - 602 - return ( 603 - <a 604 - key={i} 605 - href={siteUrl} 606 - target="_blank" 607 - rel="noopener noreferrer" 608 - className="block" 609 - > 610 - <Card className="overflow-hidden hover:shadow-xl transition-all hover:scale-105 border-2 bg-card p-0 cursor-pointer"> 611 - <img 612 - src={`/screenshots/${filename}`} 613 - alt={`${baseName} screenshot`} 614 - className="w-full h-auto object-cover aspect-video" 615 - loading="lazy" 616 - /> 617 - </Card> 618 - </a> 619 - ) 620 - })} 621 - </div> 622 - </section> 623 - 624 - {/* CTA Section */} 625 - <section className="container mx-auto px-4 py-20"> 626 - <div className="max-w-6xl mx-auto"> 627 - <div className="text-center mb-12"> 628 - <h2 className="text-3xl md:text-4xl font-bold"> 629 - Follow on Bluesky for updates 630 - </h2> 631 - </div> 632 - <div className="grid md:grid-cols-2 gap-8 items-center"> 633 - <Card 634 - className="shadow-lg border-2 border-border overflow-hidden !py-3" 635 - style={{ 636 - '--atproto-color-bg': 'var(--card)', 637 - '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 638 - '--atproto-color-text': 'hsl(var(--foreground))', 639 - '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 640 - '--atproto-color-link': 'hsl(var(--accent))', 641 - '--atproto-color-link-hover': 'hsl(var(--accent))', 642 - '--atproto-color-border': 'transparent', 643 - } as AtProtoStyles} 644 - > 645 - <BlueskyPostList did="wisp.place" /> 646 - </Card> 647 - <div className="space-y-6 w-full max-w-md mx-auto"> 648 - <Card 649 - className="shadow-lg border-2 overflow-hidden relative !py-3" 650 - style={{ 651 - '--atproto-color-bg': 'var(--card)', 652 - '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 653 - '--atproto-color-text': 'hsl(var(--foreground))', 654 - '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 655 - } as AtProtoStyles} 656 - > 657 - <BlueskyProfile did="wisp.place" /> 658 - </Card> 659 - <Card 660 - className="shadow-lg border-2 overflow-hidden relative !py-3" 661 - style={{ 662 - '--atproto-color-bg': 'var(--card)', 663 - '--atproto-color-bg-elevated': 'hsl(var(--muted) / 0.3)', 664 - '--atproto-color-text': 'hsl(var(--foreground))', 665 - '--atproto-color-text-secondary': 'hsl(var(--muted-foreground))', 666 - } as AtProtoStyles} 667 - > 668 - <LatestPostWithPrefetch did="wisp.place" /> 669 - </Card> 670 - </div> 671 - </div> 672 - </div> 673 - </section> 674 - 675 - {/* Ready to Deploy CTA */} 676 - <section className="container mx-auto px-4 py-20"> 677 - <div className="max-w-3xl mx-auto text-center bg-accent/5 border border-accent/20 rounded-2xl p-12"> 678 - <h2 className="text-3xl md:text-4xl font-bold mb-4"> 679 - Ready to deploy? 680 - </h2> 681 - <p className="text-xl text-muted-foreground mb-8"> 682 - Host your static site on your own AT Protocol 683 - account today 684 - </p> 685 - <Button 686 - size="lg" 687 - className="bg-accent text-accent-foreground hover:bg-accent/90 text-lg px-8 py-6" 688 - onClick={() => setShowForm(true)} 689 - > 690 - Get Started 691 - <ArrowRight className="ml-2 w-5 h-5" /> 692 - </Button> 693 - </div> 694 - </section> 695 - 696 - {/* Footer */} 697 - <footer className="border-t border-border/40 bg-muted/20 mt-auto"> 698 - <div className="container mx-auto px-4 py-8"> 699 - <div className="text-center text-sm text-muted-foreground"> 700 - <p> 701 - Built by{' '} 702 - <a 703 - href="https://bsky.app/profile/nekomimi.pet" 704 - target="_blank" 705 - rel="noopener noreferrer" 706 - className="text-accent hover:text-accent/80 transition-colors font-medium" 707 - > 708 - @nekomimi.pet 709 - </a> 710 - {' • '} 711 - Contact:{' '} 712 - <a 713 - href="mailto:contact@wisp.place" 714 - className="text-accent hover:text-accent/80 transition-colors font-medium" 715 - > 716 - contact@wisp.place 717 - </a> 718 - {' • '} 719 - Legal/DMCA:{' '} 720 - <a 721 - href="mailto:legal@wisp.place" 722 - className="text-accent hover:text-accent/80 transition-colors font-medium" 723 - > 724 - legal@wisp.place 725 - </a> 726 - </p> 727 - <p className="mt-2"> 728 - <a 729 - href="/acceptable-use" 730 - className="text-accent hover:text-accent/80 transition-colors font-medium" 731 - > 732 - Acceptable Use Policy 733 - </a> 734 - {' • '} 735 - <a 736 - href="https://docs.wisp.place" 737 - target="_blank" 738 - rel="noopener noreferrer" 739 - className="text-accent hover:text-accent/80 transition-colors font-medium" 740 - > 741 - Documentation 742 - </a> 743 - </p> 744 - </div> 745 - </div> 746 - </footer> 747 - </div> 748 - </> 749 - ) 750 - } 751 - 752 - const root = createRoot(document.getElementById('elysia')!) 753 - root.render( 754 - <AtProtoProvider> 755 - <Layout className="gap-6"> 756 - <App /> 757 - </Layout> 758 - </AtProtoProvider> 759 - )
+692 -165
apps/main-app/public/landingpage.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>wisp.place</title> 7 - <meta name="description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution. Built on Bluesky's decentralized network." /> 6 + <title>wisp.place - Static Hosting on AT Protocol</title> 7 + <meta name="description" content="Deploy static sites on AT Protocol. Your site lives in your PDS. Global CDN included. Push updates instantly. Forever free." /> 8 8 9 9 <!-- Open Graph / Facebook --> 10 10 <meta property="og:type" content="website" /> 11 11 <meta property="og:url" content="https://wisp.place/" /> 12 - <meta property="og:title" content="wisp.place - Decentralized Static Site Hosting" /> 13 - <meta property="og:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." /> 12 + <meta property="og:title" content="wisp.place - Static Hosting on AT Protocol" /> 13 + <meta property="og:description" content="Deploy static sites on AT Protocol. Your site lives in your PDS. Global CDN included. Push updates instantly. Forever free." /> 14 14 <meta property="og:site_name" content="wisp.place" /> 15 15 16 16 <!-- Twitter --> 17 17 <meta name="twitter:card" content="summary_large_image" /> 18 18 <meta name="twitter:url" content="https://wisp.place/" /> 19 - <meta name="twitter:title" content="wisp.place - Decentralized Static Site Hosting" /> 20 - <meta name="twitter:description" content="Host static websites directly in your AT Protocol account. Keep full ownership and control with fast CDN distribution." /> 19 + <meta name="twitter:title" content="wisp.place - Static Hosting on AT Protocol" /> 20 + <meta name="twitter:description" content="Deploy static sites on AT Protocol. Your site lives in your PDS. Global CDN included. Push updates instantly. Forever free." /> 21 21 22 22 <!-- Theme --> 23 23 <meta name="theme-color" content="#000000" /> ··· 30 30 31 31 <link rel="preconnect" href="https://fonts.googleapis.com"> 32 32 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 33 - <link href="https://fonts.googleapis.com/css2?family=Fira+Mono:wght@400;500;700&display=swap" rel="stylesheet"> 33 + <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> 34 34 35 35 <style> 36 36 * { ··· 40 40 } 41 41 42 42 :root { 43 - --bg: #fafafa; 44 - --text: #000; 45 - --text-muted: #666; 46 - --text-subtle: #999; 47 - --border: #ddd; 48 - --cta-bg: #000; 49 - --cta-text: #fff; 50 - --cta-hover-bg: #fff; 51 - --cta-hover-text: #000; 52 - --code-bg: #000; 53 - --code-text: #0f0; 54 - --link: #000; 43 + --bg: oklch(0.92 0.012 35); 44 + --bg-alt: oklch(0.88 0.01 35); 45 + --text: oklch(0.15 0.015 30); 46 + --text-muted: oklch(0.35 0.02 30); 47 + --text-subtle: oklch(0.50 0.02 30); 48 + --border: oklch(0.30 0.025 35); 49 + --border-light: oklch(0.65 0.02 30); 50 + --accent: oklch(0.65 0.18 345); 51 + --accent-muted: oklch(0.75 0.14 345); 52 + --cta-bg: oklch(0.30 0.025 35); 53 + --cta-text: oklch(0.96 0.008 35); 54 + --code-bg: oklch(0.95 0.008 35); 55 + --success: oklch(0.65 0.20 145); 55 56 } 56 57 57 58 @media (prefers-color-scheme: dark) { 58 59 :root { 59 - --bg: #0a0a0a; 60 - --text: #fafafa; 61 - --text-muted: #999; 62 - --text-subtle: #666; 63 - --border: #333; 64 - --cta-bg: #fff; 65 - --cta-text: #000; 66 - --cta-hover-bg: #0a0a0a; 67 - --cta-hover-text: #fff; 68 - --code-bg: #111; 69 - --code-text: #0f0; 70 - --link: #fff; 60 + --bg: oklch(0.23 0.015 285); 61 + --bg-alt: oklch(0.20 0.015 285); 62 + --text: oklch(0.90 0.005 285); 63 + --text-muted: oklch(0.72 0.01 285); 64 + --text-subtle: oklch(0.55 0.01 285); 65 + --border: oklch(0.90 0.005 285); 66 + --border-light: oklch(0.38 0.02 285); 67 + --accent: oklch(0.85 0.08 5); 68 + --accent-muted: oklch(0.70 0.10 295); 69 + --cta-bg: oklch(0.70 0.10 295); 70 + --cta-text: oklch(0.23 0.015 285); 71 + --code-bg: oklch(0.28 0.015 285); 71 72 } 72 73 } 73 74 75 + html { 76 + scroll-behavior: smooth; 77 + } 78 + 74 79 body { 75 - font-family: "Fira Mono", monospace; 76 - font-weight: 400; 80 + font-family: "JetBrains Mono", monospace; 77 81 background: var(--bg); 78 82 color: var(--text); 79 83 min-height: 100vh; 80 84 display: flex; 81 85 flex-direction: column; 82 - padding-top: 6rem; 86 + line-height: 1.6; 83 87 } 84 88 85 - .container { 86 - max-width: 800px; 89 + /* Header */ 90 + header { 91 + position: sticky; 92 + top: 0; 93 + background: var(--bg); 94 + border-bottom: 1px solid var(--border-light); 95 + padding: 1rem 2rem; 96 + z-index: 100; 97 + } 98 + 99 + .header-inner { 100 + max-width: 1100px; 87 101 margin: 0 auto; 88 - padding: 0 2rem; 89 - width: 100%; 102 + display: flex; 103 + justify-content: space-between; 104 + align-items: center; 105 + } 106 + 107 + .logo { 108 + font-weight: 700; 109 + font-size: 1.125rem; 110 + text-decoration: none; 111 + color: var(--text); 112 + letter-spacing: -0.02em; 90 113 } 91 114 92 - main { 93 - flex: 1; 115 + nav { 94 116 display: flex; 117 + gap: 2rem; 95 118 align-items: center; 96 - justify-content: center; 119 + } 120 + 121 + nav a { 122 + color: var(--text-muted); 123 + text-decoration: none; 124 + font-size: 1rem; 125 + transition: color 0.15s; 126 + } 127 + 128 + nav a:hover { 129 + color: var(--text); 130 + } 131 + 132 + .nav-cta { 133 + background: var(--cta-bg); 134 + color: var(--cta-text); 135 + padding: 0.5rem 1rem; 136 + border: 1px solid var(--border); 137 + font-weight: 600; 138 + font-size: 0.875rem; 139 + text-decoration: none; 140 + text-transform: uppercase; 141 + letter-spacing: 0.05em; 142 + transition: all 0.15s; 143 + } 144 + 145 + .nav-cta:hover { 146 + background: var(--bg); 147 + color: var(--text); 97 148 } 98 149 150 + /* Hero */ 99 151 .hero { 152 + padding: 8rem 2rem 6rem; 100 153 text-align: center; 101 - padding: 4rem 0; 154 + } 155 + 156 + .hero-inner { 157 + max-width: 700px; 158 + margin: 0 auto; 159 + } 160 + 161 + .hero-badge { 162 + display: inline-block; 163 + color: var(--accent); 164 + font-size: 0.875rem; 165 + font-weight: 600; 166 + text-transform: uppercase; 167 + letter-spacing: 0.1em; 168 + margin-bottom: 2rem; 169 + padding: 0.5rem 1rem; 170 + border: 1px solid var(--accent); 102 171 } 103 172 104 173 h1 { 105 - font-size: 5rem; 174 + font-size: clamp(2.75rem, 7vw, 4.25rem); 106 175 font-weight: 700; 107 - margin-bottom: 4rem; 108 - letter-spacing: -0.02em; 109 - color: #4a4a4a; 110 - text-shadow: 111 - 1px 1px 0 #fff, 112 - -1px -1px 0 #2a2a2a, 113 - 2px 2px 3px rgba(0, 0, 0, 0.3); 176 + line-height: 1.1; 177 + margin-bottom: 1.5rem; 178 + letter-spacing: -0.03em; 179 + text-align: center; 114 180 } 115 181 116 - @media (prefers-color-scheme: dark) { 117 - h1 { 118 - color: #888; 119 - text-shadow: 120 - 1px 1px 0 #222, 121 - -1px -1px 0 #000, 122 - 2px 2px 3px rgba(0, 0, 0, 0.5); 123 - } 182 + .hero-desc { 183 + font-size: 1.25rem; 184 + color: var(--text-muted); 185 + max-width: 500px; 186 + margin: 0 auto 3rem; 187 + line-height: 1.7; 124 188 } 125 189 126 - h1::after { 127 - content: '_'; 128 - animation: blink 1s infinite; 190 + .cta-group { 191 + display: flex; 192 + flex-direction: column; 193 + align-items: center; 194 + gap: 1.5rem; 129 195 } 130 196 131 - @keyframes blink { 132 - 0%, 50% { opacity: 1; } 133 - 51%, 100% { opacity: 0; } 197 + .cta-buttons { 198 + display: flex; 199 + gap: 1rem; 200 + flex-wrap: wrap; 201 + justify-content: center; 134 202 } 135 203 136 - .cta { 137 - display: inline-block; 204 + .cta-primary { 138 205 background: var(--cta-bg); 139 206 color: var(--cta-text); 140 - padding: 2rem 4rem; 141 - font-size: 1.5rem; 207 + padding: 1rem 2rem; 208 + border: 2px solid var(--border); 209 + font-weight: 600; 210 + font-size: 1rem; 211 + text-decoration: none; 212 + text-transform: uppercase; 213 + letter-spacing: 0.05em; 214 + transition: all 0.15s; 215 + } 216 + 217 + .cta-primary:hover { 218 + background: var(--bg); 219 + color: var(--text); 220 + } 221 + 222 + .cta-secondary { 223 + background: transparent; 224 + color: var(--text); 225 + padding: 1rem 2rem; 226 + border: 2px solid var(--border); 227 + font-weight: 600; 228 + font-size: 1rem; 142 229 text-decoration: none; 143 - border: 3px solid var(--cta-bg); 144 - transition: all 0.1s; 230 + text-transform: uppercase; 231 + letter-spacing: 0.05em; 232 + transition: all 0.15s; 233 + } 234 + 235 + .cta-secondary:hover { 236 + background: var(--cta-bg); 237 + color: var(--cta-text); 238 + } 239 + 240 + .hero-cmd { 241 + color: var(--text-subtle); 242 + font-size: 1rem; 243 + } 244 + 245 + .hero-cmd code { 246 + color: var(--text); 247 + background: var(--code-bg); 248 + padding: 0.5rem 0.75rem; 249 + border: 1px solid var(--border-light); 250 + border-radius: 4px; 251 + } 252 + 253 + /* How it works */ 254 + .how-it-works { 255 + padding: 6rem 2rem; 256 + background: var(--bg-alt); 257 + } 258 + 259 + .how-it-works-inner { 260 + max-width: 900px; 261 + margin: 0 auto; 262 + } 263 + 264 + .section-label { 265 + color: var(--accent); 266 + font-size: 0.875rem; 267 + font-weight: 600; 268 + text-transform: uppercase; 269 + letter-spacing: 0.1em; 270 + margin-bottom: 1rem; 271 + } 272 + 273 + .section-title { 274 + font-size: 2rem; 145 275 font-weight: 700; 146 - margin-bottom: 3rem; 276 + margin-bottom: 4rem; 277 + letter-spacing: -0.02em; 147 278 } 148 279 149 - .cta:hover { 150 - background: var(--cta-hover-bg); 151 - color: var(--cta-hover-text); 152 - border-color: var(--cta-bg); 280 + .steps { 281 + display: grid; 282 + grid-template-columns: repeat(3, 1fr); 283 + gap: 3rem; 284 + } 285 + 286 + .step { 287 + position: relative; 153 288 } 154 289 155 - .tagline { 156 - font-size: 1.2rem; 157 - color: var(--text-muted); 158 - margin-bottom: 6rem; 290 + .step-number { 291 + font-size: 3.25rem; 292 + font-weight: 700; 293 + color: var(--border-light); 294 + margin-bottom: 1rem; 295 + line-height: 1; 159 296 } 160 297 161 - .secondary { 162 - border-top: 1px solid var(--border); 163 - padding-top: 3rem; 164 - margin-top: 4rem; 298 + .step h3 { 299 + font-size: 1.125rem; 300 + font-weight: 600; 301 + margin-bottom: 0.75rem; 165 302 } 166 303 167 - .secondary h2 { 304 + .step p { 168 305 font-size: 1rem; 169 - margin-bottom: 1.5rem; 170 - font-weight: 700; 171 - text-transform: lowercase; 306 + color: var(--text-muted); 307 + line-height: 1.7; 172 308 } 173 309 174 - .code-block { 310 + .step code { 311 + display: block; 312 + margin-top: 1rem; 313 + font-size: 0.875rem; 314 + color: var(--accent); 175 315 background: var(--code-bg); 176 - color: var(--code-text); 316 + padding: 0.75rem; 317 + border: 1px solid var(--border-light); 318 + text-align: center; 319 + } 320 + 321 + /* Terminal Demo */ 322 + .demo { 323 + padding: 6rem 2rem; 324 + } 325 + 326 + .demo-inner { 327 + max-width: 700px; 328 + margin: 0 auto; 329 + } 330 + 331 + .terminal { 332 + border: 1px solid var(--border); 333 + background: #0d1117; 334 + border-radius: 8px; 335 + overflow: hidden; 336 + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); 337 + } 338 + 339 + .terminal-header { 340 + background: #161b22; 341 + padding: 0.75rem 1rem; 342 + display: flex; 343 + align-items: center; 344 + gap: 0.5rem; 345 + } 346 + 347 + .terminal-dots { 348 + display: flex; 349 + gap: 6px; 350 + } 351 + 352 + .terminal-dot { 353 + width: 12px; 354 + height: 12px; 355 + border-radius: 50%; 356 + } 357 + 358 + .terminal-dot.red { background: #ff5f56; } 359 + .terminal-dot.yellow { background: #ffbd2e; } 360 + .terminal-dot.green { background: #27ca40; } 361 + 362 + .terminal-title { 363 + color: #666; 364 + font-size: 0.875rem; 365 + margin-left: auto; 366 + } 367 + 368 + .terminal-body { 177 369 padding: 1.5rem; 178 - margin: 1rem 0; 179 - font-size: 0.9rem; 180 - overflow-x: auto; 370 + font-size: 1rem; 371 + line-height: 1.8; 372 + color: #c9d1d9; 373 + } 374 + 375 + .terminal-line { 376 + margin-bottom: 0.25rem; 377 + } 378 + 379 + .t-prompt { color: #7c7cff; } 380 + .t-cmd { color: #c9d1d9; } 381 + .t-muted { color: #666; } 382 + .t-success { color: #27ca40; } 383 + .t-cyan { color: #58a6ff; } 384 + .t-output { color: #8b949e; padding-left: 1rem; display: block; } 385 + 386 + .terminal-spacer { 387 + height: 1rem; 388 + } 389 + 390 + /* Features */ 391 + .features { 392 + padding: 6rem 2rem; 393 + background: var(--bg-alt); 394 + } 395 + 396 + .features-inner { 397 + max-width: 900px; 398 + margin: 0 auto; 399 + } 400 + 401 + .features-grid { 402 + display: grid; 403 + grid-template-columns: repeat(2, 1fr); 404 + gap: 1px; 405 + background: var(--border-light); 406 + border: 1px solid var(--border-light); 407 + margin-top: 3rem; 181 408 } 182 409 183 - .code-block code { 184 - font-family: "Fira Mono", monospace; 410 + .feature { 411 + background: var(--bg); 412 + padding: 2rem; 185 413 } 186 414 187 - .secondary p { 415 + .feature-icon { 416 + font-size: 1.5rem; 417 + margin-bottom: 1rem; 418 + } 419 + 420 + .feature h3 { 421 + font-size: 1.125rem; 422 + font-weight: 600; 423 + margin-bottom: 0.75rem; 424 + } 425 + 426 + .feature p { 427 + font-size: 1rem; 188 428 color: var(--text-muted); 429 + line-height: 1.7; 430 + } 431 + 432 + /* CLI Section */ 433 + .cli-section { 434 + padding: 6rem 2rem; 435 + } 436 + 437 + .cli-inner { 438 + max-width: 700px; 439 + margin: 0 auto; 440 + text-align: center; 441 + } 442 + 443 + .cli-section .section-title { 189 444 margin-bottom: 1rem; 190 - font-size: 0.95rem; 445 + } 446 + 447 + .cli-desc { 448 + color: var(--text-muted); 449 + margin-bottom: 3rem; 450 + font-size: 1.125rem; 451 + } 452 + 453 + .cli-features { 454 + display: grid; 455 + grid-template-columns: repeat(2, 1fr); 456 + gap: 1.5rem; 457 + text-align: left; 458 + margin-bottom: 3rem; 459 + } 460 + 461 + .cli-feature { 462 + padding: 1.5rem; 463 + border: 1px solid var(--border-light); 464 + background: var(--bg); 465 + } 466 + 467 + .cli-feature h4 { 468 + font-size: 1rem; 469 + font-weight: 600; 470 + margin-bottom: 0.5rem; 471 + } 472 + 473 + .cli-feature p { 474 + font-size: 0.9375rem; 475 + color: var(--text-muted); 476 + line-height: 1.6; 477 + } 478 + 479 + /* Final CTA */ 480 + .final-cta { 481 + padding: 8rem 2rem; 482 + text-align: center; 483 + background: var(--bg-alt); 191 484 } 192 485 193 - .secondary a { 194 - color: var(--link); 195 - text-decoration: none; 196 - border-bottom: 1px solid var(--link); 486 + .final-cta-inner { 487 + max-width: 600px; 488 + margin: 0 auto; 489 + } 490 + 491 + .final-cta h2 { 492 + font-size: 2.25rem; 493 + font-weight: 700; 494 + margin-bottom: 1rem; 495 + letter-spacing: -0.02em; 197 496 } 198 497 199 - .secondary a:hover { 200 - border-bottom: 2px solid var(--link); 498 + .final-cta p { 499 + color: var(--text-muted); 500 + margin-bottom: 2rem; 201 501 } 202 502 503 + /* Footer */ 203 504 footer { 204 - border-top: 1px solid var(--border); 205 - padding: 3rem 0; 206 - text-align: center; 207 - margin-top: 6rem; 505 + padding: 4rem 2rem 2rem; 506 + border-top: 1px solid var(--border-light); 507 + margin-top: auto; 508 + } 509 + 510 + .footer-inner { 511 + max-width: 1100px; 512 + margin: 0 auto; 513 + display: grid; 514 + grid-template-columns: 2fr repeat(3, 1fr); 515 + gap: 3rem; 208 516 } 209 517 210 - .quote { 211 - font-size: 0.85rem; 518 + .footer-brand p:first-child { 519 + font-weight: 700; 520 + margin-bottom: 0.75rem; 521 + } 522 + 523 + .footer-brand p:last-child { 524 + color: var(--text-muted); 525 + font-size: 1rem; 526 + line-height: 1.6; 527 + } 528 + 529 + .footer-col h4 { 530 + font-size: 0.875rem; 531 + font-weight: 600; 212 532 color: var(--text-subtle); 213 - font-style: italic; 533 + text-transform: uppercase; 534 + letter-spacing: 0.05em; 535 + margin-bottom: 1rem; 214 536 } 215 537 216 - .links { 217 - margin-top: 2rem; 218 - font-size: 0.85rem; 538 + .footer-col ul { 539 + list-style: none; 219 540 } 220 541 221 - .links a { 542 + .footer-col li { 543 + margin-bottom: 0.5rem; 544 + } 545 + 546 + .footer-col a { 222 547 color: var(--text-muted); 223 548 text-decoration: none; 224 - margin: 0 1rem; 549 + font-size: 1rem; 550 + transition: color 0.15s; 225 551 } 226 552 227 - .links a:hover { 553 + .footer-col a:hover { 228 554 color: var(--text); 229 555 } 230 556 557 + .footer-bottom { 558 + max-width: 1100px; 559 + margin: 3rem auto 0; 560 + padding-top: 2rem; 561 + border-top: 1px solid var(--border-light); 562 + display: flex; 563 + justify-content: space-between; 564 + align-items: center; 565 + } 566 + 567 + .footer-bottom p { 568 + color: var(--text-subtle); 569 + font-size: 0.875rem; 570 + } 571 + 572 + .footer-bottom nav { 573 + gap: 1.5rem; 574 + } 575 + 576 + .footer-bottom a { 577 + color: var(--text-subtle); 578 + text-decoration: none; 579 + font-size: 0.875rem; 580 + transition: color 0.15s; 581 + } 582 + 583 + .footer-bottom a:hover { 584 + color: var(--text); 585 + } 586 + 587 + /* Responsive */ 231 588 @media (max-width: 768px) { 232 - h1 { 233 - font-size: 3rem; 589 + .hero { 590 + padding: 5rem 1.5rem 4rem; 591 + } 592 + 593 + nav a:not(.nav-cta) { 594 + display: none; 595 + } 596 + 597 + .steps { 598 + grid-template-columns: 1fr; 599 + gap: 2rem; 600 + } 601 + 602 + .features-grid { 603 + grid-template-columns: 1fr; 604 + } 605 + 606 + .cli-features { 607 + grid-template-columns: 1fr; 608 + } 609 + 610 + .footer-inner { 611 + grid-template-columns: 1fr 1fr; 612 + } 613 + 614 + .footer-brand { 615 + grid-column: span 2; 616 + } 617 + 618 + .footer-bottom { 619 + flex-direction: column; 620 + gap: 1rem; 621 + text-align: center; 234 622 } 235 623 236 - .cta { 237 - padding: 1.5rem 3rem; 238 - font-size: 1.2rem; 624 + .cta-buttons { 625 + flex-direction: column; 626 + width: 100%; 239 627 } 240 628 241 - .tagline { 242 - font-size: 1rem; 629 + .cta-primary, .cta-secondary { 630 + width: 100%; 631 + text-align: center; 632 + } 633 + } 634 + 635 + @media (max-width: 480px) { 636 + .footer-inner { 637 + grid-template-columns: 1fr; 638 + } 639 + 640 + .footer-brand { 641 + grid-column: span 1; 243 642 } 244 643 } 245 644 </style> 246 645 </head> 247 646 <body> 248 - <main> 249 - <div class="container"> 250 - <div class="hero"> 251 - <h1>wisp.place</h1> 647 + <header> 648 + <div class="header-inner"> 649 + <a href="/" class="logo">wisp.place</a> 650 + <nav> 651 + <a href="https://docs.wisp.place" target="_blank">Docs</a> 652 + <a href="https://status.wisp.place" target="_blank">Status</a> 653 + <a href="{{ATPROTO_LOGIN_URL}}" class="nav-cta">Sign In</a> 654 + </nav> 655 + </div> 656 + </header> 252 657 253 - <a href="{{ATPROTO_LOGIN_URL}}" class="cta">SIGN IN WITH AT PROTOCOL</a> 658 + <section class="hero"> 659 + <div class="hero-inner"> 660 + <span class="hero-badge">Deploy static sites in seconds</span> 661 + <h1>Ship fast without complexity</h1> 662 + <p class="hero-desc"> 663 + Static hosting for developers who value simplicity. Your data, your PDS, your rules. 664 + </p> 665 + <div class="cta-group"> 666 + <div class="cta-buttons"> 667 + <a href="{{ATPROTO_LOGIN_URL}}" class="cta-primary">Start Deploying</a> 668 + <a href="https://docs.wisp.place" target="_blank" class="cta-secondary">Read Docs</a> 669 + </div> 670 + <p class="hero-cmd">or <code>$ npx create-wisp</code></p> 671 + </div> 672 + </div> 673 + </section> 254 674 255 - <p class="tagline">Deploy static sites like it's localhost.</p> 675 + <section class="how-it-works"> 676 + <div class="how-it-works-inner"> 677 + <p class="section-label">How it works</p> 678 + <h2 class="section-title">Three steps. No config files.</h2> 679 + <div class="steps"> 680 + <div class="step"> 681 + <div class="step-number">01</div> 682 + <h3>Sign in with Bluesky</h3> 683 + <p>Authenticate with your AT Protocol identity. No new accounts needed.</p> 684 + </div> 685 + <div class="step"> 686 + <div class="step-number">02</div> 687 + <h3>Upload your files</h3> 688 + <p>Drag and drop or use the CLI. Your site data goes directly to your PDS.</p> 689 + <code>npx wispctl deploy</code> 690 + </div> 691 + <div class="step"> 692 + <div class="step-number">03</div> 693 + <h3>You're live</h3> 694 + <p>Instantly available on our global CDN. Add a custom domain anytime.</p> 695 + </div> 696 + </div> 697 + </div> 698 + </section> 256 699 257 - <div class="secondary"> 258 - <h2>are you a nerd? From your terminal to your server, nothing in between.</h2> 259 - <div id="cli-download" class="code-block"> 260 - <code>curl https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries/wisp-cli-x86_64-linux -o wisp-cli</code> 700 + <section class="demo"> 701 + <div class="demo-inner"> 702 + <div class="terminal"> 703 + <div class="terminal-header"> 704 + <div class="terminal-dots"> 705 + <span class="terminal-dot red"></span> 706 + <span class="terminal-dot yellow"></span> 707 + <span class="terminal-dot green"></span> 261 708 </div> 262 - <div class="code-block"> 263 - <code>wisp-cli deploy alice.bsky.social --site MyBlog</code> 264 - </div> 265 - <p>host on our infrastructure for free<br> 266 - or use wisp-cli to host on your own infra with seamless deployments</p> 267 - <p>need docs? <a href="https://docs.wisp.place">docs.wisp.place</a></p> 709 + <span class="terminal-title">terminal</span> 710 + </div> 711 + <div class="terminal-body"> 712 + <div class="terminal-line"><span class="t-prompt">$</span> <span class="t-cmd">npx wispctl deploy</span></div> 713 + <div class="terminal-spacer"></div> 714 + <div class="terminal-line"><span class="t-muted">◇</span> Authenticated as <span class="t-cyan">@nekomimi.pet</span></div> 715 + <div class="terminal-line"><span class="t-muted">◇</span> Found 12 files (24 KB)</div> 716 + <div class="terminal-line"><span class="t-muted">◇</span> Uploading to PDS...</div> 717 + <div class="terminal-spacer"></div> 718 + <div class="terminal-line"><span class="t-success">✓</span> <span class="t-success">Deployed successfully</span></div> 719 + <div class="terminal-line t-output">https://sites.wisp.place/nekomimi.pet/mysite</div> 268 720 </div> 269 721 </div> 270 722 </div> 271 - </main> 723 + </section> 272 724 273 - <footer> 274 - <div class="container"> 275 - <p class="quote">"The easiest way to get static HTML going."</p> 276 - <div class="links"> 277 - <a href="https://docs.wisp.place">docs</a> 725 + <section class="features"> 726 + <div class="features-inner"> 727 + <p class="section-label">Features</p> 728 + <h2 class="section-title">Everything you need. Nothing you don't.</h2> 729 + <div class="features-grid"> 730 + <div class="feature"> 731 + <div class="feature-icon">⚡</div> 732 + <h3>Global CDN</h3> 733 + <p>Sites cached at edge locations worldwide. Sub-100ms latency everywhere.</p> 734 + </div> 735 + <div class="feature"> 736 + <div class="feature-icon">🔒</div> 737 + <h3>Custom Domains</h3> 738 + <p>Point your domain with automatic SSL. Just add a CNAME record.</p> 739 + </div> 740 + <div class="feature"> 741 + <div class="feature-icon">📦</div> 742 + <h3>Full Ownership</h3> 743 + <p>Your site data lives in your PDS. Export or migrate anytime.</p> 744 + </div> 745 + <div class="feature"> 746 + <div class="feature-icon">🚀</div> 747 + <h3>Instant Updates</h3> 748 + <p>Push and it's live. No build queues, no waiting around.</p> 749 + </div> 278 750 </div> 279 751 </div> 280 - </footer> 752 + </section> 281 753 282 - <script> 283 - const CLI_BASE = 'https://sites.wisp.place/nekomimi.pet/wisp-cli-binaries'; 754 + <section class="cli-section"> 755 + <div class="cli-inner"> 756 + <p class="section-label">For power users</p> 757 + <h2 class="section-title">Command line first</h2> 758 + <p class="cli-desc">Full control from your terminal. Integrates with your existing workflow.</p> 284 759 285 - function detectOS() { 286 - const ua = navigator.userAgent.toLowerCase(); 287 - const platform = navigator.platform.toLowerCase(); 288 - if (platform.includes('mac') || ua.includes('mac')) return 'macos'; 289 - return 'linux'; 290 - } 760 + <div class="cli-features"> 761 + <div class="cli-feature"> 762 + <h4>Deploy from anywhere</h4> 763 + <p>Git hooks, CI/CD pipelines, makefiles. Drop in one command.</p> 764 + </div> 765 + <div class="cli-feature"> 766 + <h4>Self-host option</h4> 767 + <p>Run your own server. Watches the firehose, pulls updates automatically.</p> 768 + </div> 769 + <div class="cli-feature"> 770 + <h4>Zero config</h4> 771 + <p>No YAML, no JSON. Sensible defaults that just work.</p> 772 + </div> 773 + <div class="cli-feature"> 774 + <h4>Local preview</h4> 775 + <p>Serve any wisp site locally for development and testing.</p> 776 + </div> 777 + </div> 291 778 292 - function updateCurlCommand() { 293 - const container = document.getElementById('cli-download'); 294 - const os = detectOS(); 779 + <div class="cta-buttons"> 780 + <a href="https://docs.wisp.place/cli/" target="_blank" class="cta-secondary">Install CLI</a> 781 + </div> 782 + </div> 783 + </section> 295 784 296 - if (os === 'macos') { 297 - container.innerHTML = `<code>curl ${CLI_BASE}/wisp-cli-darwin-universal -o wisp-cli</code>`; 298 - } else { 299 - container.innerHTML = `<code>curl ${CLI_BASE}/wisp-cli-x86_64-linux -o wisp-cli</code> 300 - <code># ARM: curl ${CLI_BASE}/wisp-cli-aarch64-linux -o wisp-cli</code>`; 301 - } 302 - } 785 + <section class="final-cta"> 786 + <div class="final-cta-inner"> 787 + <h2>Ready to deploy?</h2> 788 + <p>Free forever. No credit card required.</p> 789 + <div class="cta-buttons"> 790 + <a href="{{ATPROTO_LOGIN_URL}}" class="cta-primary">Start Deploying</a> 791 + </div> 792 + </div> 793 + </section> 303 794 304 - updateCurlCommand(); 305 - </script> 795 + <footer> 796 + <div class="footer-inner"> 797 + <div class="footer-brand"> 798 + <p>wisp.place</p> 799 + <p>Static hosting on AT Protocol.<br>Your data, your PDS, your rules.</p> 800 + </div> 801 + <div class="footer-col"> 802 + <h4>Product</h4> 803 + <ul> 804 + <li><a href="/editor">Dashboard</a></li> 805 + <li><a href="https://docs.wisp.place" target="_blank">Documentation</a></li> 806 + <li><a href="https://status.wisp.place" target="_blank">Status</a></li> 807 + </ul> 808 + </div> 809 + <div class="footer-col"> 810 + <h4>Resources</h4> 811 + <ul> 812 + <li><a href="https://docs.wisp.place/cli/" target="_blank">CLI Guide</a></li> 813 + <li><a href="https://docs.wisp.place/api/" target="_blank">API Reference</a></li> 814 + <li><a href="https://github.com/modder4869/wisp.place" target="_blank">GitHub</a></li> 815 + </ul> 816 + </div> 817 + <div class="footer-col"> 818 + <h4>Contact</h4> 819 + <ul> 820 + <li><a href="https://bsky.app/profile/nekomimi.pet" target="_blank">@nekomimi.pet</a></li> 821 + <li><a href="mailto:contact@wisp.place">contact@wisp.place</a></li> 822 + </ul> 823 + </div> 824 + </div> 825 + <div class="footer-bottom"> 826 + <p>Built by @nekomimi.pet</p> 827 + <nav> 828 + <a href="/acceptable-use">Acceptable Use</a> 829 + <a href="https://bsky.app/profile/wisp.place" target="_blank">Bluesky</a> 830 + </nav> 831 + </div> 832 + </footer> 306 833 </body> 307 834 </html>
+1 -1
apps/main-app/src/index.ts
··· 132 132 set.headers['Content-Type'] = 'text/html; charset=utf-8' 133 133 134 134 const html = await Bun.file('./apps/main-app/public/landingpage.html').text() 135 - return html.replace('{{ATPROTO_LOGIN_URL}}', atprotoLoginUrl) 135 + return html.replaceAll('{{ATPROTO_LOGIN_URL}}', atprotoLoginUrl) 136 136 }) 137 137 .use(authRoutes(client, cookieSecret)) 138 138 .use(wispRoutes(client, cookieSecret))
+1
cli/.gitignore
··· 1 1 test-site 2 + .wisp-serve 2 3 # dependencies (bun install) 3 4 node_modules 4 5
+5 -5
cli/commands/serve.ts
··· 9 9 import { lookup } from 'mime-types'; 10 10 import { pull } from './pull.ts'; 11 11 import { createSpinner, pc } from '../lib/progress.ts'; 12 - import { parseRedirectsFile, matchRedirectRule, parseQueryString, type RedirectRule } from '../lib/redirects.ts'; 12 + import { parseRedirectsFile, matchRedirectRule, parseQueryString, type RedirectRule } from '@wisp/fs-utils'; 13 13 import { isBun } from '../lib/runtime.ts'; 14 14 import { BunFirehose } from '../lib/firehose.ts'; 15 15 ··· 373 373 let firehoseHandle: { destroy: () => void }; 374 374 375 375 if (isBun) { 376 - // Use BunFirehose for Bun (native WebSocket) 376 + // Use BunFirehose for Bun 377 377 const bunFirehose = new BunFirehose({ 378 378 idResolver, 379 - service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), 379 + service: pdsEndpoint, 380 380 filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 381 381 handleEvent: firehoseHandleEvent, 382 382 onError: firehoseOnError, ··· 384 384 bunFirehose.start(); 385 385 firehoseHandle = { destroy: () => bunFirehose.destroy() }; 386 386 } else { 387 - // Use @atproto/sync Firehose for Node.js (uses ws library) 387 + // Use @atproto/sync Firehose for Node.js 388 388 const nodeFirehose = new Firehose({ 389 389 idResolver, 390 - service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), 390 + service: pdsEndpoint, 391 391 filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 392 392 handleEvent: firehoseHandleEvent, 393 393 onError: firehoseOnError,
+172 -44
cli/lib/redirects.ts packages/@wisp/fs-utils/src/redirects.ts
··· 1 - /** 2 - * _redirects file parsing - adapted from hosting-service 3 - */ 4 - 5 1 export interface RedirectRule { 6 2 from: string; 7 3 to: string; ··· 26 22 27 23 const MAX_REDIRECT_RULES = 1000; 28 24 25 + /** 26 + * Parse a _redirects file into an array of redirect rules 27 + */ 29 28 export function parseRedirectsFile(content: string): RedirectRule[] { 30 29 const lines = content.split('\n'); 31 30 const rules: RedirectRule[] = []; ··· 35 34 if (!lineRaw) continue; 36 35 37 36 const line = lineRaw.trim(); 38 - if (!line || line.startsWith('#')) continue; 39 - if (rules.length >= MAX_REDIRECT_RULES) break; 37 + 38 + if (!line || line.startsWith('#')) { 39 + continue; 40 + } 41 + 42 + if (rules.length >= MAX_REDIRECT_RULES) { 43 + break; 44 + } 40 45 41 46 try { 42 47 const rule = parseRedirectLine(line); 43 - if (rule?.fromPattern) { 48 + if (rule && rule.fromPattern) { 44 49 rules.push(rule); 45 50 } 46 51 } catch { ··· 51 56 return rules; 52 57 } 53 58 59 + /** 60 + * Parse a single redirect rule line 61 + * Format: /from [query_params] /to [status] [conditions] 62 + */ 54 63 function parseRedirectLine(line: string): RedirectRule | null { 55 64 const parts = line.split(/\s+/); 56 - if (parts.length < 2) return null; 65 + 66 + if (parts.length < 2) { 67 + return null; 68 + } 57 69 58 70 let idx = 0; 59 71 const from = parts[idx++]; 60 - if (!from) return null; 72 + 73 + if (!from) { 74 + return null; 75 + } 61 76 62 77 let status = 301; 63 78 let force = false; 64 79 const conditions: NonNullable<RedirectRule['conditions']> = {}; 65 80 const queryParams: Record<string, string> = {}; 66 81 67 - // Parse query parameters before destination 82 + // Parse query parameters that come before the destination path 68 83 while (idx < parts.length) { 69 84 const part = parts[idx]; 70 - if (!part) { idx++; continue; } 71 - if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) break; 85 + if (!part) { 86 + idx++; 87 + continue; 88 + } 89 + 90 + if (part.startsWith('/') || part.startsWith('http://') || part.startsWith('https://')) { 91 + break; 92 + } 93 + 72 94 if (part.includes('=')) { 73 95 const splitIndex = part.indexOf('='); 74 96 const key = part.slice(0, splitIndex); 75 97 const value = part.slice(splitIndex + 1); 76 - if (key && value) queryParams[key] = value; 98 + 99 + if (key && value) { 100 + queryParams[key] = value; 101 + } 77 102 idx++; 78 103 } else { 79 104 break; 80 105 } 81 106 } 82 107 83 - if (idx >= parts.length) return null; 108 + if (idx >= parts.length) { 109 + return null; 110 + } 111 + 84 112 const to = parts[idx++]; 85 - if (!to) return null; 113 + if (!to) { 114 + return null; 115 + } 86 116 87 - // Parse status and conditions 117 + // Parse remaining parts for status code and conditions 88 118 for (let i = idx; i < parts.length; i++) { 89 119 const part = parts[i]; 120 + 90 121 if (!part) continue; 91 122 92 123 if (/^\d+!?$/.test(part)) { ··· 103 134 const splitIndex = part.indexOf('='); 104 135 const key = part.slice(0, splitIndex); 105 136 const value = part.slice(splitIndex + 1); 137 + 106 138 if (!key || !value) continue; 107 139 108 140 const keyLower = key.toLowerCase(); 141 + 109 142 if (keyLower === 'country') { 110 143 conditions.country = value.split(',').map(v => v.trim().toLowerCase()); 111 144 } else if (keyLower === 'language') { ··· 132 165 }; 133 166 } 134 167 168 + /** 169 + * Convert a path pattern with placeholders and splats to a regex 170 + * Examples: 171 + * /blog/:year/:month/:day -> captures year, month, day 172 + * /news/* -> captures splat 173 + */ 135 174 function convertPathToRegex(pattern: string): { pattern: RegExp; params: string[] } { 136 175 const params: string[] = []; 137 176 let regexStr = '^'; 138 177 139 178 const pathPart = pattern.split('?')[0] || pattern; 179 + 140 180 let escaped = pathPart.replace(/[.+^${}()|[\]\\]/g, '\\$&'); 141 181 142 182 escaped = escaped.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, paramName) => { ··· 150 190 } 151 191 152 192 regexStr += escaped; 193 + 153 194 if (!regexStr.endsWith('.*')) { 154 195 regexStr += '/?'; 155 196 } 197 + 156 198 regexStr += '$'; 157 199 158 - return { pattern: new RegExp(regexStr), params }; 200 + return { 201 + pattern: new RegExp(regexStr), 202 + params, 203 + }; 159 204 } 160 205 206 + export interface MatchRedirectContext { 207 + queryParams?: Record<string, string>; 208 + headers?: Record<string, string>; 209 + cookies?: Record<string, string>; 210 + } 211 + 212 + /** 213 + * Match a request path against redirect rules with loop detection 214 + */ 161 215 export function matchRedirectRule( 162 216 requestPath: string, 163 217 rules: RedirectRule[], 164 - context?: { 165 - queryParams?: Record<string, string>; 166 - headers?: Record<string, string>; 167 - cookies?: Record<string, string>; 168 - }, 218 + context?: MatchRedirectContext, 169 219 visitedPaths: Set<string> = new Set() 170 220 ): RedirectMatch | null { 171 221 let normalizedPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`; 172 222 173 - if (visitedPaths.has(normalizedPath)) return null; 223 + if (visitedPaths.has(normalizedPath)) { 224 + return null; 225 + } 226 + 174 227 visitedPaths.add(normalizedPath); 175 - if (visitedPaths.size > 10) return null; 228 + 229 + if (visitedPaths.size > 10) { 230 + return null; 231 + } 176 232 177 233 for (const rule of rules) { 178 - // Check query params 234 + // Check query parameter conditions first 179 235 if (rule.queryParams) { 180 - if (!context?.queryParams) continue; 236 + if (!context?.queryParams) { 237 + continue; 238 + } 239 + 181 240 const queryMatches = Object.entries(rule.queryParams).every(([key, expectedValue]) => { 182 241 const actualValue = context.queryParams?.[key]; 183 - if (actualValue === undefined) return false; 242 + 243 + if (actualValue === undefined) { 244 + return false; 245 + } 246 + 184 247 if (expectedValue && !expectedValue.startsWith(':')) { 185 248 return actualValue === expectedValue; 186 249 } 250 + 187 251 return true; 188 252 }); 189 - if (!queryMatches) continue; 253 + 254 + if (!queryMatches) { 255 + continue; 256 + } 190 257 } 191 258 192 - // Check conditions 259 + // Check conditional redirects (country, language, role, cookie) 193 260 if (rule.conditions) { 194 261 if (rule.conditions.country && context?.headers) { 195 - const country = context.headers['cf-ipcountry']?.toLowerCase() || context.headers['x-country']?.toLowerCase(); 196 - if (!country || !rule.conditions.country.includes(country)) continue; 262 + const cfCountry = context.headers['cf-ipcountry']; 263 + const xCountry = context.headers['x-country']; 264 + const country = cfCountry?.toLowerCase() || xCountry?.toLowerCase(); 265 + if (!country || !rule.conditions.country.includes(country)) { 266 + continue; 267 + } 197 268 } 269 + 198 270 if (rule.conditions.language && context?.headers) { 199 271 const acceptLang = context.headers['accept-language']; 200 - if (!acceptLang) continue; 201 - const langs = acceptLang.split(',').map(l => l.split(';')[0]?.trim().toLowerCase() || '').filter(Boolean); 202 - const hasMatch = rule.conditions.language.some(lang => langs.some(l => l === lang || l.startsWith(lang + '-'))); 203 - if (!hasMatch) continue; 272 + if (!acceptLang) { 273 + continue; 274 + } 275 + const langs = acceptLang 276 + .split(',') 277 + .map(l => { 278 + const langPart = l.split(';')[0]; 279 + return langPart ? langPart.trim().toLowerCase() : ''; 280 + }) 281 + .filter(l => l !== ''); 282 + const hasMatch = rule.conditions.language.some(lang => 283 + langs.some(l => l === lang || l.startsWith(lang + '-')) 284 + ); 285 + if (!hasMatch) { 286 + continue; 287 + } 204 288 } 289 + 205 290 if (rule.conditions.cookie && context?.cookies) { 206 - const hasCookie = rule.conditions.cookie.some(cookieName => context.cookies && cookieName in context.cookies); 207 - if (!hasCookie) continue; 291 + const hasCookie = rule.conditions.cookie.some( 292 + cookieName => context.cookies && cookieName in context.cookies 293 + ); 294 + if (!hasCookie) { 295 + continue; 296 + } 208 297 } 209 - if (rule.conditions.role) continue; 298 + 299 + // Role-based redirects would need JWT verification - skip for now 300 + if (rule.conditions.role) { 301 + continue; 302 + } 210 303 } 211 304 212 305 const match = rule.fromPattern?.exec(normalizedPath); 213 - if (!match) continue; 306 + if (!match) { 307 + continue; 308 + } 214 309 215 310 let targetPath = rule.to; 216 311 312 + // Replace captured parameters 217 313 if (rule.fromParams && match.length > 1) { 218 314 for (let i = 0; i < rule.fromParams.length; i++) { 219 315 const paramName = rule.fromParams[i]; 220 316 const paramValue = match[i + 1]; 317 + 221 318 if (!paramName || !paramValue) continue; 222 319 223 320 const encodedValue = encodeURIComponent(paramValue); 321 + 224 322 if (paramName === 'splat') { 225 - targetPath = targetPath.replace(':splat', encodedValue.replace(/%2F/g, '/')); 323 + const splatValue = encodedValue.replace(/%2F/g, '/'); 324 + targetPath = targetPath.replace(':splat', splatValue); 226 325 } else { 227 326 targetPath = targetPath.replace(`:${paramName}`, encodedValue); 228 327 } 229 328 } 230 329 } 231 330 331 + // Handle query parameter replacements 232 332 if (rule.queryParams && context?.queryParams) { 233 333 for (const [key, placeholder] of Object.entries(rule.queryParams)) { 234 334 const actualValue = context.queryParams[key]; 235 - if (actualValue && placeholder?.startsWith(':')) { 335 + if (actualValue && placeholder && placeholder.startsWith(':')) { 236 336 const paramName = placeholder.slice(1); 237 337 if (paramName) { 238 - targetPath = targetPath.replace(`:${paramName}`, encodeURIComponent(actualValue)); 338 + const encodedValue = encodeURIComponent(actualValue); 339 + targetPath = targetPath.replace(`:${paramName}`, encodedValue); 239 340 } 240 341 } 241 342 } 242 343 } 243 344 244 - return { rule, targetPath, status: rule.status }; 345 + // Preserve query string for 200, 301, 302 redirects (unless target already has one) 346 + if ([200, 301, 302].includes(rule.status) && context?.queryParams && !targetPath.includes('?')) { 347 + const queryString = Object.entries(context.queryParams) 348 + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) 349 + .join('&'); 350 + if (queryString) { 351 + targetPath += `?${queryString}`; 352 + } 353 + } 354 + 355 + return { 356 + rule, 357 + targetPath, 358 + status: rule.status, 359 + }; 245 360 } 246 361 247 362 return null; 248 363 } 249 364 365 + /** 366 + * Parse cookies from Cookie header 367 + */ 250 368 export function parseCookies(cookieHeader?: string): Record<string, string> { 251 369 if (!cookieHeader) return {}; 370 + 252 371 const cookies: Record<string, string> = {}; 253 - for (const part of cookieHeader.split(';')) { 372 + const parts = cookieHeader.split(';'); 373 + 374 + for (const part of parts) { 254 375 const [key, ...valueParts] = part.split('='); 255 376 if (key && valueParts.length > 0) { 256 377 cookies[key.trim()] = valueParts.join('=').trim(); 257 378 } 258 379 } 380 + 259 381 return cookies; 260 382 } 261 383 384 + /** 385 + * Parse query string into object 386 + */ 262 387 export function parseQueryString(url: string): Record<string, string> { 263 388 const queryStart = url.indexOf('?'); 264 389 if (queryStart === -1) return {}; 390 + 265 391 const queryString = url.slice(queryStart + 1); 266 392 const params: Record<string, string> = {}; 393 + 267 394 for (const pair of queryString.split('&')) { 268 395 const [key, value] = pair.split('='); 269 396 if (key) { 270 397 params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : ''; 271 398 } 272 399 } 400 + 273 401 return params; 274 402 }
+3 -4
cli/package.json
··· 1 1 { 2 - "name": "create-wisp", 3 - "version": "1.0.5", 2 + "name": "wispctl", 3 + "version": "1.0.6", 4 4 "description": "CLI for wisp.place - deploy static sites to the AT Protocol", 5 5 "type": "module", 6 6 "main": "./dist/index.js", 7 7 "bin": { 8 - "create-wisp": "dist/index.js", 9 - "wisp-cli": "dist/index.js" 8 + "wispctl": "dist/index.js" 10 9 }, 11 10 "files": [ 12 11 "dist"
+2 -1
package.json
··· 26 26 "screenshot": "bun run apps/main-app/scripts/screenshot-sites.ts", 27 27 "hosting:dev": "cd apps/hosting-service && npm run dev", 28 28 "hosting:start": "cd apps/hosting-service && npm run start", 29 - "codegen": "./scripts/codegen.sh" 29 + "codegen": "./scripts/codegen.sh", 30 + "publish:cli": "cd cli && bun run build && npm publish && cd ../packages/create-wisp && npm publish" 30 31 }, 31 32 "trustedDependencies": [ 32 33 "@parcel/watcher",
+4
packages/@wisp/fs-utils/package.json
··· 25 25 "./subfs-split": { 26 26 "types": "./src/subfs-split.ts", 27 27 "default": "./src/subfs-split.ts" 28 + }, 29 + "./redirects": { 30 + "types": "./src/redirects.ts", 31 + "default": "./src/redirects.ts" 28 32 } 29 33 }, 30 34 "dependencies": {
+4
packages/@wisp/fs-utils/src/index.ts
··· 10 10 11 11 // Subfs splitting utilities 12 12 export { estimateDirectorySize, findLargeDirectories, replaceDirectoryWithSubfs, splitDirectoryIntoChunks } from './subfs-split'; 13 + 14 + // Redirects parsing and matching 15 + export type { RedirectRule, RedirectMatch, MatchRedirectContext } from './redirects'; 16 + export { parseRedirectsFile, matchRedirectRule, parseCookies, parseQueryString } from './redirects';
+2
packages/create-wisp/bin.js
··· 1 + #!/usr/bin/env node 2 + import 'wispctl/dist/index.js';
+15
packages/create-wisp/package.json
··· 1 + { 2 + "name": "create-wisp", 3 + "version": "1.0.6", 4 + "description": "CLI for wisp.place - deploy static sites to the AT Protocol", 5 + "type": "module", 6 + "bin": { 7 + "create-wisp": "bin.js" 8 + }, 9 + "files": [ 10 + "bin.js" 11 + ], 12 + "dependencies": { 13 + "wispctl": "^1.0.6" 14 + } 15 + }