Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
wisp.place
1/**
2 * HTML page generation utilities for hosting service
3 * Generates 404 pages, directory listings, and updating pages
4 */
5
6/**
7 * Generate 404 page HTML
8 */
9export function generate404Page(): string {
10 const html = `<!DOCTYPE html>
11<html>
12<head>
13 <meta charset="utf-8">
14 <meta name="viewport" content="width=device-width, initial-scale=1">
15 <title>404 - Not Found</title>
16 <style>
17 @media (prefers-color-scheme: light) {
18 :root {
19 /* Warm beige background */
20 --background: oklch(0.90 0.012 35);
21 /* Very dark brown text */
22 --foreground: oklch(0.18 0.01 30);
23 --border: oklch(0.75 0.015 30);
24 /* Bright pink accent for links */
25 --accent: oklch(0.78 0.15 345);
26 }
27 }
28 @media (prefers-color-scheme: dark) {
29 :root {
30 /* Slate violet background */
31 --background: oklch(0.23 0.015 285);
32 /* Light gray text */
33 --foreground: oklch(0.90 0.005 285);
34 /* Subtle borders */
35 --border: oklch(0.38 0.02 285);
36 /* Soft pink accent */
37 --accent: oklch(0.85 0.08 5);
38 }
39 }
40 body {
41 font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
42 background: var(--background);
43 color: var(--foreground);
44 padding: 2rem;
45 max-width: 800px;
46 margin: 0 auto;
47 display: flex;
48 flex-direction: column;
49 min-height: 100vh;
50 justify-content: center;
51 align-items: center;
52 text-align: center;
53 }
54 h1 {
55 font-size: 6rem;
56 margin: 0;
57 font-weight: 700;
58 line-height: 1;
59 }
60 h2 {
61 font-size: 1.5rem;
62 margin: 1rem 0 2rem;
63 font-weight: 400;
64 opacity: 0.8;
65 }
66 p {
67 font-size: 1rem;
68 opacity: 0.7;
69 margin-bottom: 2rem;
70 }
71 a {
72 color: var(--accent);
73 text-decoration: none;
74 font-size: 1rem;
75 }
76 a:hover {
77 text-decoration: underline;
78 }
79 footer {
80 margin-top: 2rem;
81 padding-top: 1.5rem;
82 border-top: 1px solid var(--border);
83 text-align: center;
84 font-size: 0.875rem;
85 opacity: 0.7;
86 color: var(--foreground);
87 }
88 footer a {
89 color: var(--accent);
90 text-decoration: none;
91 display: inline;
92 }
93 footer a:hover {
94 text-decoration: underline;
95 }
96 </style>
97</head>
98<body>
99 <div>
100 <h1>404</h1>
101 <h2>Page not found</h2>
102 <p>The page you're looking for doesn't exist.</p>
103 <a href="/">← Back to home</a>
104 </div>
105 <footer>
106 Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
107 </footer>
108</body>
109</html>`;
110 return html;
111}
112
113/**
114 * Generate directory listing HTML
115 */
116export function generateDirectoryListing(path: string, entries: Array<{name: string, isDirectory: boolean}>): string {
117 const title = path || 'Index';
118
119 // Sort: directories first, then files, alphabetically within each group
120 const sortedEntries = [...entries].sort((a, b) => {
121 if (a.isDirectory && !b.isDirectory) return -1;
122 if (!a.isDirectory && b.isDirectory) return 1;
123 return a.name.localeCompare(b.name);
124 });
125
126 const html = `<!DOCTYPE html>
127<html>
128<head>
129 <meta charset="utf-8">
130 <meta name="viewport" content="width=device-width, initial-scale=1">
131 <title>Index of /${path}</title>
132 <style>
133 @media (prefers-color-scheme: light) {
134 :root {
135 /* Warm beige background */
136 --background: oklch(0.90 0.012 35);
137 /* Very dark brown text */
138 --foreground: oklch(0.18 0.01 30);
139 --border: oklch(0.75 0.015 30);
140 /* Bright pink accent for links */
141 --accent: oklch(0.78 0.15 345);
142 /* Lavender for folders */
143 --folder: oklch(0.60 0.12 295);
144 --icon: oklch(0.28 0.01 30);
145 }
146 }
147 @media (prefers-color-scheme: dark) {
148 :root {
149 /* Slate violet background */
150 --background: oklch(0.23 0.015 285);
151 /* Light gray text */
152 --foreground: oklch(0.90 0.005 285);
153 /* Subtle borders */
154 --border: oklch(0.38 0.02 285);
155 /* Soft pink accent */
156 --accent: oklch(0.85 0.08 5);
157 /* Lavender for folders */
158 --folder: oklch(0.70 0.10 295);
159 --icon: oklch(0.85 0.005 285);
160 }
161 }
162 body {
163 font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
164 background: var(--background);
165 color: var(--foreground);
166 padding: 2rem;
167 max-width: 800px;
168 margin: 0 auto;
169 }
170 h1 {
171 font-size: 1.5rem;
172 margin-bottom: 2rem;
173 padding-bottom: 0.5rem;
174 border-bottom: 1px solid var(--border);
175 }
176 ul {
177 list-style: none;
178 padding: 0;
179 }
180 li {
181 padding: 0.5rem 0;
182 border-bottom: 1px solid var(--border);
183 }
184 li:last-child {
185 border-bottom: none;
186 }
187 li a {
188 color: var(--accent);
189 text-decoration: none;
190 display: flex;
191 align-items: center;
192 gap: 0.75rem;
193 }
194 li a:hover {
195 text-decoration: underline;
196 }
197 .folder {
198 color: var(--folder);
199 font-weight: 600;
200 }
201 .file {
202 color: var(--accent);
203 }
204 .folder::before,
205 .file::before,
206 .parent::before {
207 content: "";
208 display: inline-block;
209 width: 1.25em;
210 height: 1.25em;
211 background-color: var(--icon);
212 flex-shrink: 0;
213 -webkit-mask-size: contain;
214 mask-size: contain;
215 -webkit-mask-repeat: no-repeat;
216 mask-repeat: no-repeat;
217 -webkit-mask-position: center;
218 mask-position: center;
219 }
220 .folder::before {
221 -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
222 mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M64 15v37a5.006 5.006 0 0 1-5 5H5a5.006 5.006 0 0 1-5-5V12a5.006 5.006 0 0 1 5-5h14.116a6.966 6.966 0 0 1 5.466 2.627l5 6.247A2.983 2.983 0 0 0 31.922 17H59a1 1 0 0 1 0 2H31.922a4.979 4.979 0 0 1-3.9-1.876l-5-6.247A4.976 4.976 0 0 0 19.116 9H5a3 3 0 0 0-3 3v40a3 3 0 0 0 3 3h54a3 3 0 0 0 3-3V15a3 3 0 0 0-3-3H30a1 1 0 0 1 0-2h29a5.006 5.006 0 0 1 5 5z"/></svg>');
223 }
224 .file::before {
225 -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
226 mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 25 25"><g><path d="M18 8.28a.59.59 0 0 0-.13-.18l-4-3.9h-.05a.41.41 0 0 0-.15-.2.41.41 0 0 0-.19 0h-9a.5.5 0 0 0-.5.5v19a.5.5 0 0 0 .5.5h13a.5.5 0 0 0 .5-.5V8.43a.58.58 0 0 0 .02-.15zM16.3 8H14V5.69zM5 23V5h8v3.5a.49.49 0 0 0 .15.36.5.5 0 0 0 .35.14l3.5-.06V23z"/><path d="M20.5 1h-13a.5.5 0 0 0-.5.5V3a.5.5 0 0 0 1 0V2h12v18h-1a.5.5 0 0 0 0 1h1.5a.5.5 0 0 0 .5-.5v-19a.5.5 0 0 0-.5-.5z"/><path d="M7.5 8h3a.5.5 0 0 0 0-1h-3a.5.5 0 0 0 0 1zM7.5 11h4a.5.5 0 0 0 0-1h-4a.5.5 0 0 0 0 1zM13.5 13h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 16h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1zM13.5 19h-6a.5.5 0 0 0 0 1h6a.5.5 0 0 0 0-1z"/></g></svg>');
227 }
228 .parent::before {
229 -webkit-mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
230 mask-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>');
231 }
232 footer {
233 margin-top: 2rem;
234 padding-top: 1.5rem;
235 border-top: 1px solid var(--border);
236 text-align: center;
237 font-size: 0.875rem;
238 opacity: 0.7;
239 color: var(--foreground);
240 }
241 footer a {
242 color: var(--accent);
243 text-decoration: none;
244 display: inline;
245 }
246 footer a:hover {
247 text-decoration: underline;
248 }
249 </style>
250</head>
251<body>
252 <h1>Index of /${path}</h1>
253 <ul>
254 ${path ? '<li><a href="../" class="parent">../</a></li>' : ''}
255 ${sortedEntries.map(e =>
256 `<li><a href="${e.name}${e.isDirectory ? '/' : ''}" class="${e.isDirectory ? 'folder' : 'file'}">${e.name}${e.isDirectory ? '/' : ''}</a></li>`
257 ).join('\n ')}
258 </ul>
259 <footer>
260 Hosted on <a href="https://wisp.place" target="_blank" rel="noopener">wisp.place</a> - Made by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener">@nekomimi.pet</a>
261 </footer>
262</body>
263</html>`;
264 return html;
265}
266
267/**
268 * Return a response indicating the site is being updated
269 */
270export function generateSiteUpdatingPage(): string {
271 const html = `<!DOCTYPE html>
272<html>
273<head>
274 <meta charset="utf-8">
275 <meta name="viewport" content="width=device-width, initial-scale=1">
276 <title>Site Updating</title>
277 <style>
278 @media (prefers-color-scheme: light) {
279 :root {
280 --background: oklch(0.90 0.012 35);
281 --foreground: oklch(0.18 0.01 30);
282 --primary: oklch(0.35 0.02 35);
283 --accent: oklch(0.78 0.15 345);
284 }
285 }
286 @media (prefers-color-scheme: dark) {
287 :root {
288 --background: oklch(0.23 0.015 285);
289 --foreground: oklch(0.90 0.005 285);
290 --primary: oklch(0.70 0.10 295);
291 --accent: oklch(0.85 0.08 5);
292 }
293 }
294 body {
295 font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
296 display: flex;
297 align-items: center;
298 justify-content: center;
299 min-height: 100vh;
300 margin: 0;
301 background: var(--background);
302 color: var(--foreground);
303 }
304 .container {
305 text-align: center;
306 padding: 2rem;
307 max-width: 500px;
308 }
309 h1 {
310 font-size: 2.5rem;
311 margin-bottom: 1rem;
312 font-weight: 600;
313 color: var(--primary);
314 }
315 p {
316 font-size: 1.25rem;
317 opacity: 0.8;
318 margin-bottom: 2rem;
319 color: var(--foreground);
320 }
321 .spinner {
322 border: 4px solid var(--accent);
323 border-radius: 50%;
324 border-top: 4px solid var(--primary);
325 width: 40px;
326 height: 40px;
327 animation: spin 1s linear infinite;
328 margin: 0 auto;
329 }
330 @keyframes spin {
331 0% { transform: rotate(0deg); }
332 100% { transform: rotate(360deg); }
333 }
334 </style>
335 <meta http-equiv="refresh" content="3">
336</head>
337<body>
338 <div class="container">
339 <h1>Site Updating</h1>
340 <p>This site is undergoing an update right now. Check back in a moment...</p>
341 <div class="spinner"></div>
342 </div>
343</body>
344</html>`;
345
346 return html;
347}
348
349/**
350 * Create a Response for site updating
351 */
352export function siteUpdatingResponse(): Response {
353 return new Response(generateSiteUpdatingPage(), {
354 status: 503,
355 headers: {
356 'Content-Type': 'text/html; charset=utf-8',
357 'Cache-Control': 'no-store, no-cache, must-revalidate',
358 'Retry-After': '3',
359 },
360 });
361}
362