this is how weegs.dev is put together
0

Configure Feed

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

feat: initial public release of the weegs.dev engine

- terminal/TUI site engine: 3 html shells, tui.ts compiled to tui.js, prompt-chrome.js, style.css + eldritch.css
- guestbook via cloudflare pages function (functions/api/guestbook.ts) + env-var'd moderate.nu
- generate-index.nu builds folder index.json from markdown frontmatter
- sample content/ so the engine runs out of the box
- self-hosted fonts with third-party notices (JetBrains Mono OFL 1.1, Nerd Fonts)
- README + MIT LICENSE
- placeholder kv namespace id, socials, and avatar

weegs710 (Jul 14, 2026, 2:35 PM EDT) bdd2a7f9

+3863
+6
.gitignore
··· 1 + # dependencies 2 + node_modules/ 3 + 4 + # os / editor cruft 5 + .DS_Store 6 + *.swp
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 weegs710 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+101
README.md
··· 1 + # weegs.dev 2 + 3 + my personal site. it's a terminal -- the whole thing is styled after my 4 + [oh-my-posh](https://ohmyposh.dev/) prompt and a nushell/ratatui-ish TUI, so 5 + browsing it feels like poking around a shell. 6 + 7 + live at [weegs.dev](https://weegs.dev). 8 + 9 + ## what's here 10 + 11 + no framework, no bundler, no dependencies. static HTML + CSS, one TypeScript 12 + file compiled to plain JS, deployed on [Cloudflare Pages](https://pages.cloudflare.com/). 13 + the only server-side piece is a single Pages Function backing the guestbook. 14 + 15 + ``` 16 + index.html home page (fastfetch-style card + latest news) 17 + gaming.html game library (playing / finished / dropped / notes) 18 + stuff.html projects, blog, rants, guestbook 19 + tui.ts / tui.js the TUI engine: keyboard nav, markdown + nix rendering 20 + prompt-chrome.js live clock / date / page-load-time in the prompt bar 21 + style.css layout + the terminal look 22 + eldritch.css the eldritch colorscheme as css variables 23 + content/ all page content (markdown + generated index.json) 24 + functions/api/ Cloudflare Pages Function for the guestbook 25 + generate-index.nu builds a folder's index.json from its markdown frontmatter 26 + moderate.nu guestbook moderation (list / delete) 27 + ``` 28 + 29 + ## the content system 30 + 31 + pages don't hardcode their content. each list item in the HTML is either a 32 + `file` (fetches a markdown file) or a `folder` (fetches an `index.json` and 33 + expands its children inline). `tui.ts` fetches on demand and renders it with a 34 + small hand-rolled markdown renderer -- headers, tables, code blocks, and a nix 35 + syntax highlighter for the rants. 36 + 37 + folder indexes are generated, not written by hand: 38 + 39 + ``` 40 + nu generate-index.nu content/blog/ 41 + ``` 42 + 43 + it reads the `title:` / `date:` frontmatter from every `*.md` in the dir and 44 + writes a sorted `index.json` next to them. 45 + 46 + ## keyboard nav 47 + 48 + it's driven like a file manager: 49 + 50 + - `j` / `k` -- move up/down the list (or scroll the open pane) 51 + - `l` / `enter` -- open the focused item 52 + - `h` / `esc` -- close the pane, or collapse the parent folder 53 + 54 + ## local dev 55 + 56 + serve the folder with any static file server, e.g.: 57 + 58 + ``` 59 + npx serve . 60 + # or 61 + python -m http.server 62 + ``` 63 + 64 + then open the printed localhost url. everything except the guestbook works 65 + fully static -- the guestbook needs the Pages Function, so it only responds 66 + live on the deployed site (or under `wrangler pages dev`). 67 + 68 + ## build 69 + 70 + `tui.js` is committed, but it's generated from `tui.ts`. after editing the 71 + TypeScript: 72 + 73 + ``` 74 + npm run build # tsc -> tui.js 75 + npm run check # tsc --noEmit, type-check only 76 + ``` 77 + 78 + ## guestbook 79 + 80 + `functions/api/guestbook.ts` is a Cloudflare Pages Function backed by a KV 81 + namespace bound as `GUESTBOOK`. it does a honeypot field, per-IP rate limiting, 82 + and length-clamps every input. 83 + 84 + moderation is `moderate.nu`: 85 + 86 + ``` 87 + GUESTBOOK_NS_ID=<your-kv-namespace-id> nu moderate.nu list 88 + GUESTBOOK_NS_ID=<your-kv-namespace-id> nu moderate.nu delete <id> 89 + ``` 90 + 91 + it shells out to `wrangler`, so you need to be authenticated with Cloudflare 92 + and have the namespace id in the environment. 93 + 94 + ## license 95 + 96 + [MIT](LICENSE). the code is yours to learn from and reuse. the content under 97 + `content/` (my writing, notes, etc.) is mine -- please don't repost it as your 98 + own. 99 + 100 + the bundled fonts under `fonts/` are third-party and keep their own licenses -- 101 + see [fonts/NOTICE.md](fonts/NOTICE.md).
+10
content/blog/hello-world.md
··· 1 + --- 2 + title: hello world 3 + date: 2026-01-01 4 + --- 5 + 6 + # hello world 7 + 8 + sample blog post. the blog folder reads its `index.json` (generated by 9 + `generate-index.nu` from each post's frontmatter) and the engine renders the 10 + markdown in the right pane. delete this and write your own.
+7
content/blog/index.json
··· 1 + [ 2 + { 3 + "slug": "hello-world.md", 4 + "title": "hello world", 5 + "date": "2026-01-01" 6 + } 7 + ]
+1
content/gaming/dropped/index.json
··· 1 + []
+1
content/gaming/finished/index.json
··· 1 + []
+11
content/gaming/installed.md
··· 1 + --- 2 + title: installed 3 + --- 4 + 5 + # installed 6 + 7 + | name | store | status | 8 + | ------------------ | ----- | -------- | 9 + | example game one | steam | playing | 10 + | example game two | gog | finished | 11 + | example game three | epic | dropped |
+1
content/gaming/playing/index.json
··· 1 + []
+1
content/gaming/ref-notes/index.json
··· 1 + []
+7
content/news/index.json
··· 1 + [ 2 + { 3 + "slug": "welcome.md", 4 + "title": "welcome", 5 + "date": "2026-01-01" 6 + } 7 + ]
+10
content/news/welcome.md
··· 1 + --- 2 + title: welcome 3 + date: 2026-01-01 4 + --- 5 + 6 + ### 2026/01/01 7 + 8 + this is sample content shipped with the engine so it runs out of the box. 9 + everything under `content/` is example data -- replace it with your own 10 + markdown and regenerate the folder indexes with `generate-index.nu`.
+10
content/projects.md
··· 1 + --- 2 + title: projects 3 + --- 4 + 5 + # projects 6 + 7 + | name | status | lang | description | 8 + | --------------- | ------ | ---- | ------------------------------------------- | 9 + | example-project | wip | rust | a sample row -- edit content/projects.md | 10 + | another-thing | active | nix | folders and files both render from markdown |
+1
content/rants/index.json
··· 1 + []
+49
eldritch.css
··· 1 + /* Eldritch color palette -- raw values */ 2 + :root { 3 + --e-bg: #212337; 4 + --e-bg-dark: #171928; 5 + --e-bg-raised: #292e42; 6 + --e-fg: #ebfafa; 7 + --e-fg-dim: #abb4da; 8 + --e-cyan: #04d1f9; 9 + --e-cyan-bright: #39ddfd; 10 + --e-green: #37f499; 11 + --e-green-dark: #33c57f; 12 + --e-purple: #a48cf2; 13 + --e-pink: #f265b5; 14 + --e-orange: #f7c67f; 15 + --e-yellow: #f1fc79; 16 + --e-red: #f16c75; 17 + --e-red-bright: #f0313e; 18 + --e-comment: #7081d0; 19 + --e-border: #414868; 20 + } 21 + 22 + /* Semantic site roles mapped to eldritch */ 23 + :root { 24 + --bg: var(--e-bg); 25 + --bg-surface: var(--e-bg-dark); 26 + --bg-elevated: var(--e-bg-raised); 27 + 28 + --text: var(--e-fg); 29 + --text-muted: var(--e-fg-dim); 30 + --text-subtle: var(--e-comment); 31 + 32 + --accent: var(--e-green); 33 + --accent-hover: var(--e-cyan); 34 + --accent-alt: var(--e-purple); 35 + 36 + --link: var(--e-cyan); 37 + --link-hover: var(--e-cyan-bright); 38 + 39 + --border: var(--e-border); 40 + --border-focus: var(--e-cyan); 41 + 42 + --code-bg: var(--e-bg-dark); 43 + --code-text: var(--e-yellow); 44 + 45 + --status-success: var(--e-green); 46 + --status-warning: var(--e-yellow); 47 + --status-error: var(--e-red); 48 + --status-info: var(--e-cyan); 49 + }
favicon.webp

This is a binary file and will not be displayed.

fonts/JetBrainsMono-Italic.woff2

This is a binary file and will not be displayed.

+93
fonts/JetBrainsMono-OFL.txt
··· 1 + Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) 2 + 3 + This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 + This license is copied below, and is also available with a FAQ at: 5 + https://openfontlicense.org 6 + 7 + 8 + ----------------------------------------------------------- 9 + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 10 + ----------------------------------------------------------- 11 + 12 + PREAMBLE 13 + The goals of the Open Font License (OFL) are to stimulate worldwide 14 + development of collaborative font projects, to support the font creation 15 + efforts of academic and linguistic communities, and to provide a free and 16 + open framework in which fonts may be shared and improved in partnership 17 + with others. 18 + 19 + The OFL allows the licensed fonts to be used, studied, modified and 20 + redistributed freely as long as they are not sold by themselves. The 21 + fonts, including any derivative works, can be bundled, embedded, 22 + redistributed and/or sold with any software provided that any reserved 23 + names are not used by derivative works. The fonts and derivatives, 24 + however, cannot be released under any other type of license. The 25 + requirement for fonts to remain under this license does not apply 26 + to any document created using the fonts or their derivatives. 27 + 28 + DEFINITIONS 29 + "Font Software" refers to the set of files released by the Copyright 30 + Holder(s) under this license and clearly marked as such. This may 31 + include source files, build scripts and documentation. 32 + 33 + "Reserved Font Name" refers to any names specified as such after the 34 + copyright statement(s). 35 + 36 + "Original Version" refers to the collection of Font Software components as 37 + distributed by the Copyright Holder(s). 38 + 39 + "Modified Version" refers to any derivative made by adding to, deleting, 40 + or substituting -- in part or in whole -- any of the components of the 41 + Original Version, by changing formats or by porting the Font Software to a 42 + new environment. 43 + 44 + "Author" refers to any designer, engineer, programmer, technical 45 + writer or other person who contributed to the Font Software. 46 + 47 + PERMISSION & CONDITIONS 48 + Permission is hereby granted, free of charge, to any person obtaining 49 + a copy of the Font Software, to use, study, copy, merge, embed, modify, 50 + redistribute, and sell modified and unmodified copies of the Font 51 + Software, subject to the following conditions: 52 + 53 + 1) Neither the Font Software nor any of its individual components, 54 + in Original or Modified Versions, may be sold by itself. 55 + 56 + 2) Original or Modified Versions of the Font Software may be bundled, 57 + redistributed and/or sold with any software, provided that each copy 58 + contains the above copyright notice and this license. These can be 59 + included either as stand-alone text files, human-readable headers or 60 + in the appropriate machine-readable metadata fields within text or 61 + binary files as long as those fields can be easily viewed by the user. 62 + 63 + 3) No Modified Version of the Font Software may use the Reserved Font 64 + Name(s) unless explicit written permission is granted by the corresponding 65 + Copyright Holder. This restriction only applies to the primary font name as 66 + presented to the users. 67 + 68 + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 69 + Software shall not be used to promote, endorse or advertise any 70 + Modified Version, except to acknowledge the contribution(s) of the 71 + Copyright Holder(s) and the Author(s) or with their explicit written 72 + permission. 73 + 74 + 5) The Font Software, modified or unmodified, in part or in whole, 75 + must be distributed entirely under this license, and must not be 76 + distributed under any other license. The requirement for fonts to 77 + remain under this license does not apply to any document created 78 + using the Font Software. 79 + 80 + TERMINATION 81 + This license becomes null and void if any of the above conditions are 82 + not met. 83 + 84 + DISCLAIMER 85 + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 86 + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 87 + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 88 + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 89 + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 90 + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 91 + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 92 + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 93 + OTHER DEALINGS IN THE FONT SOFTWARE.
fonts/JetBrainsMono.woff2

This is a binary file and will not be displayed.

+19
fonts/NOTICE.md
··· 1 + # Third-party font licenses 2 + 3 + The font files in this directory are third-party software, bundled and 4 + redistributed under their own licenses. They are **not** covered by the MIT 5 + license that applies to the rest of this repository (see [../LICENSE](../LICENSE)). 6 + 7 + ## JetBrains Mono 8 + 9 + - Files: `JetBrainsMono.woff2`, `JetBrainsMono-Italic.woff2` 10 + - Copyright 2020 The JetBrains Mono Project Authors 11 + - License: SIL Open Font License 1.1 -- see [`JetBrainsMono-OFL.txt`](JetBrainsMono-OFL.txt) 12 + - Source: https://github.com/JetBrains/JetBrainsMono 13 + 14 + ## Nerd Fonts (icon glyphs) 15 + 16 + - File: `NerdSymbols.woff2` 17 + - Icon glyphs from the Nerd Fonts project, aggregated from multiple upstream 18 + icon fonts, each under its own license -- see [`NerdFonts-LICENSE.txt`](NerdFonts-LICENSE.txt) 19 + - Source: https://github.com/ryanoasis/nerd-fonts
+126
fonts/NerdFonts-LICENSE.txt
··· 1 + # Nerd Fonts Licensing 2 + 3 + There are various sources used under various licenses: 4 + 5 + * Nerd Fonts source fonts, patched fonts, and folders with explict OFL SIL files are licensed under SIL OPEN FONT LICENSE Version 1.1 (see below). 6 + * Nerd Fonts original source code files (such as `.sh`, `.py`, `font-patcher` and others) are licensed under the MIT License (MIT) (see below). 7 + * Many other licenses are present in this project for even more detailed breakdown see: [License Audit](https://github.com/ryanoasis/nerd-fonts/blob/-/license-audit.md). 8 + 9 + ## Source files not in folders containing an explicit license are using the MIT License (MIT) 10 + 11 + The MIT License (MIT) 12 + 13 + Copyright (c) 2014 Ryan L McIntyre 14 + 15 + Permission is hereby granted, free of charge, to any person obtaining a copy 16 + of this software and associated documentation files (the "Software"), to deal 17 + in the Software without restriction, including without limitation the rights 18 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 19 + copies of the Software, and to permit persons to whom the Software is 20 + furnished to do so, subject to the following conditions: 21 + 22 + The above copyright notice and this permission notice shall be included in all 23 + copies or substantial portions of the Software. 24 + 25 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 26 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 27 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 28 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 29 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 30 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 31 + SOFTWARE. 32 + 33 + ## Various Fonts, Patched Fonts, SVGs, Glyph Fonts, and any files in a folder with explicit SIL OFL 1.1 License 34 + 35 + Copyright (c) 2014, Ryan L McIntyre (https://ryanlmcintyre.com). 36 + 37 + This Font Software is licensed under the SIL Open Font License, Version 1.1. 38 + This license is copied below, and is also available with a FAQ at: 39 + http://scripts.sil.org/OFL 40 + 41 + ----------------------------------------------------------- 42 + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 43 + ----------------------------------------------------------- 44 + 45 + PREAMBLE 46 + The goals of the Open Font License (OFL) are to stimulate worldwide 47 + development of collaborative font projects, to support the font creation 48 + efforts of academic and linguistic communities, and to provide a free and 49 + open framework in which fonts may be shared and improved in partnership 50 + with others. 51 + 52 + The OFL allows the licensed fonts to be used, studied, modified and 53 + redistributed freely as long as they are not sold by themselves. The 54 + fonts, including any derivative works, can be bundled, embedded, 55 + redistributed and/or sold with any software provided that any reserved 56 + names are not used by derivative works. The fonts and derivatives, 57 + however, cannot be released under any other type of license. The 58 + requirement for fonts to remain under this license does not apply 59 + to any document created using the fonts or their derivatives. 60 + 61 + DEFINITIONS 62 + "Font Software" refers to the set of files released by the Copyright 63 + Holder(s) under this license and clearly marked as such. This may 64 + include source files, build scripts and documentation. 65 + 66 + "Reserved Font Name" refers to any names specified as such after the 67 + copyright statement(s). 68 + 69 + "Original Version" refers to the collection of Font Software components as 70 + distributed by the Copyright Holder(s). 71 + 72 + "Modified Version" refers to any derivative made by adding to, deleting, 73 + or substituting -- in part or in whole -- any of the components of the 74 + Original Version, by changing formats or by porting the Font Software to a 75 + new environment. 76 + 77 + "Author" refers to any designer, engineer, programmer, technical 78 + writer or other person who contributed to the Font Software. 79 + 80 + PERMISSION & CONDITIONS 81 + Permission is hereby granted, free of charge, to any person obtaining 82 + a copy of the Font Software, to use, study, copy, merge, embed, modify, 83 + redistribute, and sell modified and unmodified copies of the Font 84 + Software, subject to the following conditions: 85 + 86 + 1) Neither the Font Software nor any of its individual components, 87 + in Original or Modified Versions, may be sold by itself. 88 + 89 + 2) Original or Modified Versions of the Font Software may be bundled, 90 + redistributed and/or sold with any software, provided that each copy 91 + contains the above copyright notice and this license. These can be 92 + included either as stand-alone text files, human-readable headers or 93 + in the appropriate machine-readable metadata fields within text or 94 + binary files as long as those fields can be easily viewed by the user. 95 + 96 + 3) No Modified Version of the Font Software may use the Reserved Font 97 + Name(s) unless explicit written permission is granted by the corresponding 98 + Copyright Holder. This restriction only applies to the primary font name as 99 + presented to the users. 100 + 101 + 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font 102 + Software shall not be used to promote, endorse or advertise any 103 + Modified Version, except to acknowledge the contribution(s) of the 104 + Copyright Holder(s) and the Author(s) or with their explicit written 105 + permission. 106 + 107 + 5) The Font Software, modified or unmodified, in part or in whole, 108 + must be distributed entirely under this license, and must not be 109 + distributed under any other license. The requirement for fonts to 110 + remain under this license does not apply to any document created 111 + using the Font Software. 112 + 113 + TERMINATION 114 + This license becomes null and void if any of the above conditions are 115 + not met. 116 + 117 + DISCLAIMER 118 + THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 119 + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 120 + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 121 + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 122 + COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 123 + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 124 + DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 125 + FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 126 + OTHER DEALINGS IN THE FONT SOFTWARE.
fonts/NerdSymbols.woff2

