├── db └── .keep ├── .gitignore ├── docs ├── stream-mobile.png ├── stream-browser.png └── stream-devices.png ├── src ├── config.oak ├── model.oak ├── main.oak └── view.oak ├── Makefile ├── stream.service ├── LICENSE ├── static ├── js │ └── main.js └── css │ └── main.css └── README.md /db/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | db/*.jsonl 2 | -------------------------------------------------------------------------------- /docs/stream-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/stream/HEAD/docs/stream-mobile.png -------------------------------------------------------------------------------- /docs/stream-browser.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/stream/HEAD/docs/stream-browser.png -------------------------------------------------------------------------------- /docs/stream-devices.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thesephist/stream/HEAD/docs/stream-devices.png -------------------------------------------------------------------------------- /src/config.oak: -------------------------------------------------------------------------------- 1 | // app-wide configuration 2 | 3 | Port := 10020 4 | // Number of updates per page by default 5 | PageSize := 10 6 | // Where updates are persisted to disk 7 | StreamFile := './db/stream.jsonl' 8 | 9 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: run 2 | 3 | # run the server 4 | run: 5 | oak src/main.oak 6 | 7 | # watch and restart 8 | watch: 9 | ls src/*.oak | entr -r make 10 | w: watch 11 | 12 | # run the autoformatter 13 | fmt: 14 | oak fmt --changes --fix 15 | f: fmt 16 | -------------------------------------------------------------------------------- /stream.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=stream server 3 | ConditionPathExists=/home/stream-user/go/bin/oak 4 | After=network.target 5 | 6 | [Service] 7 | Type=simple 8 | User=stream-user 9 | LimitNOFILE=1024 10 | PermissionsStartOnly=true 11 | 12 | Restart=on-failure 13 | RestartSec=100ms 14 | StartLimitIntervalSec=60 15 | 16 | WorkingDirectory=/home/stream-user/stream 17 | ExecStart=/home/stream-user/go/bin/oak ./src/main.oak 18 | 19 | # make sure log directory exists and owned by syslog 20 | PermissionsStartOnly=true 21 | ExecStartPre=/bin/mkdir -p /var/log/stream 22 | ExecStartPre=/bin/chown syslog:adm /var/log/stream 23 | ExecStartPre=/bin/chmod 755 /var/log/stream 24 | StandardOutput=syslog 25 | StandardError=syslog 26 | SyslogIdentifier=stream 27 | 28 | [Install] 29 | WantedBy=multi-user.target 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Linus Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /static/js/main.js: -------------------------------------------------------------------------------- 1 | // Hydrate date and timestamps next to each update to match timestamp to the 2 | // visitor's current time zone. 3 | for (const timestampEl of document.querySelectorAll('.update-t')) { 4 | const date = new Date(parseInt(timestampEl.getAttribute('data-timestamp')) * 1000); 5 | timestampEl.querySelector('.datestamp').textContent = 6 | `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; 7 | timestampEl.querySelector('.datestamp').setAttribute('href', 8 | `/on/${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`); 9 | timestampEl.querySelector('.clockstamp').textContent = 10 | `${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`; 11 | } 12 | 13 | // Dark mode with localStorage persistence 14 | document.querySelector('nav').appendChild((() => { 15 | const b = document.createElement('button'); 16 | b.textContent = 'light/dark'; 17 | b.style.color = 'var(--secondary-text)'; 18 | b.addEventListener('click', () => { 19 | window.localStorage.setItem( 20 | 'colorscheme', 21 | currentlyPrefersDark() ? 'light' : 'dark', 22 | ); 23 | updateScheme(); 24 | }); 25 | return b; 26 | })()); 27 | 28 | // Tab key in textarea 29 | for (const area of document.querySelectorAll('textarea')) { 30 | area.addEventListener('keydown', evt => { 31 | switch (evt.key) { 32 | case 'Tab': { 33 | const idx = evt.target.selectionStart; 34 | if (idx == null) return; 35 | evt.preventDefault(); 36 | const val = evt.target.value; 37 | const front = val.substr(0, idx); 38 | const back = val.substr(idx); 39 | evt.target.value = front + '\t' + back; 40 | evt.target.setSelectionRange(idx + 1, idx + 1); 41 | break; 42 | } 43 | case 'Enter': { 44 | if (!evt.ctrlKey && !evt.metaKey) return; 45 | evt.preventDefault(); 46 | evt.target.closest('form').submit(); 47 | break; 48 | } 49 | } 50 | }); 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/model.oak: -------------------------------------------------------------------------------- 1 | // model and data interfaces 2 | 3 | { 4 | println: println 5 | default: default 6 | slice: slice 7 | map: map 8 | filter: filter 9 | every: every 10 | } := import('std') 11 | { 12 | lower: lower 13 | split: split 14 | contains?: strContains? 15 | join: join 16 | } := import('str') 17 | fs := import('fs') 18 | json := import('json') 19 | datetime := import('datetime') 20 | 21 | config := import('config') 22 | StreamFile := config.StreamFile 23 | 24 | if fs.statFile(StreamFile) { 25 | ? -> if fs.writeFile(StreamFile, '') { 26 | true -> println('Created database at ' + StreamFile) 27 | _ -> { 28 | println('Could not create database!') 29 | exit(1) 30 | } 31 | } 32 | } 33 | 34 | fn intoUpdates(s) { 35 | lines := [] 36 | buf := '' 37 | parse := json.parse 38 | fn sub(i) if c := s.(i) { 39 | ? -> lines << parse(buf) 40 | '\n' -> sub(i + 1, lines << parse(buf), buf <- '') 41 | _ -> sub(i + 1, buf << c) 42 | } 43 | sub(0) 44 | lines |> filter(fn(u) u != :error) 45 | } 46 | 47 | fn appendUpdate(status, withRes) { 48 | updateLine := json.serialize({ 49 | t: int(time()) 50 | s: status 51 | }) << '\n' 52 | fs.appendFile(StreamFile, updateLine, withRes) 53 | } 54 | 55 | fn updatesAtTime(t, withUpdates) with exec('grep', [ 56 | '-si' 57 | string(t) 58 | StreamFile 59 | ], '') fn(evt) if evt.type { 60 | :error -> withUpdates(?) 61 | _ -> evt.stdout |> intoUpdates() |> filter(fn(u) u.t = t) |> withUpdates() 62 | } 63 | 64 | fn deleteAtTime(t, withRes) with fs.readFile(StreamFile) fn(file) if file { 65 | ? -> withRes(?) 66 | _ -> { 67 | updates := file |> intoUpdates() 68 | deletedUpdates := updates |> with filter() fn(u) u.t != t 69 | 70 | updatedLines := deletedUpdates |> 71 | map(json.serialize) |> 72 | join('\n') + '\n' // appended log lines always trail with a '\n' 73 | fs.writeFile(StreamFile, updatedLines, withRes) 74 | } 75 | } 76 | 77 | fn latestUpdates(page, count, searchQuery, withUpdates) { 78 | with fs.readFile(StreamFile) fn(file) if file { 79 | ? -> withUpdates(?) 80 | _ -> { 81 | updates := file |> intoUpdates() 82 | if { 83 | count < 0 -> updates 84 | _ -> { 85 | keywords := searchQuery |> 86 | default('') |> 87 | split(' ') |> 88 | filter(fn(s) s != '') |> 89 | map(lower) 90 | updates <- if keywords { 91 | [] -> updates 92 | _ -> updates |> with filter() fn(update) { 93 | s := update.s |> lower() 94 | keywords |> with every() fn(word) s |> strContains?(word) 95 | } 96 | } 97 | 98 | // pagination and slicing 99 | start := len(updates) - page * count 100 | end := start + count 101 | withUpdates( 102 | updates |> slice(start, end) 103 | // previous page exists? 104 | end < len(updates) 105 | // next page exists? 106 | start > 0 107 | ) 108 | } 109 | } 110 | } 111 | } 112 | } 113 | 114 | fn updatesOnDay(year, month, day, withUpdates) { 115 | minTime := datetime.timestamp({ 116 | year: year, month: month, day: day 117 | hour: 0, minute: 0, second: 0 118 | }) 119 | maxTime := minTime + 86400 - 1 120 | 121 | with fs.readFile(StreamFile) fn(file) if file { 122 | ? -> withUpdates(?) 123 | _ -> file |> 124 | intoUpdates() |> 125 | filter(fn(update) update.t >= minTime & update.t <= maxTime) |> 126 | withUpdates() 127 | } 128 | } 129 | 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The stream 🌊 2 | 3 | [**The stream**](https://stream.thesephist.com) is like a less noisy, mini Twitter feed I've created just for myself and my updates on my work and thinking. It's a micro-blog in the truest sense of the word -- a timeilne of small casual updates that I can send out quickly on the go. The stream has a public front-end that shows a timeline of updates, and a private interface I use to send new updates and manage old ones. 4 | 5 | ![The stream, running on a browser and an iPhone](docs/stream-devices.png) 6 | 7 | I've used my [Twitter](https://twitter.com/thesephist) as the main place people can get updates on what I'm working on or thinking about. Twitter is great, but the stream was born out of my desire for a micro-blog that was a little more homebrew, a little more focused, and a little more expressive. 8 | 9 | The stream is also the first real application I built with my [Oak programming language](https://oaklang.org/), so building it served as a good testbed and excuse for exercising the language in a real use case and patching in some of the initial bugs and rough edges. At launch, The stream is quite small — only about 500 well-commented lines of Oak code. That seems about right: It's complex enough to be a good test for a fledgling language, but simple enough not to overwhelm it. 10 | 11 | ## Why not Twitter? 12 | 13 | 1. Twitter is extremely noisy. There's a million other people vying for your attention next to what I have to say. 14 | 2. Twitter is also quite limiting, not just in the character count, but also in that they only let you write in plain text, without any formatting unless you resort to ugly Unicode hacks. 15 | 3. Lastly, Twitter is great for "in the moment" discussion in public, but it's not so great as a reference you can link to from the future, and as a historical record of my thinking and work. I wanted a place more purpose-built, more focused, and more permanent for my less permanent thoughts and updates. 16 | 17 | ## Architecture 18 | 19 | The stream is a server-rendered web application written in pure [Oak](https://oaklang.org/), a dynamic programming language I created. It's the first real project using Oak (outside of things like the standard library) for something practical, so I ended up building and revising much of the standard library in the process of building the stream. The stream is server-rendered for the primary reason that Oak doesn't compile to JavaScript yet — there's a little bit of JavaScript code to enable some light interactivity like light/dark mode, but all of the core app logic lives in the backend in Oak code. 20 | 21 | The stream keeps all update data in a [JSONL](https://jsonlines.org/) file on the backend, each entry containing a timestamp and the raw Markdown for each update. New updates are efficiently appended to the end of the file, and reading latest updates is as simple as reading the last N entries from the file. Though I give up some performance by going with this format rather than, say, a SQLite database, the ability to edit the data manually or easily inspect and back it up is worth the trouble. (And if performance becomes a problem, I can always easily migrate away later.) 22 | 23 | Because the app is so simple (and I'm still trying to figure out what an "idiomatic" Oak web app should look like), there's nothing in the code that resembles a "framework". APIs hit well-defined HTTP routes, which directly call data querying and page rendering functions. Perhaps a better design pattern will appear as I build more sophisticated apps with Oak, but for the stream, this seems simple enough. 24 | 25 | ## Build and deploy 26 | 27 | Oak comes packaged as a single statically-linked executable, available from [the website](https://oaklang.org). I deploy the stream as a systemd process that runs `./src/main.oak`. The server will automatically create a `./db/stream.jsonl` data file if it's not already there when it starts. 28 | 29 | I use GNU Make to manage some common development tasks. 30 | 31 | - Just `make` to run the server 32 | - `make watch` or `make w` to run the server and re-start anytime the backend code changes 33 | - `make fmt` or `make f` to re-format any files containing unstaged changes using `oak fmt`. This is equivalent to `oak fmt --changes --fix`. 34 | -------------------------------------------------------------------------------- /src/main.oak: -------------------------------------------------------------------------------- 1 | // The stream micro-blog server 2 | 3 | { 4 | default: default 5 | slice: slice 6 | map: map 7 | } := import('std') 8 | { 9 | join: join 10 | replace: replace 11 | trim: trim 12 | } := import('str') 13 | { 14 | format: format 15 | printf: printf 16 | } := import('fmt') 17 | fs := import('fs') 18 | datetime := import('datetime') 19 | path := import('path') 20 | json := import('json') 21 | http := import('http') 22 | 23 | config := import('config') 24 | model := import('model') 25 | view := import('view') 26 | 27 | PageHeaders := { 28 | 'Content-Type': 'text/html' 29 | } 30 | 31 | server := http.Server() 32 | 33 | with server.route('/updates/:timestamp') fn(params) fn(req, end) if req.method { 34 | 'GET' -> if timestamp := int(params.timestamp) { 35 | ? -> end({ 36 | status: 400 37 | headers: PageHeaders 38 | body: view.page( 39 | view.search() << view.message('Invalid timestamp "' << params.timestamp << '".') 40 | ) 41 | }) 42 | _ -> with model.updatesAtTime(timestamp) fn(updates) if updates { 43 | ?, [] -> end({ 44 | status: 500 45 | headers: PageHeaders 46 | body: view.page( 47 | view.search() << view.message('No updates from this time.') 48 | ) 49 | }) 50 | _ -> if params.raw { 51 | ? -> end({ 52 | status: 200 53 | headers: PageHeaders 54 | body: view.page( 55 | view.feed(updates) 56 | '' 57 | ) 58 | }) 59 | _ -> end({ 60 | status: 200 61 | headers: { 'Content-Type': http.MimeTypes.txt } 62 | body: updates |> map(fn(u) u.s) |> join('\n\n') 63 | }) 64 | } 65 | } 66 | } 67 | _ -> end(http.MethodNotAllowed) 68 | } 69 | 70 | with server.route('/author/updates') fn(params) fn(req, end) if req.method { 71 | 'POST' -> { 72 | // assume urlencoded form data format s=(.*) 73 | // it seems
textarea sends \r\n's, so translate those 74 | status := req.body |> 75 | slice(2) |> 76 | http.percentDecode() |> 77 | trim() |> 78 | replace('\r\n', '\n') 79 | with model.appendUpdate(status) fn(res) if res { 80 | true -> end({ 81 | status: 303 82 | headers: { Location: '/' } 83 | body: '' 84 | }) 85 | _ -> end({ 86 | status: 500 87 | headers: PageHeaders 88 | body: view.page( 89 | view.search() << view.message('Could not save update. Please try again.') 90 | ) 91 | }) 92 | } 93 | } 94 | _ -> end(http.MethodNotAllowed) 95 | } 96 | 97 | with server.route('/author/delete') fn(params) fn(req, end) if req.method { 98 | 'POST' -> { 99 | // assume urlencoded form data format t=(.*) 100 | timestamp := req.body |> slice(2) |> http.percentDecode() |> trim() |> int() 101 | with model.deleteAtTime(timestamp) fn(res) if res { 102 | true -> end({ 103 | status: 303 104 | headers: { Location: '/' } 105 | body: '' 106 | }) 107 | _ -> end({ 108 | status: 500 109 | headers: PageHeaders 110 | body: view.page( 111 | view.search() << view.message('Could not delete this update. Please try again.') 112 | ) 113 | }) 114 | } 115 | } 116 | _ -> end(http.MethodNotAllowed) 117 | } 118 | 119 | with server.route('/on/:year/:month/:day') fn(params) fn(req, end) if req.method { 120 | 'GET' -> if [year, month, day] := [params.year, params.month, params.day] |> map(int) { 121 | [?, _, _], [_, ?, _], [_, _, ?] -> end({ 122 | status: 400 123 | headers: PageHeaders 124 | body: view.page( 125 | view.search() << view.message('Invalid request.') 126 | ) 127 | }) 128 | _ -> with model.updatesOnDay(year, month, day) fn(updates) if updates { 129 | ? -> end({ 130 | status: 500 131 | headers: PageHeaders 132 | body: view.page( 133 | view.search() << view.message('Could not read the latest updates.') 134 | ) 135 | }) 136 | _ -> end({ 137 | status: 200 138 | headers: PageHeaders 139 | body: view.page( 140 | view.search() << view.feed(updates) 141 | 'Updates on {{0}}/{{1}}/{{2}}' |> format(params.year, params.month, params.day) 142 | ) 143 | }) 144 | } 145 | } 146 | _ -> end(htpt.MethodNotAllowed) 147 | } 148 | 149 | with server.route('/s/*staticPath') fn(params) { 150 | http.handleStatic(path.join('./static', params.staticPath)) 151 | } 152 | 153 | with server.route('/author/') fn(params) fn(req, end) if req.method { 154 | 'GET' -> end({ 155 | status: 200 156 | headers: PageHeaders 157 | body: view.page( 158 | view.author() 159 | 'Author' 160 | ) 161 | }) 162 | _ -> end(http.MethodNotAllowed) 163 | } 164 | 165 | with server.route('/about/') fn(params) fn(req, end) if req.method { 166 | 'GET' -> end({ 167 | status: 200 168 | headers: PageHeaders 169 | body: view.page( 170 | view.about() 171 | 'About the stream' 172 | ) 173 | }) 174 | _ -> end(http.MethodNotAllowed) 175 | } 176 | 177 | with server.route('/') fn(params) fn(req, end) if req.method { 178 | 'GET' -> { 179 | page := int(params.p) |> default(1) 180 | count := int(params.n) |> default(config.PageSize) 181 | query := params.q 182 | raw? := params.raw != ? 183 | 184 | with model.latestUpdates(page, count, query) fn(updates, last?, next?) if updates { 185 | ? -> end({ 186 | status: 500 187 | headers: PageHeaders 188 | body: view.page( 189 | view.search() << view.message('Could not read latest updates.') 190 | ) 191 | }) 192 | _ -> if raw? { 193 | true -> end({ 194 | status: 200 195 | headers: { 'Content-Type': http.MimeTypes.json } 196 | body: json.serialize(updates) 197 | }) 198 | _ -> end({ 199 | status: 200 200 | headers: PageHeaders 201 | body: view.page( 202 | view.search(query) << 203 | view.content(updates, last?, next?, page, count, query) 204 | if query { 205 | ? -> ? 206 | _ -> 'Search "{{0}}"' |> format(query) 207 | } 208 | ) 209 | }) 210 | } 211 | } 212 | } 213 | _ -> end(http.MethodNotAllowed) 214 | } 215 | 216 | server.start(config.Port) 217 | printf('Stream server running at {{0}}', config.Port) 218 | 219 | -------------------------------------------------------------------------------- /src/view.oak: -------------------------------------------------------------------------------- 1 | // template pages 2 | 3 | { 4 | default: default 5 | map: map 6 | reverse: reverse 7 | } := import('std') 8 | { 9 | padStart: padStart 10 | join: join 11 | } := import('str') 12 | { 13 | format: format 14 | } := import('fmt') 15 | datetime := import('datetime') 16 | md := import('md') 17 | 18 | config := import('config') 19 | 20 | fn date(t) { 21 | d := datetime.describe(t) 22 | '{{0}}/{{1}}/{{2}} 23 | {{3}}:{{4}}' |> format( 24 | d.year 25 | d.month 26 | d.day 27 | d.hour 28 | d.minute |> string() |> padStart(2, '0') 29 | t 30 | ) 31 | } 32 | 33 | fn feedURL(page, count, query) { 34 | params := [] 35 | if page != 1 { 36 | true -> params << 'p=' + string(page) 37 | } 38 | if count != config.PageSize { 39 | true -> params << 'n=' + string(count) 40 | } 41 | if query != ? { 42 | true -> params << 'q=' + string(query) 43 | } 44 | if params { 45 | [] -> '/' 46 | _ -> '/?' << params |> join('&') 47 | } 48 | } 49 | 50 | fn update(u) '
51 |
{{1}}
52 |
{{2}}
53 |
' |> format( 54 | u.t 55 | u.t |> date() 56 | md.transform(u.s) 57 | ) 58 | 59 | fn feed(updates) if updates { 60 | [] -> '
61 |
No updates.
62 |
' 63 | _ -> '
{{0}}
' |> 64 | format(updates |> reverse() |> map(update) |> join()) 65 | } 66 | 67 | fn message(s) '
{{0}}
' |> format(s) 68 | 69 | fn content(updates, last?, next?, page, count, query) ' 70 | {{0}} 71 |
{{1}}{{2}}
72 | ' |> format( 73 | feed(updates) 74 | if last? { 75 | true -> '' |> format( 76 | feedURL(page - 1, count, query) 77 | ) 78 | _ -> '' 79 | } 80 | if next? { 81 | true -> '' |> format( 82 | feedURL(page + 1, count, query) 83 | ) 84 | _ -> '' 85 | } 86 | ) 87 | 88 | fn author ' 89 | 92 | 93 |
94 |
95 | 96 | 97 |
' 98 | 99 | fn about '
100 |

Welcome to my stream. Here, you can expect to find a constant 101 | stream of updates about what I\'m working on or thinking about, especially 102 | around my current interests of creative knowledge tools, software, and 103 | writing.

104 |

You can think of this website like a less noisy, mini Twitter feed I\'ve 105 | created just for myself and my updates on my work and thinking. It\'s a 106 | micro-blog in the truest sense of the word.

107 |

Why don\'t you just use Twitter, then? I hear you asking. I 108 | could, but there are a few problems with Twitter. First, Twitter is 109 | extremely noisy. There\'s a million other people vying for your attention 110 | next to what I have to say. Twitter is also quite limiting, not just in the 111 | character count, but also in that they only let you write in plain text, 112 | without any formatting unless you resort to ugly Unicode hacks. Lastly, 113 | Twitter is great for "in the moment" discussion in public, but it\'s not so 114 | great as a reference you can link to from the future, and as a historical 115 | record of my thinking and work. I wanted a place more purpose-built, more 116 | focused, and more permanent for my less permanent thoughts and updates.

117 |

The stream is also the first real application I built with my 118 | Oak programming language, so building it 119 | served as a good testbed and excuse for exercising the language in a real 120 | use case and patching in some of the initial bugs and rough edges. At 121 | launch, The stream is quite small — only about 500 well-commented 122 | lines of Oak code. That seems about right: It\'s complex enough to be a 123 | good test for a fledgling language, but simple enough not to overwhelm 124 | it.

125 |

The stream is free and open source on GitHub, if you want to 127 | check out how it works. But there isn\'t much to it — all the data 128 | lives in a single JSONL file, and 129 | there\'s a light view and model layer on top.

130 |
' 131 | 132 | fn search(searchQuery) '' |> format( 138 | searchQuery |> default('') 139 | ) 140 | 141 | fn page(body, title, searchQuery) ' 142 | 143 | 144 | {{0}} 145 | 146 | 147 | 148 | 149 |
150 | 151 | 154 |
155 | {{1}} 156 |
157 | {{2}} 158 |
159 | 161 | 180 | 181 | ' |> format( 182 | if title { 183 | ?, '' -> 'the stream' 184 | _ -> title + ' | the stream' 185 | } 186 | if title { 187 | ? -> '

Linus\'s stream

' 188 | '' -> '' 189 | _ -> '

' << title << '

' 190 | } 191 | body 192 | ) 193 | 194 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | body { 7 | --primary-bg: #fdfeff; 8 | --primary-text: #111111; 9 | --secondary-bg: #eeeef3; 10 | --secondary-text: #9b9b9b; 11 | --hover-bg: #dde1e5; 12 | --active-bg: #cdcfd2; 13 | 14 | --dark-primary-bg: #141516; 15 | --dark-primary-text: #ebebeb; 16 | --dark-secondary-bg: #30373a; 17 | --dark-secondary-text: #a4a7a9; 18 | --dark-hover-bg: #474c50; 19 | --dark-active-bg: #626569; 20 | } 21 | 22 | .dark { 23 | --primary-bg: var(--dark-primary-bg); 24 | --primary-text: var(--dark-primary-text); 25 | --secondary-bg: var(--dark-secondary-bg); 26 | --secondary-text: var(--dark-secondary-text); 27 | --hover-bg: var(--dark-hover-bg); 28 | --active-bg: var(--dark-active-bg); 29 | } 30 | 31 | @media (prefers-color-scheme: dark) { 32 | body:not(.light) { 33 | --primary-bg: var(--dark-primary-bg); 34 | --primary-text: var(--dark-primary-text); 35 | --secondary-bg: var(--dark-secondary-bg); 36 | --secondary-text: var(--dark-secondary-text); 37 | --hover-bg: var(--dark-hover-bg); 38 | --active-bg: var(--dark-active-bg); 39 | } 40 | } 41 | 42 | body { 43 | font-family: system-ui, sans-serif; 44 | color: var(--primary-text); 45 | background: var(--primary-bg); 46 | 47 | display: flex; 48 | flex-direction: column; 49 | min-height: 100vh; 50 | border-bottom: 8px solid #111111; 51 | } 52 | 53 | input, 54 | button, 55 | textarea { 56 | font-size: 1em; 57 | padding: .5em .8em; 58 | color: var(--primary-text); 59 | font-family: system-ui, sans-serif; 60 | tab-size: 4; 61 | } 62 | 63 | input::placeholder, 64 | textarea::placeholder { 65 | color: var(--secondary-text); 66 | } 67 | 68 | header, 69 | h1, 70 | main { 71 | width: calc(100% - 32px); 72 | max-width: 860px; 73 | margin: 1em auto; 74 | } 75 | 76 | header { 77 | display: flex; 78 | flex-direction: row; 79 | align-items: center; 80 | justify-content: space-between; 81 | } 82 | 83 | header .logo { 84 | font-weight: bold; 85 | } 86 | 87 | nav { 88 | display: flex; 89 | flex-direction: row-reverse; 90 | align-items: center; 91 | gap: 1em; 92 | } 93 | 94 | header a, 95 | header button { 96 | display: inline; 97 | cursor: pointer; 98 | color: var(--primary-text); 99 | background: transparent; 100 | border: 0; 101 | border-radius: 0; 102 | text-decoration: none; 103 | padding: .5em 0; 104 | } 105 | 106 | header a:hover, 107 | header button:hover { 108 | text-decoration: underline; 109 | } 110 | 111 | h1 { 112 | margin-top: 0.75em; 113 | margin-bottom: 0.25em; 114 | line-height: 1.4em; 115 | } 116 | 117 | main { 118 | margin-bottom: 3em; 119 | } 120 | 121 | .about p, 122 | .about li { 123 | max-width: 64ch; 124 | line-height: 1.5em; 125 | } 126 | 127 | .about a { 128 | color: inherit; 129 | } 130 | 131 | .about a:hover { 132 | background: var(--hover-bg); 133 | } 134 | 135 | form { 136 | position: relative; 137 | margin-bottom: 2em; 138 | overflow: hidden; 139 | } 140 | 141 | form input, 142 | form textarea { 143 | display: block; 144 | border-radius: 6px; 145 | border: 0; 146 | background: var(--hover-bg); 147 | width: 100%; 148 | box-sizing: border-box; 149 | } 150 | 151 | form textarea { 152 | min-height: 50vh; 153 | line-height: 1.5em; 154 | resize: vertical; 155 | } 156 | 157 | form input:hover, 158 | form input:focus, 159 | form textarea:hover, 160 | form textarea:focus { 161 | outline: 0; 162 | } 163 | 164 | form button[type="submit"] { 165 | border-radius: 6px; 166 | border: 0; 167 | color: var(--primary-bg); 168 | background: var(--primary-text); 169 | margin-top: .5em; 170 | float: right; 171 | cursor: pointer; 172 | } 173 | 174 | form button[type="submit"]:hover { 175 | background: var(--secondary-text); 176 | } 177 | 178 | .pageControls { 179 | float: right; 180 | display: flex; 181 | flex-direction: row; 182 | align-items: center; 183 | justify-content: flex-end; 184 | gap: .75em; 185 | } 186 | 187 | .pageControls a { 188 | color: var(--primary-text); 189 | text-decoration: none; 190 | } 191 | 192 | .pageControls a:hover { 193 | text-decoration: underline; 194 | } 195 | 196 | a.pageButton { 197 | display: flex; 198 | align-items: center; 199 | justify-content: center; 200 | font-size: 1.5em; 201 | height: 1.5em; 202 | width: 1.5em; 203 | border-radius: 50%; 204 | background: var(--secondary-bg); 205 | } 206 | 207 | a.pageButton:hover { 208 | background: var(--hover-bg); 209 | text-decoration: none; 210 | } 211 | 212 | .message { 213 | font-style: italic; 214 | color: var(--secondary-text); 215 | margin-bottom: 2em; 216 | } 217 | 218 | .update { 219 | margin-bottom: 2.5em; 220 | word-break: break-word; 221 | } 222 | 223 | .update .update-t { 224 | font-size: 14px; 225 | color: var(--secondary-text); 226 | margin-bottom: -.5em; 227 | } 228 | 229 | .update .update-t a { 230 | color: var(--secondary-text); 231 | text-decoration: none; 232 | } 233 | 234 | .update .update-t a:hover { 235 | text-decoration: underline; 236 | } 237 | 238 | .update-t .relativestamp { 239 | margin-bottom: 6px; 240 | } 241 | 242 | /* update Markdown */ 243 | 244 | .update h1, 245 | .update h2, 246 | .update h3 { 247 | margin: .75em 0 .5em 0; 248 | line-height: 1.4em; 249 | } 250 | 251 | .update h1 { 252 | font-size: 1.75em; 253 | } 254 | 255 | .update h2 { 256 | font-size: 1.5em; 257 | } 258 | 259 | .update h3 { 260 | font-size: 1.2em; 261 | } 262 | 263 | .update h4, 264 | .update h5, 265 | .update h6 { 266 | font-size: 1em; 267 | } 268 | 269 | .update p, 270 | .update li { 271 | line-height: 1.5em; 272 | max-width: 64ch; 273 | } 274 | 275 | .update strike { 276 | color: var(--secondary-text); 277 | } 278 | 279 | .update img { 280 | max-width: 100%; 281 | max-height: 500px; 282 | border-radius: 6px; 283 | } 284 | 285 | .update a { 286 | color: var(--primary-text); 287 | text-decoration: underline; 288 | } 289 | 290 | .update ul, 291 | .update ol { 292 | padding-left: 3ch; 293 | } 294 | 295 | .update pre, 296 | .update code { 297 | background: var(--hover-bg); 298 | font-size: 1em; 299 | font-family: 'IBM Plex Mono', 'Menlo', 'Monaco', monospace; 300 | /* 301 | * In Safari (2021), word-break: break-word from `.update` combined with 302 | * certain contents of
 tags that do not begin lines with whitespace
303 |      * break line-wrapping behavior. This inversion of word-break works around
304 |      * that Safari bug (or at least, what appears to be a browser bug, as
305 |      * Chrome does not reproduce).
306 |      */
307 |     word-break: initial;
308 | }
309 | 
310 | .update pre {
311 |     border-radius: 6px;
312 |     box-sizing: border-box;
313 |     padding: 12px 8px;
314 |     overflow-x: auto;
315 | }
316 | 
317 | .update code {
318 |     padding: 1px 5px;
319 |     border-radius: 6px;
320 | }
321 | 
322 | .update pre code {
323 |     padding: 0;
324 | }
325 | 
326 | .update blockquote {
327 |     margin: 0;
328 |     border-left: 4px solid var(--active-bg);
329 |     padding-left: 1em;
330 |     display: block;
331 | }
332 | 
333 | @media only screen and (min-width: 760px) {
334 |     .update {
335 |         display: flex;
336 |         flex-direction: row;
337 |         align-items: flex-start;
338 |         justify-content: space-between;
339 |         margin-bottom: 1.5em;
340 |     }
341 |     .update-t {
342 |         flex-grow: 0;
343 |         flex-shrink: 0;
344 |         width: 134px;
345 |         margin-top: 3px;
346 |     }
347 |     .update-s {
348 |         width: 0;
349 |         flex-grow: 1;
350 |         flex-shrink: 1;
351 |     }
352 |     .update-s :first-child {
353 |         margin-top: 0;
354 |     }
355 | }
356 | 
357 | 


--------------------------------------------------------------------------------