This is a binary file and will not be displayed.

+103
functions/api/guestbook.ts
··· 1 + interface GBEntry { 2 + id: string; 3 + name: string; 4 + message: string; 5 + website: string; 6 + timestamp: number; 7 + date: string; 8 + } 9 + 10 + // minimal inline types -- install @cloudflare/workers-types for full IDE support 11 + interface KV { 12 + get(key: string): Promise<string | null>; 13 + get<T>(key: string, type: "json"): Promise<T | null>; 14 + put( 15 + key: string, 16 + value: string, 17 + options?: { expirationTtl?: number }, 18 + ): Promise<void>; 19 + } 20 + 21 + interface Env { 22 + GUESTBOOK: KV; 23 + } 24 + 25 + interface PagesCtx { 26 + request: Request; 27 + env: Env; 28 + } 29 + 30 + const ENTRIES_KEY = "entries"; 31 + const MAX_ENTRIES = 200; 32 + const RATE_TTL = 60; // seconds between submissions per IP 33 + 34 + function json(data: unknown, status = 200): Response { 35 + return new Response(JSON.stringify(data), { 36 + status, 37 + headers: { 38 + "Content-Type": "application/json", 39 + "Cache-Control": "no-store", 40 + }, 41 + }); 42 + } 43 + 44 + export const onRequest = async ({ 45 + request, 46 + env, 47 + }: PagesCtx): Promise<Response> => { 48 + if (!env.GUESTBOOK) return json({ error: "guestbook not configured" }, 503); 49 + 50 + if (request.method === "GET") { 51 + const entries = await env.GUESTBOOK.get<GBEntry[]>(ENTRIES_KEY, "json"); 52 + return json({ entries: entries ?? [] }); 53 + } 54 + 55 + if (request.method === "POST") { 56 + let body: Record<string, string>; 57 + try { 58 + body = (await request.json()) as Record<string, string>; 59 + } catch { 60 + return json({ error: "invalid json" }, 400); 61 + } 62 + 63 + // honeypot -- bots fill this, humans don't 64 + if (body.hp) return json({ ok: true }); 65 + 66 + const name = (body.name ?? "").trim().slice(0, 50); 67 + const message = (body.message ?? "").trim().slice(0, 500); 68 + const website = (body.website ?? "").trim().slice(0, 100); 69 + 70 + if (!name || !message) 71 + return json({ error: "name and message required" }, 400); 72 + 73 + const ip = request.headers.get("cf-connecting-ip") ?? "unknown"; 74 + const rlKey = `ratelimit:${ip}`; 75 + const ratelimited = await env.GUESTBOOK.get(rlKey); 76 + if (ratelimited) return json({ error: "slow down" }, 429); 77 + 78 + const now = Date.now(); 79 + const entry: GBEntry = { 80 + id: `${now}-${Math.random().toString(36).slice(2, 7)}`, 81 + name, 82 + message, 83 + website, 84 + timestamp: now, 85 + date: new Date(now).toISOString().slice(0, 10), 86 + }; 87 + 88 + // read-modify-write -- race acceptable at personal site traffic levels 89 + const entries = 90 + (await env.GUESTBOOK.get<GBEntry[]>(ENTRIES_KEY, "json")) ?? []; 91 + entries.unshift(entry); 92 + if (entries.length > MAX_ENTRIES) entries.splice(MAX_ENTRIES); 93 + 94 + await Promise.all([ 95 + env.GUESTBOOK.put(ENTRIES_KEY, JSON.stringify(entries)), 96 + env.GUESTBOOK.put(rlKey, "1", { expirationTtl: RATE_TTL }), 97 + ]); 98 + 99 + return json({ ok: true, entry }, 201); 100 + } 101 + 102 + return json({ error: "method not allowed" }, 405); 103 + };
+116
gaming.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>gaming - weegs.dev</title> 7 + <link rel="icon" href="favicon.webp" type="image/webp" /> 8 + <link rel="stylesheet" href="eldritch.css" /> 9 + <link rel="stylesheet" href="style.css" /> 10 + </head> 11 + <body> 12 + <div class="editor"> 13 + <div class="prompt-chrome"> 14 + <div class="prompt-l1"> 15 + <nav class="powerline-nav"> 16 + <a class="nav-seg nav-seg-logo" href="/">weegs.dev</a> 17 + <a class="nav-seg" href="/">home</a> 18 + <a class="nav-seg active" href="gaming.html">gaming</a> 19 + <a class="nav-seg" href="stuff.html">my stuff</a> 20 + </nav> 21 + <div class="r-segments"> 22 + <span class="r-status">λ</span 23 + ><span class="r-exectime" 24 + ><span class="r-icon">&#xF254;</span 25 + ><span id="loadtime">...</span></span 26 + > 27 + </div> 28 + </div> 29 + <div class="prompt-l2"> 30 + <span class="p-frame">╭─</span> 31 + <span class="p-time" id="clock">--:--:--</span> 32 + <span class="p-sep"> | </span> 33 + <span class="p-date" id="datestamp"></span> 34 + <span class="p-sep"> | </span><span class="p-icon">&#xF07B;</span 35 + ><span class="p-sep"> in </span> 36 + <span class="p-cwd">~/gaming</span> 37 + </div> 38 + <div class="prompt-l3"> 39 + <span 40 + ><span class="p-frame">╰─</span><span class="p-arrow">❯</span></span 41 + > 42 + </div> 43 + </div> 44 + 45 + <main class="content"> 46 + <div class="content-inner"> 47 + <div class="tui-session"> 48 + <div class="tui-prompt-line"> 49 + <span class="p-arrow">❯</span 50 + ><span class="cmd"> ls ~/gaming/</span> 51 + </div> 52 + <div class="tui-workspace"> 53 + <div class="tui-list"> 54 + <div class="tui-list-header">~/gaming/</div> 55 + <div 56 + class="tui-item" 57 + data-type="file" 58 + data-path="content/gaming/installed.md" 59 + > 60 + installed 61 + </div> 62 + <div 63 + class="tui-item tui-item-folder" 64 + data-type="folder" 65 + data-path="content/gaming/playing/" 66 + > 67 + <span class="folder-arrow">+</span> playing 68 + </div> 69 + <div 70 + class="tui-item tui-item-folder" 71 + data-type="folder" 72 + data-path="content/gaming/finished/" 73 + > 74 + <span class="folder-arrow">+</span> finished 75 + </div> 76 + <div 77 + class="tui-item tui-item-folder" 78 + data-type="folder" 79 + data-path="content/gaming/dropped/" 80 + > 81 + <span class="folder-arrow">+</span> dropped 82 + </div> 83 + <div 84 + class="tui-item tui-item-folder" 85 + data-type="folder" 86 + data-path="content/gaming/ref-notes/" 87 + > 88 + <span class="folder-arrow">+</span> ref-notes 89 + </div> 90 + <div class="tui-list-hint"> 91 + <span class="key">j</span><span class="key">k</span> move 92 + &nbsp; <span class="key">l</span 93 + ><span class="key">↵</span> open &nbsp; 94 + <span class="key">h</span><span class="key">esc</span> back 95 + </div> 96 + </div> 97 + <div class="tui-output-wrap"> 98 + <div class="tui-cmd-line" id="tui-cmd-line"> 99 + <span class="p-arrow">❯</span> 100 + <span class="cmd" id="tui-cmd-text"></span> 101 + </div> 102 + <div id="tui-output"></div> 103 + <div class="tui-empty-hint" id="tui-empty-hint"> 104 + select a list 105 + </div> 106 + </div> 107 + </div> 108 + </div> 109 + </div> 110 + </main> 111 + </div> 112 + 113 + <script src="prompt-chrome.js"></script> 114 + <script type="module" src="tui.js"></script> 115 + </body> 116 + </html>
+35
generate-index.nu
··· 1 + #!/usr/bin/env nu 2 + 3 + # pivots frontmatter key: value pairs into a record via parse+flatten+transpose 4 + def parse-frontmatter [content: string] { 5 + let lines = ($content | lines) 6 + if ($lines | is-empty) or (($lines | first) != "---") { return {} } 7 + let rest = ($lines | skip 1) 8 + let closes = ($rest | enumerate | where item == "---") 9 + if ($closes | is-empty) { return {} } 10 + let close_idx = ($closes | first | get index) 11 + let kv = ($rest | take $close_idx 12 + | each { parse '{key}: {val}' } 13 + | flatten) 14 + if ($kv | is-empty) { return {} } 15 + $kv | update val { str trim --char '"' } | transpose -r -d 16 + } 17 + 18 + def main [dir: string] { 19 + let base = ($dir | str trim --right --char '/') 20 + glob $"($base)/*.md" 21 + | where { |p| ($p | path basename) !~ '(?i)readme' } 22 + | each { |path| 23 + let fm = parse-frontmatter (open --raw $path) 24 + let slug = ($path | path basename) 25 + { 26 + slug: $slug, 27 + title: ($fm.title? | default ($slug | str replace '.md' '' | str replace --all '-' ' ')), 28 + date: ($fm.date? | default ''), 29 + } 30 + } 31 + | sort-by date -r 32 + | to json 33 + | save -f $"($base)/index.json" 34 + print $"wrote index to ($base)/index.json" 35 + }
+85
index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>weegs.dev</title> 7 + <link rel="icon" href="favicon.webp" type="image/webp" /> 8 + <link rel="stylesheet" href="eldritch.css" /> 9 + <link rel="stylesheet" href="style.css" /> 10 + </head> 11 + <body> 12 + <div class="editor"> 13 + <div class="prompt-chrome"> 14 + <div class="prompt-l1"> 15 + <nav class="powerline-nav"> 16 + <a class="nav-seg nav-seg-logo" href="/">weegs.dev</a> 17 + <a class="nav-seg active" href="/">home</a> 18 + <a class="nav-seg" href="gaming.html">gaming</a> 19 + <a class="nav-seg" href="stuff.html">my stuff</a> 20 + </nav> 21 + <div class="r-segments"> 22 + <span class="r-status">λ</span 23 + ><span class="r-exectime" 24 + ><span class="r-icon">&#xF254;</span 25 + ><span id="loadtime">...</span></span 26 + > 27 + </div> 28 + </div> 29 + <div class="prompt-l2"> 30 + <span class="p-frame">╭─</span> 31 + <span class="p-time" id="clock">--:--:--</span> 32 + <span class="p-sep"> | </span> 33 + <span class="p-date" id="datestamp"></span> 34 + <span class="p-sep"> | </span><span class="p-icon">&#xF07B;</span 35 + ><span class="p-sep"> in </span> 36 + <span class="p-cwd">~</span> 37 + </div> 38 + <div class="prompt-l3"> 39 + <span 40 + ><span class="p-frame">╰─</span><span class="p-arrow">❯</span></span 41 + > 42 + </div> 43 + </div> 44 + 45 + <main class="content"> 46 + <div class="content-inner"> 47 + <div class="tui-session" data-page="home"> 48 + <div class="tui-prompt-line"> 49 + <span class="p-arrow">❯</span><span class="cmd"> ls ~/</span> 50 + </div> 51 + <div class="tui-workspace"> 52 + <div class="tui-list"> 53 + <div class="tui-list-header">~/</div> 54 + <div 55 + class="tui-item tui-item-folder" 56 + data-type="folder" 57 + data-path="content/news/" 58 + > 59 + <span class="folder-arrow">+</span> news 60 + </div> 61 + <div class="tui-list-hint"> 62 + <span class="key">j</span><span class="key">k</span> move 63 + &nbsp; <span class="key">l</span 64 + ><span class="key">↵</span> open &nbsp; 65 + <span class="key">h</span><span class="key">esc</span> back 66 + </div> 67 + </div> 68 + <div class="tui-output-wrap"> 69 + <div class="tui-cmd-line" id="tui-cmd-line"> 70 + <span class="p-arrow">❯</span> 71 + <span class="cmd" id="tui-cmd-text"></span> 72 + </div> 73 + <div id="tui-output"></div> 74 + <div class="tui-empty-hint" id="tui-empty-hint">loading...</div> 75 + </div> 76 + </div> 77 + </div> 78 + </div> 79 + </main> 80 + </div> 81 + 82 + <script src="prompt-chrome.js"></script> 83 + <script type="module" src="tui.js"></script> 84 + </body> 85 + </html>
+61
moderate.nu
··· 1 + #!/usr/bin/env nu 2 + # guestbook moderation -- list and delete entries via wrangler KV 3 + 4 + # namespace id comes from the environment so it stays out of the public repo 5 + def ns-id [] { 6 + let id = ($env | get -o GUESTBOOK_NS_ID) 7 + if ($id | is-empty) { 8 + error make {msg: "set $env.GUESTBOOK_NS_ID to the guestbook KV namespace id"} 9 + } 10 + $id 11 + } 12 + 13 + def get-entries [] { 14 + let raw = (try { 15 + cd /tmp; ^nix run nixpkgs#wrangler -- kv key get --namespace-id (ns-id) --remote entries 16 + } catch { 17 + return [] 18 + }) 19 + if ($raw | is-empty) { return [] } 20 + # strip wrangler/nix banner lines before the JSON array 21 + let idx = ($raw | str index-of '[') 22 + if $idx == -1 { return [] } 23 + $raw | str substring $idx.. | from json 24 + } 25 + 26 + def put-entries [entries: list] { 27 + cd /tmp; ^nix run nixpkgs#wrangler -- kv key put --namespace-id (ns-id) --remote entries ($entries | to json) 28 + } 29 + 30 + # show all entries 31 + def "main list" [] { 32 + let entries = (get-entries) 33 + if ($entries | is-empty) { 34 + print "(no entries)" 35 + return 36 + } 37 + $entries | enumerate | each {|it| { 38 + "#": $it.index 39 + id: $it.item.id 40 + name: $it.item.name 41 + message: ($it.item.message | str substring 0..59) 42 + date: $it.item.date 43 + }} 44 + } 45 + 46 + # delete an entry by id (get the id from `main list`) 47 + def "main delete" [id: string] { 48 + let entries = (get-entries) 49 + let filtered = ($entries | where id != $id) 50 + if ($filtered | length) == ($entries | length) { 51 + error make {msg: $"no entry with id '($id)'"} 52 + } 53 + put-entries $filtered 54 + print $"deleted ($id)" 55 + } 56 + 57 + def main [] { 58 + print "usage:" 59 + print " nu moderate.nu list" 60 + print " nu moderate.nu delete <id>" 61 + }
+6
package.json
··· 1 + { 2 + "scripts": { 3 + "build": "tsc", 4 + "check": "tsc --noEmit" 5 + } 6 + }
pfp-frame.webp

This is a binary file and will not be displayed.

pfp.webp

This is a binary file and will not be displayed.

+40
prompt-chrome.js
··· 1 + (function () { 2 + var days = [ 3 + "Sunday", 4 + "Monday", 5 + "Tuesday", 6 + "Wednesday", 7 + "Thursday", 8 + "Friday", 9 + "Saturday", 10 + ]; 11 + var months = [ 12 + "Jan", 13 + "Feb", 14 + "Mar", 15 + "Apr", 16 + "May", 17 + "Jun", 18 + "Jul", 19 + "Aug", 20 + "Sep", 21 + "Oct", 22 + "Nov", 23 + "Dec", 24 + ]; 25 + function tick() { 26 + var n = new Date(); 27 + var p = function (v) { 28 + return String(v).padStart(2, "0"); 29 + }; 30 + document.getElementById("clock").textContent = 31 + p(n.getHours()) + ":" + p(n.getMinutes()) + ":" + p(n.getSeconds()); 32 + document.getElementById("datestamp").textContent = 33 + n.getDate() + " " + months[n.getMonth()] + ", " + days[n.getDay()]; 34 + } 35 + tick(); 36 + setInterval(tick, 1000); 37 + var ms = Math.round(performance.now()); 38 + document.getElementById("loadtime").textContent = 39 + ms >= 1000 ? Math.floor(ms / 1000) + "s " + (ms % 1000) + "ms" : ms + "ms"; 40 + })();
+2
robots.txt
··· 1 + User-agent: * 2 + Disallow:
+110
stuff.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>my stuff - weegs.dev</title> 7 + <link rel="icon" href="favicon.webp" type="image/webp" /> 8 + <link rel="stylesheet" href="eldritch.css" /> 9 + <link rel="stylesheet" href="style.css" /> 10 + </head> 11 + <body> 12 + <div class="editor"> 13 + <div class="prompt-chrome"> 14 + <div class="prompt-l1"> 15 + <nav class="powerline-nav"> 16 + <a class="nav-seg nav-seg-logo" href="/">weegs.dev</a> 17 + <a class="nav-seg" href="/">home</a> 18 + <a class="nav-seg" href="gaming.html">gaming</a> 19 + <a class="nav-seg active" href="stuff.html">my stuff</a> 20 + </nav> 21 + <div class="r-segments"> 22 + <span class="r-status">λ</span 23 + ><span class="r-exectime" 24 + ><span class="r-icon">&#xF254;</span 25 + ><span id="loadtime">...</span></span 26 + > 27 + </div> 28 + </div> 29 + <div class="prompt-l2"> 30 + <span class="p-frame">╭─</span> 31 + <span class="p-time" id="clock">--:--:--</span> 32 + <span class="p-sep"> | </span> 33 + <span class="p-date" id="datestamp"></span> 34 + <span class="p-sep"> | </span><span class="p-icon">&#xF07B;</span 35 + ><span class="p-sep"> in </span> 36 + <span class="p-cwd">~/stuff</span> 37 + </div> 38 + <div class="prompt-l3"> 39 + <span 40 + ><span class="p-frame">╰─</span><span class="p-arrow">❯</span></span 41 + > 42 + </div> 43 + </div> 44 + 45 + <main class="content"> 46 + <div class="content-inner"> 47 + <div class="tui-session"> 48 + <div class="tui-prompt-line"> 49 + <span class="p-arrow">❯</span 50 + ><span class="cmd"> ls ~/stuff/</span> 51 + </div> 52 + <div class="tui-workspace"> 53 + <div class="tui-list"> 54 + <div class="tui-list-header">~/stuff/</div> 55 + <div 56 + class="tui-item" 57 + data-type="file" 58 + data-path="content/projects.md" 59 + data-cmd="open content/projects.md" 60 + > 61 + projects 62 + </div> 63 + <div 64 + class="tui-item tui-item-folder" 65 + data-type="folder" 66 + data-path="content/blog/" 67 + > 68 + <span class="folder-arrow">+</span> blog 69 + </div> 70 + <div 71 + class="tui-item tui-item-folder" 72 + data-type="folder" 73 + data-path="content/rants/" 74 + > 75 + <span class="folder-arrow">+</span> rants 76 + </div> 77 + <div 78 + class="tui-item" 79 + data-type="guestbook" 80 + data-cmd="http get /api/guestbook" 81 + > 82 + guestbook 83 + </div> 84 + <div class="tui-list-hint"> 85 + <span class="key">j</span><span class="key">k</span> move 86 + &nbsp; <span class="key">l</span 87 + ><span class="key">↵</span> open &nbsp; 88 + <span class="key">h</span><span class="key">esc</span> back 89 + </div> 90 + </div> 91 + <div class="tui-output-wrap"> 92 + <div class="tui-cmd-line" id="tui-cmd-line"> 93 + <span class="p-arrow">❯</span> 94 + <span class="cmd" id="tui-cmd-text"></span> 95 + </div> 96 + <div id="tui-output"></div> 97 + <div class="tui-empty-hint" id="tui-empty-hint"> 98 + select a section 99 + </div> 100 + </div> 101 + </div> 102 + </div> 103 + </div> 104 + </main> 105 + </div> 106 + 107 + <script src="prompt-chrome.js"></script> 108 + <script type="module" src="tui.js"></script> 109 + </body> 110 + </html>
+1141
style.css
··· 1 + /* self-hosted fonts -- no external request, served from Cloudflare CDN */ 2 + @font-face { 3 + font-family: "NerdSymbols"; 4 + src: url("./fonts/NerdSymbols.woff2") format("woff2"); 5 + font-weight: 400; 6 + font-style: normal; 7 + font-display: swap; 8 + /* only fetched when a page actually contains these glyphs */ 9 + unicode-range: U+F07B, U+F254; 10 + } 11 + 12 + @font-face { 13 + font-family: "JetBrains Mono"; 14 + src: url("./fonts/JetBrainsMono.woff2") format("woff2"); 15 + font-weight: 100 800; 16 + font-style: normal; 17 + font-display: swap; 18 + } 19 + 20 + @font-face { 21 + font-family: "JetBrains Mono"; 22 + src: url("./fonts/JetBrainsMono-Italic.woff2") format("woff2"); 23 + font-weight: 100 800; 24 + font-style: italic; 25 + font-display: swap; 26 + } 27 + 28 + *, 29 + *::before, 30 + *::after { 31 + box-sizing: border-box; 32 + margin: 0; 33 + padding: 0; 34 + } 35 + html, 36 + body { 37 + height: 100%; 38 + } 39 + 40 + body { 41 + background: var(--e-bg-dark); 42 + color: var(--text); 43 + font-family: "JetBrains Mono", "NerdSymbols", monospace; 44 + min-height: 100vh; 45 + display: flex; 46 + align-items: flex-start; 47 + justify-content: center; 48 + padding: 5vh 10vw; 49 + } 50 + 51 + .editor { 52 + width: 100%; 53 + max-width: none; 54 + height: 90vh; 55 + display: flex; 56 + flex-direction: column; 57 + background: var(--bg-surface); 58 + border-radius: 10px; 59 + border: 1px solid var(--e-green); 60 + /* shadow matches hyprland: range=8 render_power=4 (tight, focused falloff) */ 61 + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.7); 62 + overflow: hidden; 63 + } 64 + 65 + .prompt-chrome { 66 + background: var(--bg-surface); 67 + border-bottom: 1px solid var(--border); 68 + flex-shrink: 0; 69 + padding-top: 5px; 70 + } 71 + 72 + .prompt-l1 { 73 + height: 20px; 74 + display: flex; 75 + align-items: stretch; 76 + justify-content: space-between; 77 + } 78 + 79 + .powerline-nav { 80 + display: flex; 81 + align-items: stretch; 82 + padding-left: 18px; 83 + } 84 + 85 + .nav-seg { 86 + position: relative; 87 + display: inline-flex; 88 + align-items: center; 89 + padding: 0 12px 0 10px; 90 + font-size: 0.78rem; 91 + font-family: inherit; 92 + text-decoration: none; 93 + white-space: nowrap; 94 + --seg-bg: #80638e; 95 + background: var(--seg-bg); 96 + color: #000000; 97 + transition: color 0.15s; 98 + } 99 + 100 + .nav-seg::after { 101 + content: ""; 102 + position: absolute; 103 + right: -12px; 104 + top: 0; 105 + width: 0; 106 + height: 0; 107 + border-top: 10px solid transparent; 108 + border-bottom: 10px solid transparent; 109 + border-left: 12px solid var(--seg-bg); 110 + } 111 + 112 + /* extra left padding so text clears the previous segment's arrow */ 113 + .nav-seg + .nav-seg { 114 + padding-left: 18px; 115 + } 116 + 117 + /* decreasing z-index ensures each segment's arrow paints over the next segment's background */ 118 + .powerline-nav .nav-seg:nth-child(1) { 119 + z-index: 15; 120 + } 121 + .powerline-nav .nav-seg:nth-child(2) { 122 + z-index: 14; 123 + } 124 + .powerline-nav .nav-seg:nth-child(3) { 125 + z-index: 13; 126 + } 127 + .powerline-nav .nav-seg:nth-child(4) { 128 + z-index: 12; 129 + } 130 + 131 + .nav-seg-logo { 132 + --seg-bg: #2d5b58; 133 + color: #e8f6f5; 134 + font-weight: 700; 135 + } 136 + 137 + /* leading diamond cap matching OMP OS segment leading_diamond */ 138 + .nav-seg-logo::before { 139 + content: ""; 140 + position: absolute; 141 + left: -12px; 142 + top: 0; 143 + width: 0; 144 + height: 0; 145 + border-top: 10px solid transparent; 146 + border-bottom: 10px solid transparent; 147 + border-right: 12px solid #2d5b58; 148 + } 149 + 150 + .nav-seg.active { 151 + --seg-bg: #6cc644; 152 + background: var(--seg-bg); 153 + color: #000000; 154 + font-weight: 500; 155 + } 156 + 157 + /* hover turns the whole segment shape (body + trailing arrow) yellow via --seg-bg */ 158 + .nav-seg:not(.nav-seg-logo):not(.active):hover { 159 + --seg-bg: var(--e-yellow); 160 + background: var(--seg-bg); 161 + color: #000000; 162 + } 163 + 164 + .r-segments { 165 + display: flex; 166 + align-items: stretch; 167 + padding-right: 14px; 168 + } 169 + 170 + /* OMP status: background=#6cc644 foreground=#000000 (no eldritch equivalent) */ 171 + .r-status { 172 + display: inline-flex; 173 + align-items: center; 174 + padding: 0 10px; 175 + background: #6cc644; 176 + color: #000000; 177 + font-size: 0.78rem; 178 + font-weight: 700; 179 + border-radius: 10px 0 0 10px; 180 + } 181 + 182 + /* OMP exec time: background=#2d5b58 foreground=#e8f6f5 (no eldritch equivalent) */ 183 + .r-exectime { 184 + display: inline-flex; 185 + align-items: center; 186 + gap: 6px; 187 + padding: 0 10px; 188 + background: #2d5b58; 189 + color: #e8f6f5; 190 + font-size: 0.78rem; 191 + border-radius: 0 10px 10px 0; 192 + } 193 + 194 + .r-icon { 195 + font-size: 0.9rem; 196 + } 197 + 198 + /* line-height: 1 so box-drawing chars connect across lines */ 199 + .prompt-l2, 200 + .prompt-l3 { 201 + padding: 0 16px; 202 + font-size: 0.8rem; 203 + line-height: 1; 204 + white-space: nowrap; 205 + } 206 + 207 + .prompt-l2 { 208 + padding-top: 5px; 209 + } 210 + .prompt-l3 { 211 + padding-bottom: 8px; 212 + } 213 + 214 + .p-frame { 215 + color: var(--text-subtle); 216 + } 217 + .p-time { 218 + color: var(--e-yellow); 219 + } 220 + .p-sep { 221 + color: var(--text-subtle); 222 + } 223 + .p-date { 224 + color: var(--e-purple); 225 + } 226 + .p-icon { 227 + color: var(--e-cyan); 228 + font-size: 1rem; 229 + margin-right: 2px; 230 + } 231 + .p-cwd { 232 + color: var(--e-cyan); 233 + } 234 + /* larger font-weight so ❯ reads clearly at small size */ 235 + .p-arrow { 236 + color: var(--e-green); 237 + font-weight: 700; 238 + margin-left: 4px; 239 + } 240 + 241 + .content { 242 + flex: 1; 243 + min-height: 0; 244 + padding: 24px 32px; 245 + display: flex; 246 + flex-direction: column; 247 + overflow: hidden; 248 + } 249 + 250 + .content-inner { 251 + flex: 1; 252 + min-height: 0; 253 + display: flex; 254 + flex-direction: column; 255 + } 256 + 257 + .panel { 258 + border: 1px solid var(--border); 259 + background: var(--bg); 260 + padding: 24px; 261 + margin-bottom: 24px; 262 + position: relative; 263 + } 264 + 265 + /* floated label sits in the border top, background must match content area */ 266 + .panel-label { 267 + position: absolute; 268 + top: -0.65em; 269 + left: 14px; 270 + padding: 0 8px; 271 + background: var(--bg); 272 + color: var(--e-cyan); 273 + font-size: 0.72rem; 274 + letter-spacing: 0.04em; 275 + } 276 + 277 + .page-header { 278 + margin-bottom: 32px; 279 + } 280 + 281 + .page-header h1 { 282 + font-size: 1.4rem; 283 + color: var(--e-green); 284 + margin-bottom: 6px; 285 + } 286 + 287 + .prompt { 288 + color: var(--text-subtle); 289 + font-size: 0.85rem; 290 + } 291 + .prompt .cmd { 292 + color: var(--e-cyan); 293 + } 294 + 295 + p { 296 + color: var(--text-muted); 297 + line-height: 1.7; 298 + font-size: 0.9rem; 299 + margin-bottom: 12px; 300 + } 301 + p:last-child { 302 + margin-bottom: 0; 303 + } 304 + 305 + a { 306 + color: var(--link); 307 + text-decoration: none; 308 + } 309 + a:hover { 310 + color: var(--link-hover); 311 + } 312 + 313 + /* ─── TUI navigation system ─── */ 314 + 315 + .tui-session { 316 + display: flex; 317 + flex-direction: column; 318 + flex: 1; 319 + min-height: 0; 320 + } 321 + 322 + .tui-prompt-line { 323 + margin-bottom: 10px; 324 + font-size: 0.85rem; 325 + } 326 + 327 + .tui-workspace { 328 + display: flex; 329 + align-items: stretch; 330 + flex: 1; 331 + min-height: 0; 332 + } 333 + 334 + /* list pane */ 335 + .tui-list { 336 + width: 300px; 337 + flex-shrink: 0; 338 + border: 1px solid var(--border); 339 + background: var(--bg-surface); 340 + display: flex; 341 + flex-direction: column; 342 + } 343 + 344 + .tui-list-header { 345 + padding: 5px 12px; 346 + font-size: 0.72rem; 347 + color: var(--e-cyan); 348 + letter-spacing: 0.04em; 349 + border-bottom: 1px solid var(--border); 350 + flex-shrink: 0; 351 + } 352 + 353 + .tui-item { 354 + display: flex; 355 + align-items: center; 356 + padding: 5px 12px 5px 10px; 357 + font-size: 0.85rem; 358 + color: var(--text-muted); 359 + cursor: pointer; 360 + user-select: none; 361 + white-space: nowrap; 362 + } 363 + 364 + .tui-item::before { 365 + content: " "; 366 + width: 2ch; 367 + display: inline-block; 368 + flex-shrink: 0; 369 + } 370 + 371 + .tui-item.focused { 372 + background: var(--bg-elevated); 373 + color: var(--e-green); 374 + } 375 + 376 + .tui-item.focused::before { 377 + content: "▶ "; 378 + } 379 + 380 + .tui-item:hover:not(.focused) { 381 + background: var(--bg-elevated); 382 + color: var(--text); 383 + } 384 + 385 + .tui-list-hint { 386 + margin-top: auto; 387 + padding: 8px 10px; 388 + font-size: 0.7rem; 389 + color: var(--text-subtle); 390 + border-top: 1px solid var(--border); 391 + } 392 + 393 + .key { 394 + background: var(--bg-elevated); 395 + border: 1px solid var(--border); 396 + color: var(--e-fg-dim); 397 + padding: 0 4px; 398 + border-radius: 3px; 399 + font-size: 0.7rem; 400 + } 401 + 402 + /* output pane */ 403 + .tui-output-wrap { 404 + flex: 1; 405 + border: 1px solid var(--border); 406 + border-left: none; 407 + background: var(--bg); 408 + display: flex; 409 + flex-direction: column; 410 + overflow: hidden; 411 + } 412 + 413 + .tui-cmd-line { 414 + padding: 5px 14px; 415 + font-size: 0.85rem; 416 + border-bottom: 1px solid var(--border); 417 + flex-shrink: 0; 418 + visibility: hidden; 419 + background: var(--bg-surface); 420 + } 421 + 422 + #tui-output { 423 + padding: 16px 20px; 424 + overflow-y: auto; 425 + flex: 1; 426 + } 427 + 428 + .tui-empty-hint { 429 + flex: 1; 430 + display: flex; 431 + align-items: center; 432 + justify-content: center; 433 + color: var(--text-subtle); 434 + font-size: 0.8rem; 435 + user-select: none; 436 + } 437 + 438 + /* nushell-style table */ 439 + .nu-table { 440 + width: 100%; 441 + border-collapse: separate; 442 + border-spacing: 0; 443 + border: 1px solid var(--border); 444 + border-radius: 4px; 445 + overflow: hidden; 446 + font-size: 0.82rem; 447 + margin-bottom: 16px; 448 + } 449 + 450 + .nu-table th { 451 + background: var(--bg-surface); 452 + color: var(--e-cyan); 453 + font-weight: 700; 454 + padding: 4px 14px; 455 + text-align: left; 456 + border-bottom: 1px solid var(--border); 457 + white-space: nowrap; 458 + } 459 + 460 + .nu-table th.col-idx { 461 + text-align: right; 462 + } 463 + 464 + .nu-table td { 465 + padding: 3px 14px; 466 + border-bottom: 1px solid var(--bg-elevated); 467 + white-space: nowrap; 468 + } 469 + 470 + .nu-table tbody tr:last-child td { 471 + border-bottom: none; 472 + } 473 + 474 + .nu-table tbody tr:nth-child(even) td { 475 + background: var(--bg-elevated); 476 + } 477 + 478 + .col-idx { 479 + color: var(--e-comment); 480 + text-align: right; 481 + padding-right: 10px !important; 482 + width: 3ch; 483 + } 484 + 485 + .col-str { 486 + color: var(--e-fg); 487 + } 488 + 489 + .col-num { 490 + color: var(--e-purple); 491 + } 492 + 493 + .col-date { 494 + color: var(--e-yellow); 495 + } 496 + 497 + .col-path { 498 + color: var(--e-green); 499 + } 500 + 501 + /* status tags */ 502 + .tag { 503 + font-size: 0.8em; 504 + } 505 + 506 + .tag-playing { 507 + color: var(--e-green); 508 + } 509 + 510 + .tag-finished { 511 + color: var(--e-cyan); 512 + } 513 + 514 + .tag-dropped { 515 + color: var(--e-red); 516 + } 517 + 518 + .tag-paused { 519 + color: var(--e-yellow); 520 + } 521 + 522 + .tag-active { 523 + color: var(--e-green); 524 + } 525 + 526 + .tag-wip { 527 + color: var(--e-yellow); 528 + } 529 + 530 + .tag-done { 531 + color: var(--e-cyan); 532 + } 533 + 534 + .tag-deferred { 535 + color: var(--e-comment); 536 + } 537 + 538 + /* tui content block */ 539 + .tui-block { 540 + border: 1px solid var(--border); 541 + background: var(--bg-surface); 542 + margin-bottom: 16px; 543 + overflow: hidden; 544 + } 545 + 546 + .tui-block-title { 547 + padding: 4px 14px; 548 + font-size: 0.72rem; 549 + color: var(--e-cyan); 550 + letter-spacing: 0.04em; 551 + border-bottom: 1px solid var(--border); 552 + } 553 + 554 + .tui-block-body { 555 + padding: 14px 16px; 556 + } 557 + 558 + .tui-text { 559 + color: var(--text-muted); 560 + line-height: 1.7; 561 + font-size: 0.85rem; 562 + margin-bottom: 6px; 563 + } 564 + 565 + .tui-text:last-child { 566 + margin-bottom: 0; 567 + } 568 + 569 + /* left panel scrolls independently without affecting right pane or page */ 570 + .tui-list { 571 + overflow-y: auto; 572 + } 573 + 574 + /* folder +/- sits after the ::before focus cursor, inherits item color */ 575 + .tui-item-folder .folder-arrow { 576 + margin-right: 5px; 577 + flex-shrink: 0; 578 + } 579 + 580 + /* child items: indented, slightly smaller */ 581 + .tui-item-child { 582 + padding-left: 28px; 583 + font-size: 0.82rem; 584 + } 585 + 586 + .child-date { 587 + color: var(--e-comment); 588 + font-size: 0.75rem; 589 + margin-right: 8px; 590 + } 591 + 592 + /* ─── Markdown renderer styles ─── */ 593 + 594 + .md-h1 { 595 + color: var(--e-cyan); 596 + font-weight: 700; 597 + font-size: 0.95rem; 598 + margin-bottom: 8px; 599 + margin-top: 14px; 600 + } 601 + 602 + .md-h1:first-child { 603 + margin-top: 0; 604 + } 605 + 606 + .md-h2 { 607 + color: var(--e-green); 608 + font-weight: 600; 609 + font-size: 0.9rem; 610 + margin-bottom: 6px; 611 + margin-top: 12px; 612 + } 613 + 614 + .md-h3 { 615 + color: var(--e-yellow); 616 + font-weight: 600; 617 + font-size: 0.88rem; 618 + margin-bottom: 4px; 619 + margin-top: 10px; 620 + } 621 + 622 + .md-hash { 623 + opacity: 0.45; 624 + } 625 + 626 + .md-p { 627 + color: var(--text-muted); 628 + line-height: 1.7; 629 + font-size: 0.85rem; 630 + margin-bottom: 4px; 631 + } 632 + 633 + .md-li { 634 + color: var(--text-muted); 635 + font-size: 0.85rem; 636 + margin-bottom: 2px; 637 + display: flex; 638 + align-items: baseline; 639 + } 640 + 641 + .md-bullet { 642 + color: var(--e-cyan); 643 + margin-right: 8px; 644 + flex-shrink: 0; 645 + } 646 + 647 + .md-li-nested { 648 + padding-left: 20px; 649 + } 650 + 651 + .md-codeblock { 652 + border: 1px solid var(--border); 653 + background: var(--bg-surface); 654 + margin: 8px 0; 655 + overflow-x: auto; 656 + } 657 + 658 + .code-lang { 659 + padding: 2px 12px; 660 + font-size: 0.72rem; 661 + color: var(--e-comment); 662 + border-bottom: 1px solid var(--border); 663 + } 664 + 665 + .code-pre { 666 + margin: 0; 667 + padding: 10px 14px; 668 + font-size: 0.82rem; 669 + color: var(--e-fg); 670 + white-space: pre; 671 + overflow-x: auto; 672 + } 673 + 674 + .md-code { 675 + color: var(--e-yellow); 676 + background: var(--bg-elevated); 677 + padding: 0 4px; 678 + border-radius: 2px; 679 + } 680 + 681 + .md-spacer { 682 + height: 6px; 683 + } 684 + 685 + .md-hr { 686 + border: none; 687 + border-top: 1px solid var(--border); 688 + margin: 12px 0; 689 + } 690 + 691 + .md-loading { 692 + color: var(--text-subtle); 693 + font-size: 0.85rem; 694 + padding: 4px 0; 695 + } 696 + 697 + .md-error { 698 + color: var(--e-red); 699 + font-size: 0.85rem; 700 + padding: 4px 0; 701 + } 702 + 703 + /* ─── Guestbook ─── */ 704 + 705 + /* message column: wraps unlike other nu-table cells */ 706 + .gb-table .col-msg { 707 + white-space: pre-wrap; 708 + word-break: break-word; 709 + max-width: 380px; 710 + } 711 + 712 + .gb-from a { 713 + color: var(--e-green); 714 + text-decoration: none; 715 + } 716 + 717 + .gb-from a:hover { 718 + color: var(--e-cyan); 719 + } 720 + 721 + .gb-empty { 722 + color: var(--text-subtle); 723 + font-style: italic; 724 + margin-bottom: 16px; 725 + } 726 + 727 + .gb-form-wrap { 728 + margin-top: 20px; 729 + } 730 + 731 + .gb-form-header { 732 + color: var(--text-subtle); 733 + font-size: 0.82rem; 734 + margin-bottom: 10px; 735 + } 736 + 737 + .gb-hp { 738 + position: absolute; 739 + left: -9999px; 740 + opacity: 0; 741 + pointer-events: none; 742 + } 743 + 744 + .gb-form { 745 + border: 1px solid var(--border); 746 + background: var(--bg-surface); 747 + padding: 12px 16px; 748 + display: flex; 749 + flex-direction: column; 750 + gap: 9px; 751 + } 752 + 753 + .gb-field { 754 + display: flex; 755 + align-items: center; 756 + gap: 14px; 757 + } 758 + 759 + .gb-field-msg { 760 + align-items: flex-start; 761 + } 762 + 763 + .gb-label { 764 + color: var(--e-comment); 765 + font-size: 0.8rem; 766 + width: 4ch; 767 + flex-shrink: 0; 768 + padding-top: 3px; 769 + } 770 + 771 + .gb-input { 772 + flex: 1; 773 + background: transparent; 774 + border: none; 775 + border-bottom: 1px solid var(--border); 776 + color: var(--e-fg); 777 + font-family: inherit; 778 + font-size: 0.82rem; 779 + padding: 2px 4px; 780 + outline: none; 781 + transition: border-color 0.15s; 782 + } 783 + 784 + .gb-input::placeholder { 785 + color: var(--text-subtle); 786 + } 787 + 788 + .gb-input:focus { 789 + border-bottom-color: var(--e-cyan); 790 + } 791 + 792 + .gb-textarea { 793 + resize: vertical; 794 + min-height: 62px; 795 + line-height: 1.5; 796 + } 797 + 798 + .gb-actions { 799 + display: flex; 800 + align-items: center; 801 + gap: 16px; 802 + padding-top: 4px; 803 + margin-left: calc(4ch + 14px); 804 + } 805 + 806 + .gb-btn { 807 + background: none; 808 + border: none; 809 + color: var(--e-green); 810 + font-family: inherit; 811 + font-size: 0.82rem; 812 + cursor: pointer; 813 + padding: 0; 814 + transition: color 0.15s; 815 + } 816 + 817 + .gb-btn:hover { 818 + color: var(--e-cyan); 819 + } 820 + 821 + .gb-status { 822 + color: var(--e-red); 823 + font-size: 0.8rem; 824 + } 825 + 826 + /* ─── Nix file viewer ─── */ 827 + 828 + /* removes output pane padding so nix-view fills edge to edge */ 829 + #tui-output.nix-output { 830 + padding: 0; 831 + } 832 + 833 + .nix-view { 834 + display: flex; 835 + flex-direction: column; 836 + height: 100%; 837 + background: var(--e-bg-dark); 838 + font-size: 0.82rem; 839 + } 840 + 841 + .nix-file-header { 842 + padding: 4px 16px; 843 + font-size: 0.75rem; 844 + color: var(--e-comment); 845 + border-bottom: 1px solid var(--border); 846 + background: var(--e-bg-raised); 847 + flex-shrink: 0; 848 + position: sticky; 849 + top: 0; 850 + z-index: 1; 851 + } 852 + 853 + .nix-body { 854 + overflow-x: auto; 855 + padding: 10px 0; 856 + } 857 + 858 + .nix-line { 859 + display: flex; 860 + align-items: baseline; 861 + line-height: 1.5; 862 + } 863 + 864 + .nix-line:hover { 865 + background: rgba(255, 255, 255, 0.03); 866 + } 867 + 868 + .nix-ln { 869 + min-width: 3ch; 870 + text-align: right; 871 + padding: 0 16px 0 12px; 872 + color: var(--e-comment); 873 + opacity: 0.5; 874 + user-select: none; 875 + flex-shrink: 0; 876 + } 877 + 878 + .nix-code { 879 + white-space: pre; 880 + color: var(--e-fg); 881 + padding-right: 24px; 882 + } 883 + 884 + /* nix token colors -- eldritch palette */ 885 + .nx-kw { 886 + color: var(--e-purple); 887 + } 888 + .nx-ns { 889 + color: var(--e-purple); 890 + } 891 + .nx-bool { 892 + color: var(--e-yellow); 893 + } 894 + .nx-null { 895 + color: var(--e-red); 896 + } 897 + .nx-str { 898 + color: var(--e-green); 899 + } 900 + .nx-interp { 901 + color: var(--e-cyan); 902 + } 903 + .nx-comment { 904 + color: var(--e-comment); 905 + font-style: italic; 906 + } 907 + .nx-num { 908 + color: var(--e-cyan); 909 + } 910 + .nx-path { 911 + color: var(--e-green-dark); 912 + } 913 + 914 + /* mobile: fullscreen window, hide chrome that won't fit */ 915 + @media (max-width: 768px) { 916 + body { 917 + padding: 0; 918 + } 919 + 920 + .editor { 921 + width: 100%; 922 + height: 100svh; 923 + border-radius: 0; 924 + border-left: none; 925 + border-right: none; 926 + } 927 + 928 + /* right pill doesn't fit on narrow nav */ 929 + .r-segments { 930 + display: none; 931 + } 932 + 933 + .nav-seg { 934 + font-size: 0.7rem; 935 + } 936 + 937 + .content { 938 + padding: 16px 20px; 939 + } 940 + } 941 + 942 + /* responsive: stack tui workspace on narrow screens */ 943 + @media (max-width: 620px) { 944 + .tui-workspace { 945 + flex-direction: column; 946 + } 947 + 948 + .tui-list { 949 + width: 100%; 950 + flex-shrink: 0; 951 + } 952 + 953 + .tui-output-wrap { 954 + border-left: 1px solid var(--border); 955 + border-top: none; 956 + flex: 1; 957 + min-height: 0; 958 + } 959 + 960 + .content { 961 + padding: 12px 16px; 962 + } 963 + 964 + /* keyboard shortcuts don't apply on touch */ 965 + .tui-list-hint { 966 + display: none; 967 + } 968 + 969 + /* 44px minimum touch target */ 970 + .tui-item { 971 + padding: 12px 12px 12px 10px; 972 + } 973 + } 974 + 975 + /* ─── Home page layout ─── */ 976 + 977 + .home-layout { 978 + display: flex; 979 + flex-direction: column; 980 + } 981 + 982 + .home-speech { 983 + position: relative; 984 + border: 1px solid var(--border); 985 + background: var(--bg-surface); 986 + padding: 10px 14px; 987 + margin-bottom: 20px; 988 + } 989 + 990 + /* outer border of speech bubble tail -- points at avatar center (70px from layout left) */ 991 + .home-speech::after { 992 + content: ""; 993 + position: absolute; 994 + bottom: -10px; 995 + left: 61px; 996 + border-left: 9px solid transparent; 997 + border-right: 9px solid transparent; 998 + border-top: 9px solid var(--border); 999 + } 1000 + 1001 + /* inner fill of speech bubble tail */ 1002 + .home-speech::before { 1003 + content: ""; 1004 + position: absolute; 1005 + bottom: -8px; 1006 + left: 62px; 1007 + z-index: 1; 1008 + border-left: 8px solid transparent; 1009 + border-right: 8px solid transparent; 1010 + border-top: 8px solid var(--bg-surface); 1011 + } 1012 + 1013 + .home-ff { 1014 + display: flex; 1015 + align-items: flex-start; 1016 + gap: 28px; 1017 + } 1018 + 1019 + .home-speech-inner .md-p, 1020 + .home-speech-inner .md-loading, 1021 + .home-speech-inner .md-error { 1022 + font-size: 0.78rem; 1023 + line-height: 1.55; 1024 + margin-bottom: 2px; 1025 + } 1026 + 1027 + .home-avatar-wrap { 1028 + position: relative; 1029 + width: 140px; 1030 + height: 140px; 1031 + } 1032 + 1033 + .home-avatar { 1034 + position: absolute; 1035 + top: 50%; 1036 + left: 50%; 1037 + transform: translate(-50%, -50%); 1038 + width: 100px; 1039 + height: 100px; 1040 + border-radius: 50%; 1041 + } 1042 + 1043 + .home-frame { 1044 + position: absolute; 1045 + top: 0; 1046 + left: 0; 1047 + width: 140px; 1048 + height: 140px; 1049 + pointer-events: none; 1050 + } 1051 + 1052 + /* info panel */ 1053 + .home-panel { 1054 + flex: 1; 1055 + min-width: 0; 1056 + font-size: 0.82rem; 1057 + padding-top: 2px; 1058 + } 1059 + 1060 + .ff-title { 1061 + font-size: 0.9rem; 1062 + font-weight: 700; 1063 + color: var(--e-fg); 1064 + margin-bottom: 3px; 1065 + } 1066 + 1067 + .ff-at { 1068 + color: var(--e-comment); 1069 + } 1070 + 1071 + .ff-sep-line { 1072 + color: var(--border); 1073 + margin-bottom: 10px; 1074 + letter-spacing: -0.5px; 1075 + } 1076 + 1077 + .ff-map-row { 1078 + display: flex; 1079 + align-items: baseline; 1080 + gap: 6px; 1081 + line-height: 1.75; 1082 + } 1083 + 1084 + .ff-tree { 1085 + color: var(--e-comment); 1086 + flex-shrink: 0; 1087 + } 1088 + 1089 + .ff-site-link { 1090 + color: var(--e-cyan); 1091 + text-decoration: none; 1092 + font-weight: 500; 1093 + flex-shrink: 0; 1094 + } 1095 + 1096 + .ff-site-link:hover { 1097 + color: var(--e-cyan-bright); 1098 + } 1099 + 1100 + .ff-comment { 1101 + color: var(--e-comment); 1102 + font-size: 0.78rem; 1103 + white-space: nowrap; 1104 + overflow: hidden; 1105 + text-overflow: ellipsis; 1106 + } 1107 + 1108 + .ff-section-label { 1109 + color: var(--e-yellow); 1110 + font-size: 0.72rem; 1111 + font-weight: 700; 1112 + letter-spacing: 0.08em; 1113 + margin-top: 12px; 1114 + margin-bottom: 4px; 1115 + } 1116 + 1117 + .ff-label { 1118 + color: var(--e-purple); 1119 + width: 9ch; 1120 + flex-shrink: 0; 1121 + } 1122 + 1123 + .ff-contact-link { 1124 + color: var(--e-cyan); 1125 + text-decoration: none; 1126 + } 1127 + 1128 + .ff-contact-link:hover { 1129 + color: var(--e-cyan-bright); 1130 + } 1131 + 1132 + @media (max-width: 620px) { 1133 + .home-ff { 1134 + flex-direction: column; 1135 + align-items: center; 1136 + } 1137 + 1138 + .home-panel { 1139 + width: 100%; 1140 + } 1141 + }
+15
tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2020", 4 + "lib": ["ES2020", "DOM"], 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "strict": true, 8 + "outDir": ".", 9 + "rootDir": ".", 10 + "declaration": false, 11 + "sourceMap": false, 12 + "noEmitOnError": true 13 + }, 14 + "files": ["tui.ts"] 15 + }
+771
tui.js
··· 1 + const state = { items: [], focused: 0, open: false }; 2 + let isHomePage = false; 3 + function getType(el) { 4 + return el.dataset["type"] ?? "static"; 5 + } 6 + function rebuildItems() { 7 + state.items = Array.from(document.querySelectorAll(".tui-list .tui-item")); 8 + } 9 + function init() { 10 + rebuildItems(); 11 + isHomePage = !!document.querySelector('[data-page="home"]'); 12 + if (!state.items.length) { 13 + if (isHomePage) void renderHome(); 14 + return; 15 + } 16 + const output = document.getElementById("tui-output"); 17 + if (output && !isHomePage) output.style.display = "none"; 18 + setFocus(0); 19 + document.addEventListener("keydown", onKey); 20 + // event delegation handles dynamically inserted child items 21 + const list = document.querySelector(".tui-list"); 22 + list?.addEventListener("click", (e) => { 23 + const item = e.target.closest(".tui-item"); 24 + if (!item) return; 25 + const idx = state.items.indexOf(item); 26 + if (idx === -1) return; 27 + if (idx === state.focused && state.open && getType(item) !== "folder") { 28 + // closes output for touch devices that lack keyboard back-navigation 29 + back(); 30 + } else { 31 + setFocus(idx); 32 + select(); 33 + } 34 + }); 35 + if (isHomePage) { 36 + void renderHome(); 37 + } else { 38 + // preview first item: show content without locking j/k into scroll mode 39 + void previewFirst(); 40 + } 41 + } 42 + async function previewFirst() { 43 + const item = state.items[0]; 44 + if (!item) return; 45 + const type = getType(item); 46 + const output = document.getElementById("tui-output"); 47 + const hint = document.getElementById("tui-empty-hint"); 48 + if (!output) return; 49 + if (type === "file") { 50 + const path = item.dataset["path"] ?? ""; 51 + try { 52 + const res = await fetch(path, { cache: "no-store" }); 53 + if (!res.ok) return; 54 + if (state.focused !== 0) return; // user navigated before fetch completed 55 + const md = await res.text(); 56 + if (state.focused !== 0) return; 57 + if (hint) hint.style.display = "none"; 58 + output.innerHTML = renderMarkdown(stripFrontmatter(md)); 59 + output.style.display = ""; 60 + termRender(output); 61 + } catch { 62 + // silent fail -- user can select explicitly 63 + } 64 + } else if (type === "static") { 65 + const contentId = item.dataset["content"]; 66 + const tmpl = contentId ? document.getElementById(contentId) : null; 67 + if (!tmpl) return; 68 + if (hint) hint.style.display = "none"; 69 + output.innerHTML = ""; 70 + output.appendChild(tmpl.content.cloneNode(true)); 71 + output.style.display = ""; 72 + termRender(output); 73 + } 74 + // guestbook: no auto-preview on load -- requires explicit selection 75 + // state.open stays false -- j/k continues to navigate the list 76 + } 77 + function onKey(e) { 78 + const target = e.target; 79 + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; 80 + switch (e.key) { 81 + case "ArrowUp": 82 + case "k": 83 + e.preventDefault(); 84 + // j/k scroll right pane when open, navigate list when closed 85 + if (state.open) scrollOutput(-60); 86 + else move(-1); 87 + break; 88 + case "ArrowDown": 89 + case "j": 90 + e.preventDefault(); 91 + if (state.open) scrollOutput(60); 92 + else move(1); 93 + break; 94 + case "ArrowRight": 95 + case "l": 96 + case "Enter": 97 + e.preventDefault(); 98 + select(); 99 + break; 100 + case "ArrowLeft": 101 + case "h": 102 + case "Escape": 103 + e.preventDefault(); 104 + back(); 105 + break; 106 + } 107 + } 108 + function scrollOutput(delta) { 109 + document 110 + .getElementById("tui-output") 111 + ?.scrollBy({ top: delta, behavior: "smooth" }); 112 + } 113 + function move(delta) { 114 + const next = 115 + (state.focused + delta + state.items.length) % state.items.length; 116 + setFocus(next); 117 + state.items[next].scrollIntoView({ block: "nearest" }); 118 + } 119 + function setFocus(idx) { 120 + state.items[state.focused]?.classList.remove("focused"); 121 + state.focused = idx; 122 + state.items[idx]?.classList.add("focused"); 123 + } 124 + function select() { 125 + const item = state.items[state.focused]; 126 + if (!item) return; 127 + const type = getType(item); 128 + if (type === "folder") { 129 + void toggleFolder(item); 130 + return; 131 + } 132 + if (type === "file") { 133 + const path = item.dataset["path"] ?? ""; 134 + const cmd = item.dataset["cmd"] ?? `open ${path}`; 135 + void openFile(path, cmd); 136 + return; 137 + } 138 + if (type === "guestbook") { 139 + const cmd = item.dataset["cmd"] ?? "http get /api/guestbook"; 140 + void openGuestbook(cmd); 141 + return; 142 + } 143 + // static: legacy inline template behavior 144 + const contentId = item.dataset["content"]; 145 + const tmpl = contentId ? document.getElementById(contentId) : null; 146 + const output = document.getElementById("tui-output"); 147 + if (!tmpl || !output) return; 148 + const cmdText = document.getElementById("tui-cmd-text"); 149 + const cmdLine = document.getElementById("tui-cmd-line"); 150 + const hint = document.getElementById("tui-empty-hint"); 151 + if (cmdText) cmdText.textContent = item.dataset["cmd"] ?? contentId ?? ""; 152 + if (cmdLine) cmdLine.style.visibility = "visible"; 153 + if (hint) hint.style.display = "none"; 154 + output.innerHTML = ""; 155 + output.appendChild(tmpl.content.cloneNode(true)); 156 + output.style.display = ""; 157 + termRender(output); 158 + state.open = true; 159 + } 160 + async function openFile(path, cmd) { 161 + const output = document.getElementById("tui-output"); 162 + if (!output) return; 163 + const cmdText = document.getElementById("tui-cmd-text"); 164 + const cmdLine = document.getElementById("tui-cmd-line"); 165 + const hint = document.getElementById("tui-empty-hint"); 166 + if (cmdText) cmdText.textContent = cmd; 167 + if (cmdLine) cmdLine.style.visibility = "visible"; 168 + if (hint) hint.style.display = "none"; 169 + output.innerHTML = '<div class="md-loading">loading...</div>'; 170 + output.style.display = ""; 171 + state.open = true; 172 + try { 173 + const res = await fetch(path, { cache: "no-store" }); 174 + if (!res.ok) throw new Error(String(res.status)); 175 + const text = await res.text(); 176 + if (path.endsWith(".nix")) { 177 + const filename = path.split("/").pop() ?? path; 178 + output.classList.add("nix-output"); 179 + output.innerHTML = renderNix(filename, text); 180 + } else { 181 + output.classList.remove("nix-output"); 182 + output.innerHTML = renderMarkdown(stripFrontmatter(text)); 183 + termRender(output); 184 + } 185 + } catch { 186 + output.innerHTML = `<div class="md-error">error: failed to load ${escHtml(path)}</div>`; 187 + } 188 + } 189 + async function toggleFolder(item) { 190 + const path = item.dataset["path"] ?? ""; 191 + const expanded = item.dataset["expanded"] === "true"; 192 + const arrow = item.querySelector(".folder-arrow"); 193 + if (expanded) { 194 + // de-focus current item before removing it if it's a child of this folder 195 + state.items[state.focused]?.classList.remove("focused"); 196 + Array.from(document.querySelectorAll("[data-parent]")) 197 + .filter((el) => el.dataset["parent"] === path) 198 + .forEach((el) => el.remove()); 199 + item.dataset["expanded"] = "false"; 200 + if (arrow) arrow.textContent = "+"; 201 + rebuildItems(); 202 + const folderIdx = state.items.indexOf(item); 203 + state.focused = folderIdx !== -1 ? folderIdx : 0; 204 + state.items[state.focused]?.classList.add("focused"); 205 + if (state.open) back(); 206 + return; 207 + } 208 + item.dataset["expanded"] = "true"; 209 + if (arrow) arrow.textContent = "-"; 210 + const loading = document.createElement("div"); 211 + loading.className = "tui-item tui-item-child"; 212 + loading.dataset["parent"] = path; 213 + loading.textContent = " loading..."; 214 + item.insertAdjacentElement("afterend", loading); 215 + rebuildItems(); 216 + try { 217 + const res = await fetch(`${path}index.json`, { cache: "no-store" }); 218 + if (!res.ok) throw new Error(String(res.status)); 219 + const entries = await res.json(); 220 + loading.remove(); 221 + if (!entries.length) { 222 + const empty = document.createElement("div"); 223 + empty.className = "tui-item tui-item-child"; 224 + empty.dataset["parent"] = path; 225 + empty.textContent = " (empty)"; 226 + item.insertAdjacentElement("afterend", empty); 227 + } else { 228 + let insertAfter = item; 229 + for (const entry of entries) { 230 + const child = document.createElement("div"); 231 + child.className = "tui-item tui-item-child"; 232 + child.dataset["type"] = "file"; 233 + child.dataset["parent"] = path; 234 + child.dataset["path"] = `${path}${entry.slug}`; 235 + child.dataset["cmd"] = `open ${path}${entry.slug}`; 236 + const date = entry.date 237 + ? `<span class="child-date">${escHtml(fmtDate(entry.date))}</span> ` 238 + : ""; 239 + child.innerHTML = `${date}${escHtml(entry.title)}`; 240 + insertAfter.insertAdjacentElement("afterend", child); 241 + insertAfter = child; 242 + } 243 + } 244 + } catch { 245 + loading.remove(); 246 + const err = document.createElement("div"); 247 + err.className = "tui-item tui-item-child"; 248 + err.dataset["parent"] = path; 249 + err.textContent = " error loading index"; 250 + item.insertAdjacentElement("afterend", err); 251 + } 252 + rebuildItems(); 253 + const folderIdx = state.items.indexOf(item); 254 + if (folderIdx !== -1 && folderIdx + 1 < state.items.length) { 255 + state.items[folderIdx]?.classList.remove("focused"); 256 + state.focused = folderIdx + 1; 257 + state.items[state.focused]?.classList.add("focused"); 258 + } 259 + } 260 + async function openGuestbook(cmd) { 261 + const output = document.getElementById("tui-output"); 262 + if (!output) return; 263 + const cmdText = document.getElementById("tui-cmd-text"); 264 + const cmdLine = document.getElementById("tui-cmd-line"); 265 + const hint = document.getElementById("tui-empty-hint"); 266 + if (cmdText) cmdText.textContent = cmd; 267 + if (cmdLine) cmdLine.style.visibility = "visible"; 268 + if (hint) hint.style.display = "none"; 269 + output.innerHTML = '<div class="md-loading tui-line">loading...</div>'; 270 + output.style.display = ""; 271 + state.open = true; 272 + try { 273 + const res = await fetch("/api/guestbook"); 274 + if (!res.ok) throw new Error(String(res.status)); 275 + const data = await res.json(); 276 + output.innerHTML = renderGuestbook(data.entries); 277 + termRender(output); 278 + attachGuestbookForm(output); 279 + } catch { 280 + output.innerHTML = `<div class="md-error">error: failed to load guestbook</div>`; 281 + } 282 + } 283 + function renderGuestbook(entries) { 284 + const tableHtml = 285 + entries.length === 0 286 + ? `<div class="md-p gb-empty tui-line">(no entries yet -- be the first)</div>` 287 + : `<table class="nu-table gb-table"> 288 + <thead><tr> 289 + <th class="col-idx tui-line">#</th> 290 + <th class="tui-line">from</th> 291 + <th class="tui-line">message</th> 292 + <th class="tui-line">date</th> 293 + </tr></thead> 294 + <tbody>${entries 295 + .map((e, i) => { 296 + const fromCell = e.website 297 + ? `<a href="${escHtml(e.website)}" target="_blank" rel="noopener noreferrer">${escHtml(e.name)}</a>` 298 + : escHtml(e.name); 299 + return `<tr> 300 + <td class="col-idx tui-line">${i}</td> 301 + <td class="col-str gb-from tui-line">${fromCell}</td> 302 + <td class="col-msg tui-line">${escHtml(e.message)}</td> 303 + <td class="col-date tui-line">${escHtml(e.date)}</td> 304 + </tr>`; 305 + }) 306 + .join("")}</tbody> 307 + </table>`; 308 + return `${tableHtml} 309 + <div class="gb-form-wrap tui-line"> 310 + <div class="gb-form-header"><span class="p-arrow">❯</span> http post /api/guestbook $entry</div> 311 + <form id="gb-form" class="gb-form" autocomplete="off"> 312 + <input type="text" name="hp" class="gb-hp" tabindex="-1" aria-hidden="true" /> 313 + <div class="gb-field"> 314 + <label class="gb-label">name</label> 315 + <input type="text" name="name" class="gb-input" maxlength="50" required placeholder="you" /> 316 + </div> 317 + <div class="gb-field"> 318 + <label class="gb-label">site</label> 319 + <input type="text" name="website" class="gb-input" maxlength="100" placeholder="optional" /> 320 + </div> 321 + <div class="gb-field gb-field-msg"> 322 + <label class="gb-label">msg</label> 323 + <textarea name="message" class="gb-input gb-textarea" maxlength="500" required placeholder="leave your mark..."></textarea> 324 + </div> 325 + <div class="gb-actions"> 326 + <button type="submit" class="gb-btn">[ submit ]</button> 327 + <span class="gb-status" id="gb-status"></span> 328 + </div> 329 + </form> 330 + </div>`; 331 + } 332 + function attachGuestbookForm(container) { 333 + const form = container.querySelector("#gb-form"); 334 + if (!form) return; 335 + form.addEventListener("submit", async (e) => { 336 + e.preventDefault(); 337 + const status = document.getElementById("gb-status"); 338 + const btn = form.querySelector(".gb-btn"); 339 + const data = new FormData(form); 340 + if (btn) btn.textContent = "[ ... ]"; 341 + if (status) status.textContent = ""; 342 + try { 343 + const res = await fetch("/api/guestbook", { 344 + method: "POST", 345 + headers: { "Content-Type": "application/json" }, 346 + body: JSON.stringify({ 347 + name: data.get("name"), 348 + message: data.get("message"), 349 + website: data.get("website"), 350 + hp: data.get("hp"), 351 + }), 352 + }); 353 + const payload = await res.json(); 354 + if (!res.ok) { 355 + if (status) status.textContent = `error: ${payload.error ?? "unknown"}`; 356 + if (btn) btn.textContent = "[ submit ]"; 357 + return; 358 + } 359 + // re-fetch and re-render full output with updated entries 360 + const listRes = await fetch("/api/guestbook"); 361 + const listData = await listRes.json(); 362 + const output = document.getElementById("tui-output"); 363 + if (!output) return; 364 + output.innerHTML = renderGuestbook(listData.entries); 365 + termRender(output); 366 + attachGuestbookForm(output); 367 + } catch { 368 + if (status) status.textContent = "error: network failure"; 369 + if (btn) btn.textContent = "[ submit ]"; 370 + } 371 + }); 372 + } 373 + function back() { 374 + if (state.open) { 375 + state.open = false; 376 + if (isHomePage) { 377 + void renderHome(); 378 + return; 379 + } 380 + const output = document.getElementById("tui-output"); 381 + const hint = document.getElementById("tui-empty-hint"); 382 + const cmdLine = document.getElementById("tui-cmd-line"); 383 + if (output) { 384 + output.innerHTML = ""; 385 + output.style.display = "none"; 386 + output.classList.remove("nix-output"); 387 + } 388 + if (hint) hint.style.display = ""; 389 + if (cmdLine) cmdLine.style.visibility = "hidden"; 390 + return; 391 + } 392 + // pane closed: if on a child item, collapse its parent folder 393 + const item = state.items[state.focused]; 394 + if (!item?.dataset["parent"]) return; 395 + const parentPath = item.dataset["parent"]; 396 + const parentItem = Array.from( 397 + document.querySelectorAll('.tui-item[data-type="folder"]'), 398 + ).find((el) => el.dataset["path"] === parentPath); 399 + if (parentItem) void toggleFolder(parentItem); 400 + } 401 + async function renderHome() { 402 + const output = document.getElementById("tui-output"); 403 + const hint = document.getElementById("tui-empty-hint"); 404 + const cmdText = document.getElementById("tui-cmd-text"); 405 + const cmdLine = document.getElementById("tui-cmd-line"); 406 + if (!output) return; 407 + if (hint) hint.style.display = "none"; 408 + if (cmdText) cmdText.textContent = "fastfetch"; 409 + if (cmdLine) cmdLine.style.visibility = "visible"; 410 + output.classList.remove("nix-output"); 411 + output.style.display = ""; 412 + output.innerHTML = buildHomeHtml( 413 + '<span class="md-loading">loading...</span>', 414 + ); 415 + try { 416 + const idxRes = await fetch("content/news/index.json", { 417 + cache: "no-store", 418 + }); 419 + if (!idxRes.ok) throw new Error(String(idxRes.status)); 420 + const entries = await idxRes.json(); 421 + const latest = entries[0]; 422 + if (!latest) { 423 + output.innerHTML = buildHomeHtml(""); 424 + termRender(output); 425 + return; 426 + } 427 + const mdRes = await fetch(`content/news/${latest.slug}`, { 428 + cache: "no-store", 429 + }); 430 + if (!mdRes.ok) throw new Error(String(mdRes.status)); 431 + const md = await mdRes.text(); 432 + output.innerHTML = buildHomeHtml( 433 + renderMarkdown(stripFrontmatter(md).trim()), 434 + ); 435 + termRender(output); 436 + } catch { 437 + output.innerHTML = buildHomeHtml( 438 + '<span class="md-error">error: failed to load news</span>', 439 + ); 440 + } 441 + } 442 + function buildHomeHtml(speechContent) { 443 + return `<div class="home-layout"> 444 + <div class="home-speech"><div class="home-speech-inner">${speechContent}</div></div> 445 + <div class="home-ff"> 446 + <div class="home-avatar-wrap"> 447 + <img class="home-avatar" src="pfp.webp" alt="avatar" /> 448 + <img class="home-frame" src="pfp-frame.webp" alt="" aria-hidden="true" /> 449 + </div> 450 + <div class="home-panel"> 451 + <div class="ff-title">weegs<span class="ff-at">@</span>weegs.dev</div> 452 + <div class="ff-sep-line">──────────────────────</div> 453 + <div class="ff-map-row"><span class="ff-tree">├</span><a class="ff-site-link" href="gaming.html">gaming</a><span class="ff-comment"> # library of everything i play, played, and dropped</span></div> 454 + <div class="ff-map-row"><span class="ff-tree">└</span><a class="ff-site-link" href="stuff.html">my stuff</a><span class="ff-comment"> # projects, blog, rants, guestbook</span></div> 455 + <div class="ff-section-label">CONTACT</div> 456 + <div class="ff-map-row"><span class="ff-tree">├</span><span class="ff-label">github</span><a class="ff-contact-link" href="https://github.com/yourname" target="_blank" rel="noopener noreferrer">yourname</a></div> 457 + <div class="ff-map-row"><span class="ff-tree">├</span><span class="ff-label">codeberg</span><a class="ff-contact-link" href="https://codeberg.org/yourname" target="_blank" rel="noopener noreferrer">yourname</a></div> 458 + <div class="ff-map-row"><span class="ff-tree">├</span><span class="ff-label">discord</span><a class="ff-contact-link" href="https://discord.com/users/000000000000000000" target="_blank" rel="noopener noreferrer">yourname</a></div> 459 + <div class="ff-map-row"><span class="ff-tree">└</span><span class="ff-label">steam</span><a class="ff-contact-link" href="https://steamcommunity.com/id/yourname/" target="_blank" rel="noopener noreferrer">yourname</a></div> 460 + </div> 461 + </div> 462 + </div>`; 463 + } 464 + function termRender(el) { 465 + Array.from(el.querySelectorAll(".tui-line")).forEach((line, i) => { 466 + line.style.visibility = "hidden"; 467 + setTimeout(() => { 468 + line.style.visibility = "visible"; 469 + }, i * 22); 470 + }); 471 + } 472 + // ─── Markdown renderer ─── 473 + function stripFrontmatter(md) { 474 + if (!md.startsWith("---")) return md; 475 + const end = md.indexOf("\n---", 3); 476 + if (end === -1) return md; 477 + return md.slice(end + 4).trimStart(); 478 + } 479 + function fmtDate(raw) { 480 + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw); 481 + if (!m) return raw; 482 + return `${m[1]}/${m[2]}/${m[3]}`; 483 + } 484 + function escHtml(s) { 485 + return s 486 + .replace(/&/g, "&amp;") 487 + .replace(/</g, "&lt;") 488 + .replace(/>/g, "&gt;") 489 + .replace(/"/g, "&quot;"); 490 + } 491 + // ─── Nix syntax highlighter ─── 492 + const NIX_KEYWORDS = new Set([ 493 + "let", 494 + "in", 495 + "rec", 496 + "with", 497 + "inherit", 498 + "if", 499 + "then", 500 + "else", 501 + "assert", 502 + "import", 503 + "or", 504 + ]); 505 + const NIX_NS = new Set(["lib", "pkgs", "config", "builtins", "options"]); 506 + const NIX_BOOL = new Set(["true", "false"]); 507 + function highlightNix(code) { 508 + const out = []; 509 + let i = 0; 510 + const n = code.length; 511 + const span = (cls, text) => { 512 + const esc = text 513 + .replace(/&/g, "&amp;") 514 + .replace(/</g, "&lt;") 515 + .replace(/>/g, "&gt;"); 516 + out.push(`<span class="${cls}">${esc}</span>`); 517 + }; 518 + const plain = (text) => { 519 + out.push( 520 + text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"), 521 + ); 522 + }; 523 + while (i < n) { 524 + // line comment 525 + if (code[i] === "#") { 526 + const end = code.indexOf("\n", i); 527 + const text = end === -1 ? code.slice(i) : code.slice(i, end); 528 + span("nx-comment", text); 529 + i += text.length; 530 + continue; 531 + } 532 + // block comment 533 + if (code.slice(i, i + 2) === "/*") { 534 + const end = code.indexOf("*/", i + 2); 535 + const text = end === -1 ? code.slice(i) : code.slice(i, end + 2); 536 + span("nx-comment", text); 537 + i += text.length; 538 + continue; 539 + } 540 + // indented string ''...'' 541 + if (code.slice(i, i + 2) === "''") { 542 + const end = code.indexOf("''", i + 2); 543 + const text = end === -1 ? code.slice(i) : code.slice(i, end + 2); 544 + span("nx-str", text); 545 + i += text.length; 546 + continue; 547 + } 548 + // double-quoted string with interpolation 549 + if (code[i] === '"') { 550 + let j = i + 1; 551 + let strBuf = '"'; 552 + while (j < n && code[j] !== '"') { 553 + if (code[j] === "\\") { 554 + strBuf += code.slice(j, j + 2); 555 + j += 2; 556 + continue; 557 + } 558 + if (code.slice(j, j + 2) === "${") { 559 + if (strBuf) { 560 + span("nx-str", strBuf); 561 + strBuf = ""; 562 + } 563 + let depth = 1; 564 + let k = j + 2; 565 + while (k < n && depth > 0) { 566 + if (code[k] === "{") depth++; 567 + else if (code[k] === "}") depth--; 568 + k++; 569 + } 570 + span("nx-interp", code.slice(j, k)); 571 + j = k; 572 + continue; 573 + } 574 + strBuf += code[j]; 575 + j++; 576 + } 577 + if (j < n) { 578 + strBuf += '"'; 579 + j++; 580 + } 581 + if (strBuf) span("nx-str", strBuf); 582 + i = j; 583 + continue; 584 + } 585 + // angle-bracket path <nixpkgs> 586 + if (code[i] === "<" && i + 1 < n && /[a-zA-Z]/.test(code[i + 1])) { 587 + const end = code.indexOf(">", i + 1); 588 + if (end !== -1) { 589 + span("nx-path", code.slice(i, end + 1)); 590 + i = end + 1; 591 + continue; 592 + } 593 + } 594 + // number 595 + if (/[0-9]/.test(code[i])) { 596 + let j = i; 597 + while (j < n && /[0-9]/.test(code[j])) j++; 598 + span("nx-num", code.slice(i, j)); 599 + i = j; 600 + continue; 601 + } 602 + // identifier, keyword, namespace, boolean, null 603 + if (/[a-zA-Z_]/.test(code[i])) { 604 + let j = i; 605 + while (j < n && /[-a-zA-Z0-9_']/.test(code[j])) j++; 606 + const word = code.slice(i, j); 607 + if (NIX_BOOL.has(word)) span("nx-bool", word); 608 + else if (word === "null") span("nx-null", word); 609 + else if (NIX_KEYWORDS.has(word)) span("nx-kw", word); 610 + else if (NIX_NS.has(word)) span("nx-ns", word); 611 + else plain(word); 612 + i = j; 613 + continue; 614 + } 615 + // everything else: whitespace, operators, punctuation 616 + plain(code[i]); 617 + i++; 618 + } 619 + return out.join(""); 620 + } 621 + function renderNix(filename, source) { 622 + const highlighted = highlightNix(source); 623 + const lines = highlighted.split("\n"); 624 + // drop trailing blank produced by a final newline 625 + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); 626 + const lineHtml = lines 627 + .map((line, idx) => { 628 + const code = line === "" ? "&nbsp;" : line; 629 + return `<div class="nix-line"><span class="nix-ln">${idx + 1}</span><span class="nix-code">${code}</span></div>`; 630 + }) 631 + .join(""); 632 + return `<div class="nix-view"><div class="nix-file-header">${escHtml(filename)}</div><div class="nix-body">${lineHtml}</div></div>`; 633 + } 634 + function inlineFmt(raw) { 635 + return escHtml(raw) 636 + .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") 637 + .replace(/\*([^*]+)\*/g, "<em>$1</em>") 638 + .replace(/`([^`]+)`/g, '<code class="md-code">$1</code>') 639 + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'); 640 + } 641 + const STATUS_TAGS = new Set([ 642 + "wip", 643 + "active", 644 + "done", 645 + "deferred", 646 + "playing", 647 + "finished", 648 + "dropped", 649 + "paused", 650 + "todo", 651 + "archived", 652 + ]); 653 + function cellClass(raw) { 654 + if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return "col-date"; 655 + if (/^\d+(\.\d+)?$/.test(raw)) return "col-num"; 656 + if (raw.includes("/") && !raw.includes(" ")) return "col-path"; 657 + return "col-str"; 658 + } 659 + function fmtCell(raw) { 660 + const lower = raw.toLowerCase(); 661 + if (STATUS_TAGS.has(lower)) 662 + return `<span class="tag tag-${lower}">${escHtml(raw)}</span>`; 663 + return inlineFmt(raw); 664 + } 665 + function parseTableRow(line) { 666 + return line 667 + .split("|") 668 + .slice(1, -1) 669 + .map((c) => c.trim()); 670 + } 671 + function renderTable(headers, rows) { 672 + const ths = 673 + `<th class="col-idx tui-line">#</th>` + 674 + headers.map((h) => `<th class="tui-line">${inlineFmt(h)}</th>`).join(""); 675 + const trs = rows 676 + .map( 677 + (row, i) => 678 + `<tr><td class="col-idx tui-line">${i}</td>${row 679 + .map( 680 + (cell) => 681 + `<td class="${cellClass(cell)} tui-line">${fmtCell(cell)}</td>`, 682 + ) 683 + .join("")}</tr>`, 684 + ) 685 + .join(""); 686 + return `<table class="nu-table">\n<thead><tr>${ths}</tr></thead>\n<tbody>${trs}</tbody>\n</table>`; 687 + } 688 + function renderMarkdown(md) { 689 + const lines = md.split("\n"); 690 + const out = []; 691 + let inCode = false; 692 + let codeLang = ""; 693 + let codeLines = []; 694 + let tableHeaders = null; 695 + let tableRows = []; 696 + const isSep = (l) => /^\|[\s\-|:]+\|$/.test(l); 697 + const flushTable = () => { 698 + if (tableHeaders !== null) { 699 + out.push(renderTable(tableHeaders, tableRows)); 700 + tableHeaders = null; 701 + tableRows = []; 702 + } 703 + }; 704 + for (const line of lines) { 705 + if (line.startsWith("```")) { 706 + flushTable(); 707 + if (inCode) { 708 + const escaped = codeLines.map(escHtml).join("\n"); 709 + const langLabel = codeLang 710 + ? `<div class="code-lang">${escHtml(codeLang)}</div>` 711 + : ""; 712 + out.push( 713 + `<div class="md-codeblock tui-line">${langLabel}<pre class="code-pre">${escaped}</pre></div>`, 714 + ); 715 + inCode = false; 716 + codeLines = []; 717 + codeLang = ""; 718 + } else { 719 + inCode = true; 720 + codeLang = line.slice(3).trim(); 721 + } 722 + continue; 723 + } 724 + if (inCode) { 725 + codeLines.push(line); 726 + continue; 727 + } 728 + if (line.startsWith("|")) { 729 + if (isSep(line)) continue; 730 + if (tableHeaders === null) { 731 + tableHeaders = parseTableRow(line); 732 + } else { 733 + tableRows.push(parseTableRow(line)); 734 + } 735 + continue; 736 + } else { 737 + flushTable(); 738 + } 739 + if (line.startsWith("# ")) { 740 + out.push( 741 + `<div class="md-h1 tui-line"><span class="md-hash"># </span>${inlineFmt(line.slice(2))}</div>`, 742 + ); 743 + } else if (line.startsWith("## ")) { 744 + out.push( 745 + `<div class="md-h2 tui-line"><span class="md-hash">## </span>${inlineFmt(line.slice(3))}</div>`, 746 + ); 747 + } else if (line.startsWith("### ")) { 748 + out.push( 749 + `<div class="md-h3 tui-line"><span class="md-hash">### </span>${inlineFmt(line.slice(4))}</div>`, 750 + ); 751 + } else if (line.startsWith("- ") || line.startsWith("* ")) { 752 + out.push( 753 + `<div class="md-li tui-line"><span class="md-bullet">•</span> ${inlineFmt(line.slice(2))}</div>`, 754 + ); 755 + } else if (line.startsWith(" - ") || line.startsWith(" * ")) { 756 + out.push( 757 + `<div class="md-li md-li-nested tui-line"><span class="md-bullet">•</span> ${inlineFmt(line.slice(4))}</div>`, 758 + ); 759 + } else if (line.trim() === "") { 760 + out.push('<div class="md-spacer"></div>'); 761 + } else if (/^---+$/.test(line.trim())) { 762 + out.push('<hr class="md-hr tui-line" />'); 763 + } else { 764 + out.push(`<div class="md-p tui-line">${inlineFmt(line)}</div>`); 765 + } 766 + } 767 + flushTable(); 768 + return out.join("\n"); 769 + } 770 + document.addEventListener("DOMContentLoaded", init); 771 + export {};
+893
tui.ts
··· 1 + export {}; 2 + 3 + type NodeType = "static" | "file" | "folder" | "guestbook"; 4 + 5 + interface IndexEntry { 6 + slug: string; 7 + title: string; 8 + date: string; 9 + } 10 + 11 + interface GBEntry { 12 + id: string; 13 + name: string; 14 + message: string; 15 + website: string; 16 + timestamp: number; 17 + date: string; 18 + } 19 + 20 + interface TuiState { 21 + items: HTMLElement[]; 22 + focused: number; 23 + open: boolean; 24 + } 25 + 26 + const state: TuiState = { items: [], focused: 0, open: false }; 27 + let isHomePage = false; 28 + 29 + function getType(el: HTMLElement): NodeType { 30 + return (el.dataset["type"] as NodeType | undefined) ?? "static"; 31 + } 32 + 33 + function rebuildItems(): void { 34 + state.items = Array.from( 35 + document.querySelectorAll<HTMLElement>(".tui-list .tui-item"), 36 + ); 37 + } 38 + 39 + function init(): void { 40 + rebuildItems(); 41 + isHomePage = !!document.querySelector('[data-page="home"]'); 42 + 43 + if (!state.items.length) { 44 + if (isHomePage) void renderHome(); 45 + return; 46 + } 47 + 48 + const output = document.getElementById("tui-output"); 49 + if (output && !isHomePage) output.style.display = "none"; 50 + 51 + setFocus(0); 52 + document.addEventListener("keydown", onKey); 53 + 54 + // event delegation handles dynamically inserted child items 55 + const list = document.querySelector<HTMLElement>(".tui-list"); 56 + list?.addEventListener("click", (e) => { 57 + const item = (e.target as HTMLElement).closest<HTMLElement>(".tui-item"); 58 + if (!item) return; 59 + const idx = state.items.indexOf(item); 60 + if (idx === -1) return; 61 + if (idx === state.focused && state.open && getType(item) !== "folder") { 62 + // closes output for touch devices that lack keyboard back-navigation 63 + back(); 64 + } else { 65 + setFocus(idx); 66 + select(); 67 + } 68 + }); 69 + 70 + if (isHomePage) { 71 + void renderHome(); 72 + } else { 73 + // preview first item: show content without locking j/k into scroll mode 74 + void previewFirst(); 75 + } 76 + } 77 + 78 + async function previewFirst(): Promise<void> { 79 + const item = state.items[0]; 80 + if (!item) return; 81 + const type = getType(item); 82 + const output = document.getElementById("tui-output"); 83 + const hint = document.getElementById("tui-empty-hint"); 84 + if (!output) return; 85 + 86 + if (type === "file") { 87 + const path = item.dataset["path"] ?? ""; 88 + try { 89 + const res = await fetch(path, { cache: "no-store" }); 90 + if (!res.ok) return; 91 + if (state.focused !== 0) return; // user navigated before fetch completed 92 + const md = await res.text(); 93 + if (state.focused !== 0) return; 94 + if (hint) hint.style.display = "none"; 95 + output.innerHTML = renderMarkdown(stripFrontmatter(md)); 96 + output.style.display = ""; 97 + termRender(output); 98 + } catch { 99 + // silent fail -- user can select explicitly 100 + } 101 + } else if (type === "static") { 102 + const contentId = item.dataset["content"]; 103 + const tmpl = contentId 104 + ? (document.getElementById(contentId) as HTMLTemplateElement | null) 105 + : null; 106 + if (!tmpl) return; 107 + if (hint) hint.style.display = "none"; 108 + output.innerHTML = ""; 109 + output.appendChild(tmpl.content.cloneNode(true)); 110 + output.style.display = ""; 111 + termRender(output); 112 + } 113 + // guestbook: no auto-preview on load -- requires explicit selection 114 + // state.open stays false -- j/k continues to navigate the list 115 + } 116 + 117 + function onKey(e: KeyboardEvent): void { 118 + const target = e.target as HTMLElement; 119 + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return; 120 + switch (e.key) { 121 + case "ArrowUp": 122 + case "k": 123 + e.preventDefault(); 124 + // j/k scroll right pane when open, navigate list when closed 125 + if (state.open) scrollOutput(-60); 126 + else move(-1); 127 + break; 128 + case "ArrowDown": 129 + case "j": 130 + e.preventDefault(); 131 + if (state.open) scrollOutput(60); 132 + else move(1); 133 + break; 134 + case "ArrowRight": 135 + case "l": 136 + case "Enter": 137 + e.preventDefault(); 138 + select(); 139 + break; 140 + case "ArrowLeft": 141 + case "h": 142 + case "Escape": 143 + e.preventDefault(); 144 + back(); 145 + break; 146 + } 147 + } 148 + 149 + function scrollOutput(delta: number): void { 150 + document 151 + .getElementById("tui-output") 152 + ?.scrollBy({ top: delta, behavior: "smooth" }); 153 + } 154 + 155 + function move(delta: number): void { 156 + const next = 157 + (state.focused + delta + state.items.length) % state.items.length; 158 + setFocus(next); 159 + state.items[next].scrollIntoView({ block: "nearest" }); 160 + } 161 + 162 + function setFocus(idx: number): void { 163 + state.items[state.focused]?.classList.remove("focused"); 164 + state.focused = idx; 165 + state.items[idx]?.classList.add("focused"); 166 + } 167 + 168 + function select(): void { 169 + const item = state.items[state.focused]; 170 + if (!item) return; 171 + const type = getType(item); 172 + 173 + if (type === "folder") { 174 + void toggleFolder(item); 175 + return; 176 + } 177 + 178 + if (type === "file") { 179 + const path = item.dataset["path"] ?? ""; 180 + const cmd = item.dataset["cmd"] ?? `open ${path}`; 181 + void openFile(path, cmd); 182 + return; 183 + } 184 + 185 + if (type === "guestbook") { 186 + const cmd = item.dataset["cmd"] ?? "http get /api/guestbook"; 187 + void openGuestbook(cmd); 188 + return; 189 + } 190 + 191 + // static: legacy inline template behavior 192 + const contentId = item.dataset["content"]; 193 + const tmpl = contentId 194 + ? (document.getElementById(contentId) as HTMLTemplateElement | null) 195 + : null; 196 + const output = document.getElementById("tui-output"); 197 + if (!tmpl || !output) return; 198 + 199 + const cmdText = document.getElementById("tui-cmd-text"); 200 + const cmdLine = document.getElementById("tui-cmd-line"); 201 + const hint = document.getElementById("tui-empty-hint"); 202 + 203 + if (cmdText) cmdText.textContent = item.dataset["cmd"] ?? contentId ?? ""; 204 + if (cmdLine) cmdLine.style.visibility = "visible"; 205 + if (hint) hint.style.display = "none"; 206 + 207 + output.innerHTML = ""; 208 + output.appendChild(tmpl.content.cloneNode(true)); 209 + output.style.display = ""; 210 + termRender(output); 211 + state.open = true; 212 + } 213 + 214 + async function openFile(path: string, cmd: string): Promise<void> { 215 + const output = document.getElementById("tui-output"); 216 + if (!output) return; 217 + const cmdText = document.getElementById("tui-cmd-text"); 218 + const cmdLine = document.getElementById("tui-cmd-line"); 219 + const hint = document.getElementById("tui-empty-hint"); 220 + 221 + if (cmdText) cmdText.textContent = cmd; 222 + if (cmdLine) cmdLine.style.visibility = "visible"; 223 + if (hint) hint.style.display = "none"; 224 + 225 + output.innerHTML = '<div class="md-loading">loading...</div>'; 226 + output.style.display = ""; 227 + state.open = true; 228 + 229 + try { 230 + const res = await fetch(path, { cache: "no-store" }); 231 + if (!res.ok) throw new Error(String(res.status)); 232 + const text = await res.text(); 233 + if (path.endsWith(".nix")) { 234 + const filename = path.split("/").pop() ?? path; 235 + output.classList.add("nix-output"); 236 + output.innerHTML = renderNix(filename, text); 237 + } else { 238 + output.classList.remove("nix-output"); 239 + output.innerHTML = renderMarkdown(stripFrontmatter(text)); 240 + termRender(output); 241 + } 242 + } catch { 243 + output.innerHTML = `<div class="md-error">error: failed to load ${escHtml(path)}</div>`; 244 + } 245 + } 246 + 247 + async function toggleFolder(item: HTMLElement): Promise<void> { 248 + const path = item.dataset["path"] ?? ""; 249 + const expanded = item.dataset["expanded"] === "true"; 250 + const arrow = item.querySelector<HTMLElement>(".folder-arrow"); 251 + 252 + if (expanded) { 253 + // de-focus current item before removing it if it's a child of this folder 254 + state.items[state.focused]?.classList.remove("focused"); 255 + 256 + Array.from(document.querySelectorAll<HTMLElement>("[data-parent]")) 257 + .filter((el) => el.dataset["parent"] === path) 258 + .forEach((el) => el.remove()); 259 + 260 + item.dataset["expanded"] = "false"; 261 + if (arrow) arrow.textContent = "+"; 262 + 263 + rebuildItems(); 264 + const folderIdx = state.items.indexOf(item); 265 + state.focused = folderIdx !== -1 ? folderIdx : 0; 266 + state.items[state.focused]?.classList.add("focused"); 267 + 268 + if (state.open) back(); 269 + return; 270 + } 271 + 272 + item.dataset["expanded"] = "true"; 273 + if (arrow) arrow.textContent = "-"; 274 + 275 + const loading = document.createElement("div"); 276 + loading.className = "tui-item tui-item-child"; 277 + loading.dataset["parent"] = path; 278 + loading.textContent = " loading..."; 279 + item.insertAdjacentElement("afterend", loading); 280 + rebuildItems(); 281 + 282 + try { 283 + const res = await fetch(`${path}index.json`, { cache: "no-store" }); 284 + if (!res.ok) throw new Error(String(res.status)); 285 + const entries = (await res.json()) as IndexEntry[]; 286 + 287 + loading.remove(); 288 + 289 + if (!entries.length) { 290 + const empty = document.createElement("div"); 291 + empty.className = "tui-item tui-item-child"; 292 + empty.dataset["parent"] = path; 293 + empty.textContent = " (empty)"; 294 + item.insertAdjacentElement("afterend", empty); 295 + } else { 296 + let insertAfter: Element = item; 297 + for (const entry of entries) { 298 + const child = document.createElement("div"); 299 + child.className = "tui-item tui-item-child"; 300 + child.dataset["type"] = "file"; 301 + child.dataset["parent"] = path; 302 + child.dataset["path"] = `${path}${entry.slug}`; 303 + child.dataset["cmd"] = `open ${path}${entry.slug}`; 304 + const date = entry.date 305 + ? `<span class="child-date">${escHtml(fmtDate(entry.date))}</span> ` 306 + : ""; 307 + child.innerHTML = `${date}${escHtml(entry.title)}`; 308 + insertAfter.insertAdjacentElement("afterend", child); 309 + insertAfter = child; 310 + } 311 + } 312 + } catch { 313 + loading.remove(); 314 + const err = document.createElement("div"); 315 + err.className = "tui-item tui-item-child"; 316 + err.dataset["parent"] = path; 317 + err.textContent = " error loading index"; 318 + item.insertAdjacentElement("afterend", err); 319 + } 320 + 321 + rebuildItems(); 322 + const folderIdx = state.items.indexOf(item); 323 + if (folderIdx !== -1 && folderIdx + 1 < state.items.length) { 324 + state.items[folderIdx]?.classList.remove("focused"); 325 + state.focused = folderIdx + 1; 326 + state.items[state.focused]?.classList.add("focused"); 327 + } 328 + } 329 + 330 + async function openGuestbook(cmd: string): Promise<void> { 331 + const output = document.getElementById("tui-output"); 332 + if (!output) return; 333 + const cmdText = document.getElementById("tui-cmd-text"); 334 + const cmdLine = document.getElementById("tui-cmd-line"); 335 + const hint = document.getElementById("tui-empty-hint"); 336 + 337 + if (cmdText) cmdText.textContent = cmd; 338 + if (cmdLine) cmdLine.style.visibility = "visible"; 339 + if (hint) hint.style.display = "none"; 340 + 341 + output.innerHTML = '<div class="md-loading tui-line">loading...</div>'; 342 + output.style.display = ""; 343 + state.open = true; 344 + 345 + try { 346 + const res = await fetch("/api/guestbook"); 347 + if (!res.ok) throw new Error(String(res.status)); 348 + const data = (await res.json()) as { entries: GBEntry[] }; 349 + output.innerHTML = renderGuestbook(data.entries); 350 + termRender(output); 351 + attachGuestbookForm(output); 352 + } catch { 353 + output.innerHTML = `<div class="md-error">error: failed to load guestbook</div>`; 354 + } 355 + } 356 + 357 + function renderGuestbook(entries: GBEntry[]): string { 358 + const tableHtml = 359 + entries.length === 0 360 + ? `<div class="md-p gb-empty tui-line">(no entries yet -- be the first)</div>` 361 + : `<table class="nu-table gb-table"> 362 + <thead><tr> 363 + <th class="col-idx tui-line">#</th> 364 + <th class="tui-line">from</th> 365 + <th class="tui-line">message</th> 366 + <th class="tui-line">date</th> 367 + </tr></thead> 368 + <tbody>${entries 369 + .map((e, i) => { 370 + const fromCell = e.website 371 + ? `<a href="${escHtml(e.website)}" target="_blank" rel="noopener noreferrer">${escHtml(e.name)}</a>` 372 + : escHtml(e.name); 373 + return `<tr> 374 + <td class="col-idx tui-line">${i}</td> 375 + <td class="col-str gb-from tui-line">${fromCell}</td> 376 + <td class="col-msg tui-line">${escHtml(e.message)}</td> 377 + <td class="col-date tui-line">${escHtml(e.date)}</td> 378 + </tr>`; 379 + }) 380 + .join("")}</tbody> 381 + </table>`; 382 + 383 + return `${tableHtml} 384 + <div class="gb-form-wrap tui-line"> 385 + <div class="gb-form-header"><span class="p-arrow">❯</span> http post /api/guestbook $entry</div> 386 + <form id="gb-form" class="gb-form" autocomplete="off"> 387 + <input type="text" name="hp" class="gb-hp" tabindex="-1" aria-hidden="true" /> 388 + <div class="gb-field"> 389 + <label class="gb-label">name</label> 390 + <input type="text" name="name" class="gb-input" maxlength="50" required placeholder="you" /> 391 + </div> 392 + <div class="gb-field"> 393 + <label class="gb-label">site</label> 394 + <input type="text" name="website" class="gb-input" maxlength="100" placeholder="optional" /> 395 + </div> 396 + <div class="gb-field gb-field-msg"> 397 + <label class="gb-label">msg</label> 398 + <textarea name="message" class="gb-input gb-textarea" maxlength="500" required placeholder="leave your mark..."></textarea> 399 + </div> 400 + <div class="gb-actions"> 401 + <button type="submit" class="gb-btn">[ submit ]</button> 402 + <span class="gb-status" id="gb-status"></span> 403 + </div> 404 + </form> 405 + </div>`; 406 + } 407 + 408 + function attachGuestbookForm(container: HTMLElement): void { 409 + const form = container.querySelector<HTMLFormElement>("#gb-form"); 410 + if (!form) return; 411 + 412 + form.addEventListener("submit", async (e) => { 413 + e.preventDefault(); 414 + const status = document.getElementById("gb-status"); 415 + const btn = form.querySelector<HTMLButtonElement>(".gb-btn"); 416 + const data = new FormData(form); 417 + 418 + if (btn) btn.textContent = "[ ... ]"; 419 + if (status) status.textContent = ""; 420 + 421 + try { 422 + const res = await fetch("/api/guestbook", { 423 + method: "POST", 424 + headers: { "Content-Type": "application/json" }, 425 + body: JSON.stringify({ 426 + name: data.get("name"), 427 + message: data.get("message"), 428 + website: data.get("website"), 429 + hp: data.get("hp"), 430 + }), 431 + }); 432 + const payload = (await res.json()) as { ok?: boolean; error?: string }; 433 + 434 + if (!res.ok) { 435 + if (status) status.textContent = `error: ${payload.error ?? "unknown"}`; 436 + if (btn) btn.textContent = "[ submit ]"; 437 + return; 438 + } 439 + 440 + // re-fetch and re-render full output with updated entries 441 + const listRes = await fetch("/api/guestbook"); 442 + const listData = (await listRes.json()) as { entries: GBEntry[] }; 443 + const output = document.getElementById("tui-output"); 444 + if (!output) return; 445 + output.innerHTML = renderGuestbook(listData.entries); 446 + termRender(output); 447 + attachGuestbookForm(output); 448 + } catch { 449 + if (status) status.textContent = "error: network failure"; 450 + if (btn) btn.textContent = "[ submit ]"; 451 + } 452 + }); 453 + } 454 + 455 + function back(): void { 456 + if (state.open) { 457 + state.open = false; 458 + if (isHomePage) { 459 + void renderHome(); 460 + return; 461 + } 462 + const output = document.getElementById("tui-output"); 463 + const hint = document.getElementById("tui-empty-hint"); 464 + const cmdLine = document.getElementById("tui-cmd-line"); 465 + if (output) { 466 + output.innerHTML = ""; 467 + output.style.display = "none"; 468 + output.classList.remove("nix-output"); 469 + } 470 + if (hint) hint.style.display = ""; 471 + if (cmdLine) cmdLine.style.visibility = "hidden"; 472 + return; 473 + } 474 + 475 + // pane closed: if on a child item, collapse its parent folder 476 + const item = state.items[state.focused]; 477 + if (!item?.dataset["parent"]) return; 478 + const parentPath = item.dataset["parent"]; 479 + const parentItem = Array.from( 480 + document.querySelectorAll<HTMLElement>('.tui-item[data-type="folder"]'), 481 + ).find((el) => el.dataset["path"] === parentPath); 482 + if (parentItem) void toggleFolder(parentItem); 483 + } 484 + 485 + async function renderHome(): Promise<void> { 486 + const output = document.getElementById("tui-output"); 487 + const hint = document.getElementById("tui-empty-hint"); 488 + const cmdText = document.getElementById("tui-cmd-text"); 489 + const cmdLine = document.getElementById("tui-cmd-line"); 490 + if (!output) return; 491 + 492 + if (hint) hint.style.display = "none"; 493 + if (cmdText) cmdText.textContent = "fastfetch"; 494 + if (cmdLine) cmdLine.style.visibility = "visible"; 495 + output.classList.remove("nix-output"); 496 + output.style.display = ""; 497 + output.innerHTML = buildHomeHtml( 498 + '<span class="md-loading">loading...</span>', 499 + ); 500 + 501 + try { 502 + const idxRes = await fetch("content/news/index.json", { 503 + cache: "no-store", 504 + }); 505 + if (!idxRes.ok) throw new Error(String(idxRes.status)); 506 + const entries = (await idxRes.json()) as IndexEntry[]; 507 + const latest = entries[0]; 508 + if (!latest) { 509 + output.innerHTML = buildHomeHtml(""); 510 + termRender(output); 511 + return; 512 + } 513 + const mdRes = await fetch(`content/news/${latest.slug}`, { 514 + cache: "no-store", 515 + }); 516 + if (!mdRes.ok) throw new Error(String(mdRes.status)); 517 + const md = await mdRes.text(); 518 + output.innerHTML = buildHomeHtml( 519 + renderMarkdown(stripFrontmatter(md).trim()), 520 + ); 521 + termRender(output); 522 + } catch { 523 + output.innerHTML = buildHomeHtml( 524 + '<span class="md-error">error: failed to load news</span>', 525 + ); 526 + } 527 + } 528 + 529 + function buildHomeHtml(speechContent: string): string { 530 + return `<div class="home-layout"> 531 + <div class="home-speech"><div class="home-speech-inner">${speechContent}</div></div> 532 + <div class="home-ff"> 533 + <div class="home-avatar-wrap"> 534 + <img class="home-avatar" src="pfp.webp" alt="avatar" /> 535 + <img class="home-frame" src="pfp-frame.webp" alt="" aria-hidden="true" /> 536 + </div> 537 + <div class="home-panel"> 538 + <div class="ff-title">weegs<span class="ff-at">@</span>weegs.dev</div> 539 + <div class="ff-sep-line">──────────────────────</div> 540 + <div class="ff-map-row"><span class="ff-tree">├</span><a class="ff-site-link" href="gaming.html">gaming</a><span class="ff-comment"> # library of everything i play, played, and dropped</span></div> 541 + <div class="ff-map-row"><span class="ff-tree">└</span><a class="ff-site-link" href="stuff.html">my stuff</a><span class="ff-comment"> # projects, blog, rants, guestbook</span></div> 542 + <div class="ff-section-label">CONTACT</div> 543 + <div class="ff-map-row"><span class="ff-tree">├</span><span class="ff-label">github</span><a class="ff-contact-link" href="https://github.com/yourname" target="_blank" rel="noopener noreferrer">yourname</a></div> 544 + <div class="ff-map-row"><span class="ff-tree">├</span><span class="ff-label">codeberg</span><a class="ff-contact-link" href="https://codeberg.org/yourname" target="_blank" rel="noopener noreferrer">yourname</a></div> 545 + <div class="ff-map-row"><span class="ff-tree">├</span><span class="ff-label">discord</span><a class="ff-contact-link" href="https://discord.com/users/000000000000000000" target="_blank" rel="noopener noreferrer">yourname</a></div> 546 + <div class="ff-map-row"><span class="ff-tree">└</span><span class="ff-label">steam</span><a class="ff-contact-link" href="https://steamcommunity.com/id/yourname/" target="_blank" rel="noopener noreferrer">yourname</a></div> 547 + </div> 548 + </div> 549 + </div>`; 550 + } 551 + 552 + function termRender(el: HTMLElement): void { 553 + Array.from(el.querySelectorAll<HTMLElement>(".tui-line")).forEach( 554 + (line, i) => { 555 + line.style.visibility = "hidden"; 556 + setTimeout(() => { 557 + line.style.visibility = "visible"; 558 + }, i * 22); 559 + }, 560 + ); 561 + } 562 + 563 + // ─── Markdown renderer ─── 564 + 565 + function stripFrontmatter(md: string): string { 566 + if (!md.startsWith("---")) return md; 567 + const end = md.indexOf("\n---", 3); 568 + if (end === -1) return md; 569 + return md.slice(end + 4).trimStart(); 570 + } 571 + 572 + function fmtDate(raw: string): string { 573 + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw); 574 + if (!m) return raw; 575 + return `${m[1]}/${m[2]}/${m[3]}`; 576 + } 577 + 578 + function escHtml(s: string): string { 579 + return s 580 + .replace(/&/g, "&amp;") 581 + .replace(/</g, "&lt;") 582 + .replace(/>/g, "&gt;") 583 + .replace(/"/g, "&quot;"); 584 + } 585 + 586 + // ─── Nix syntax highlighter ─── 587 + 588 + const NIX_KEYWORDS = new Set([ 589 + "let", 590 + "in", 591 + "rec", 592 + "with", 593 + "inherit", 594 + "if", 595 + "then", 596 + "else", 597 + "assert", 598 + "import", 599 + "or", 600 + ]); 601 + const NIX_NS = new Set(["lib", "pkgs", "config", "builtins", "options"]); 602 + const NIX_BOOL = new Set(["true", "false"]); 603 + 604 + function highlightNix(code: string): string { 605 + const out: string[] = []; 606 + let i = 0; 607 + const n = code.length; 608 + 609 + const span = (cls: string, text: string) => { 610 + const esc = text 611 + .replace(/&/g, "&amp;") 612 + .replace(/</g, "&lt;") 613 + .replace(/>/g, "&gt;"); 614 + out.push(`<span class="${cls}">${esc}</span>`); 615 + }; 616 + const plain = (text: string) => { 617 + out.push( 618 + text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"), 619 + ); 620 + }; 621 + 622 + while (i < n) { 623 + // line comment 624 + if (code[i] === "#") { 625 + const end = code.indexOf("\n", i); 626 + const text = end === -1 ? code.slice(i) : code.slice(i, end); 627 + span("nx-comment", text); 628 + i += text.length; 629 + continue; 630 + } 631 + 632 + // block comment 633 + if (code.slice(i, i + 2) === "/*") { 634 + const end = code.indexOf("*/", i + 2); 635 + const text = end === -1 ? code.slice(i) : code.slice(i, end + 2); 636 + span("nx-comment", text); 637 + i += text.length; 638 + continue; 639 + } 640 + 641 + // indented string ''...'' 642 + if (code.slice(i, i + 2) === "''") { 643 + const end = code.indexOf("''", i + 2); 644 + const text = end === -1 ? code.slice(i) : code.slice(i, end + 2); 645 + span("nx-str", text); 646 + i += text.length; 647 + continue; 648 + } 649 + 650 + // double-quoted string with interpolation 651 + if (code[i] === '"') { 652 + let j = i + 1; 653 + let strBuf = '"'; 654 + while (j < n && code[j] !== '"') { 655 + if (code[j] === "\\") { 656 + strBuf += code.slice(j, j + 2); 657 + j += 2; 658 + continue; 659 + } 660 + if (code.slice(j, j + 2) === "${") { 661 + if (strBuf) { 662 + span("nx-str", strBuf); 663 + strBuf = ""; 664 + } 665 + let depth = 1; 666 + let k = j + 2; 667 + while (k < n && depth > 0) { 668 + if (code[k] === "{") depth++; 669 + else if (code[k] === "}") depth--; 670 + k++; 671 + } 672 + span("nx-interp", code.slice(j, k)); 673 + j = k; 674 + continue; 675 + } 676 + strBuf += code[j]; 677 + j++; 678 + } 679 + if (j < n) { 680 + strBuf += '"'; 681 + j++; 682 + } 683 + if (strBuf) span("nx-str", strBuf); 684 + i = j; 685 + continue; 686 + } 687 + 688 + // angle-bracket path <nixpkgs> 689 + if (code[i] === "<" && i + 1 < n && /[a-zA-Z]/.test(code[i + 1])) { 690 + const end = code.indexOf(">", i + 1); 691 + if (end !== -1) { 692 + span("nx-path", code.slice(i, end + 1)); 693 + i = end + 1; 694 + continue; 695 + } 696 + } 697 + 698 + // number 699 + if (/[0-9]/.test(code[i])) { 700 + let j = i; 701 + while (j < n && /[0-9]/.test(code[j])) j++; 702 + span("nx-num", code.slice(i, j)); 703 + i = j; 704 + continue; 705 + } 706 + 707 + // identifier, keyword, namespace, boolean, null 708 + if (/[a-zA-Z_]/.test(code[i])) { 709 + let j = i; 710 + while (j < n && /[-a-zA-Z0-9_']/.test(code[j])) j++; 711 + const word = code.slice(i, j); 712 + if (NIX_BOOL.has(word)) span("nx-bool", word); 713 + else if (word === "null") span("nx-null", word); 714 + else if (NIX_KEYWORDS.has(word)) span("nx-kw", word); 715 + else if (NIX_NS.has(word)) span("nx-ns", word); 716 + else plain(word); 717 + i = j; 718 + continue; 719 + } 720 + 721 + // everything else: whitespace, operators, punctuation 722 + plain(code[i]); 723 + i++; 724 + } 725 + 726 + return out.join(""); 727 + } 728 + 729 + function renderNix(filename: string, source: string): string { 730 + const highlighted = highlightNix(source); 731 + const lines = highlighted.split("\n"); 732 + // drop trailing blank produced by a final newline 733 + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); 734 + const lineHtml = lines 735 + .map((line, idx) => { 736 + const code = line === "" ? "&nbsp;" : line; 737 + return `<div class="nix-line"><span class="nix-ln">${idx + 1}</span><span class="nix-code">${code}</span></div>`; 738 + }) 739 + .join(""); 740 + return `<div class="nix-view"><div class="nix-file-header">${escHtml(filename)}</div><div class="nix-body">${lineHtml}</div></div>`; 741 + } 742 + 743 + function inlineFmt(raw: string): string { 744 + return escHtml(raw) 745 + .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") 746 + .replace(/\*([^*]+)\*/g, "<em>$1</em>") 747 + .replace(/`([^`]+)`/g, '<code class="md-code">$1</code>') 748 + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'); 749 + } 750 + 751 + const STATUS_TAGS = new Set([ 752 + "wip", 753 + "active", 754 + "done", 755 + "deferred", 756 + "playing", 757 + "finished", 758 + "dropped", 759 + "paused", 760 + "todo", 761 + "archived", 762 + ]); 763 + 764 + function cellClass(raw: string): string { 765 + if (/^\d{4}-\d{2}-\d{2}/.test(raw)) return "col-date"; 766 + if (/^\d+(\.\d+)?$/.test(raw)) return "col-num"; 767 + if (raw.includes("/") && !raw.includes(" ")) return "col-path"; 768 + return "col-str"; 769 + } 770 + 771 + function fmtCell(raw: string): string { 772 + const lower = raw.toLowerCase(); 773 + if (STATUS_TAGS.has(lower)) 774 + return `<span class="tag tag-${lower}">${escHtml(raw)}</span>`; 775 + return inlineFmt(raw); 776 + } 777 + 778 + function parseTableRow(line: string): string[] { 779 + return line 780 + .split("|") 781 + .slice(1, -1) 782 + .map((c) => c.trim()); 783 + } 784 + 785 + function renderTable(headers: string[], rows: string[][]): string { 786 + const ths = 787 + `<th class="col-idx tui-line">#</th>` + 788 + headers.map((h) => `<th class="tui-line">${inlineFmt(h)}</th>`).join(""); 789 + const trs = rows 790 + .map( 791 + (row, i) => 792 + `<tr><td class="col-idx tui-line">${i}</td>${row 793 + .map( 794 + (cell) => 795 + `<td class="${cellClass(cell)} tui-line">${fmtCell(cell)}</td>`, 796 + ) 797 + .join("")}</tr>`, 798 + ) 799 + .join(""); 800 + return `<table class="nu-table">\n<thead><tr>${ths}</tr></thead>\n<tbody>${trs}</tbody>\n</table>`; 801 + } 802 + 803 + function renderMarkdown(md: string): string { 804 + const lines = md.split("\n"); 805 + const out: string[] = []; 806 + let inCode = false; 807 + let codeLang = ""; 808 + let codeLines: string[] = []; 809 + let tableHeaders: string[] | null = null; 810 + let tableRows: string[][] = []; 811 + 812 + const isSep = (l: string) => /^\|[\s\-|:]+\|$/.test(l); 813 + 814 + const flushTable = () => { 815 + if (tableHeaders !== null) { 816 + out.push(renderTable(tableHeaders, tableRows)); 817 + tableHeaders = null; 818 + tableRows = []; 819 + } 820 + }; 821 + 822 + for (const line of lines) { 823 + if (line.startsWith("```")) { 824 + flushTable(); 825 + if (inCode) { 826 + const escaped = codeLines.map(escHtml).join("\n"); 827 + const langLabel = codeLang 828 + ? `<div class="code-lang">${escHtml(codeLang)}</div>` 829 + : ""; 830 + out.push( 831 + `<div class="md-codeblock tui-line">${langLabel}<pre class="code-pre">${escaped}</pre></div>`, 832 + ); 833 + inCode = false; 834 + codeLines = []; 835 + codeLang = ""; 836 + } else { 837 + inCode = true; 838 + codeLang = line.slice(3).trim(); 839 + } 840 + continue; 841 + } 842 + 843 + if (inCode) { 844 + codeLines.push(line); 845 + continue; 846 + } 847 + 848 + if (line.startsWith("|")) { 849 + if (isSep(line)) continue; 850 + if (tableHeaders === null) { 851 + tableHeaders = parseTableRow(line); 852 + } else { 853 + tableRows.push(parseTableRow(line)); 854 + } 855 + continue; 856 + } else { 857 + flushTable(); 858 + } 859 + 860 + if (line.startsWith("# ")) { 861 + out.push( 862 + `<div class="md-h1 tui-line"><span class="md-hash"># </span>${inlineFmt(line.slice(2))}</div>`, 863 + ); 864 + } else if (line.startsWith("## ")) { 865 + out.push( 866 + `<div class="md-h2 tui-line"><span class="md-hash">## </span>${inlineFmt(line.slice(3))}</div>`, 867 + ); 868 + } else if (line.startsWith("### ")) { 869 + out.push( 870 + `<div class="md-h3 tui-line"><span class="md-hash">### </span>${inlineFmt(line.slice(4))}</div>`, 871 + ); 872 + } else if (line.startsWith("- ") || line.startsWith("* ")) { 873 + out.push( 874 + `<div class="md-li tui-line"><span class="md-bullet">•</span> ${inlineFmt(line.slice(2))}</div>`, 875 + ); 876 + } else if (line.startsWith(" - ") || line.startsWith(" * ")) { 877 + out.push( 878 + `<div class="md-li md-li-nested tui-line"><span class="md-bullet">•</span> ${inlineFmt(line.slice(4))}</div>`, 879 + ); 880 + } else if (line.trim() === "") { 881 + out.push('<div class="md-spacer"></div>'); 882 + } else if (/^---+$/.test(line.trim())) { 883 + out.push('<hr class="md-hr tui-line" />'); 884 + } else { 885 + out.push(`<div class="md-p tui-line">${inlineFmt(line)}</div>`); 886 + } 887 + } 888 + 889 + flushTable(); 890 + return out.join("\n"); 891 + } 892 + 893 + document.addEventListener("DOMContentLoaded", init);
+10
wrangler.toml
··· 1 + name = "weegs-dev" 2 + pages_build_output_dir = "." 3 + compatibility_date = "2024-04-01" 4 + 5 + # After running: wrangler kv namespace create GUESTBOOK 6 + # Paste the output id below, then add the same binding in: 7 + # Cloudflare dashboard > Pages > weegs-dev > Settings > Functions > KV namespace bindings 8 + [[kv_namespaces]] 9 + binding = "GUESTBOOK" 10 + id = "<YOUR_KV_NAMESPACE_ID>"