1<!doctype html> 2<html lang="en" class="light"> 3 <head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>About</title> 7 8 9 <!-- Fonts --> 10 <link rel="preconnect" href="https://fonts.googleapis.com" /> 11 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 12 <link 13 href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=JetBrains+Mono:wght@400;700&display=swap" 14 rel="stylesheet" 15 /> 16 17 <!-- Favicons --> 18 <link 19 rel="apple-touch-icon" 20 sizes="180x180" 21 href="/static/favicon_io/apple-touch-icon.png" 22 /> 23 <link 24 rel="icon" 25 type="image/png" 26 sizes="32x32" 27 href="/static/favicon_io/favicon-32x32.png" 28 /> 29 <link 30 rel="icon" 31 type="image/png" 32 sizes="16x16" 33 href="/static/favicon_io/favicon-16x16.png" 34 /> 35 <link rel="manifest" href="/static/favicon_io/site.webmanifest" /> 36 37
37<script src="https://cdn.tailwindcss.com"></script>
37 38
38<script src="https://unpkg.com/alpinejs" defer></script>
38 39
39<script 40 defer 41 data-domain="koaning.io" 42 src="https://plausible.io/js/script.js" 43 ></script>
43 44
44<script> 45 tailwind.config = { 46 darkMode: "class", 47 theme: { 48 extend: { 49 colors: { 50 primary: "#0969da", 51 secondary: "#24292f", 52 "dark-primary": "#58a6ff", 53 "dark-secondary": "#c9d1d9", 54 "dark-bg": "#0d1117", 55 "dark-card": "#161b22", 56 }, 57 fontFamily: { 58 sans: ["IBM Plex Sans", "Helvetica", "Arial", "sans-serif"], 59 mono: [ 60 "JetBrains Mono", 61 "Courier New", 62 "Consolas", 63 "monospace", 64 ], 65 }, 66 }, 67 }, 68 }; 69 </script>
69 70 71 <link 72 rel="stylesheet" 73 href="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.css" 74 integrity="sha384-zh0CIslj+VczCZtlzBcjt5ppRcsAmDnRem7ESsYwWwg3m/OaJ2l4x7YBZl9Kxxib" 75 crossorigin="anonymous" 76 /> 77
77<script 78 defer 79 src="https://cdn.jsdelivr.net/npm/[email protected]/dist/katex.min.js" 80 integrity="sha384-Rma6DA2IPUwhNxmrB/7S3Tno0YY7sFu9WSYMCuulLhIqYSGZ2gKCJWIqhBWqMQfh" 81 crossorigin="anonymous" 82 ></script>
82 83
83<script 84 defer 85 src="https://cdn.jsdelivr.net/npm/[email protected]/dist/contrib/auto-render.min.js" 86 integrity="sha384-hCXGrW6PitJEwbkoStFjeJxv+fSOOQKOPbJxSfM6G5sWZjAyWhXiTIIAmQqnlLlh" 87 crossorigin="anonymous" 88 ></script>
88 89
89<script> 90 document.addEventListener("DOMContentLoaded", function () { 91 renderMathInElement(document.body, { 92 // customised options 93 // ⢠auto-render specific keys, e.g.: 94 delimiters: [ 95 { left: "$$", right: "$$", display: true }, 96 { left: "$", right: "$", display: false }, 97 { left: "\\(", right: "\\)", display: false }, 98 { left: "\\[", right: "\\]", display: true }, 99 ], 100 // ⢠rendering keys, e.g.: 101 throwOnError: false, 102 }); 103 }); 104 </script>
104 105 106 <!-- Prism CSS - Simple setup with one theme --> 107 <link 108 rel="stylesheet" 109 href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" 110 /> 111 112 <!-- Prism JS - Load required components directly --> 113
113<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
113 114
114<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
114 115
115<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
115 116
116<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-toml.min.js"></script>
116 117
117<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
117 118
118<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
118 119
119<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
119 120
120<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
120 121
121<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
121 122
122<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
122 123
123<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-docker.min.js"></script>
123 124
124<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-rust.min.js"></script>
124 125
125<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script>
125 126 <style> 127 /* Base styles with fluid typography */ 128 :root { 129 --font-base: "IBM Plex Sans", Helvetica, Arial, sans-serif; 130 --size-step-0: clamp(1rem, 0.96rem + 0.22vw, 1.125rem); 131 --size-step-1: clamp(1.125rem, 1.06rem + 0.33vw, 1.313rem); 132 --size-step-2: clamp(1.266rem, 1.17rem + 0.48vw, 1.563rem); 133 --size-step-3: clamp(1.424rem, 1.28rem + 0.72vw, 1.875rem); 134 --space-s: clamp(0.75rem, 0.69rem + 0.29vw, 0.938rem); 135 --space-m: clamp(1rem, 0.92rem + 0.39vw, 1.25rem); 136 --space-l: clamp(1.5rem, 1.38rem + 0.58vw, 1.875rem); 137 --color-primary: #089ce7; 138 --color-dark: #252525; 139 --color-light: #f9f9f9; 140 } 141 142 /* Apply monospace font globally */ 143 body { 144 font-family: var(--font-base); 145 } 146 147 .prose { 148 font-family: var(--font-base); 149 font-size: var(--size-step-0); 150 line-height: 1.7; 151 } 152 .prose { 153 max-width: 65ch; 154 color: #24292f; 155 } 156 /* Interactive (.mdx) posts: keep body text at a readable measure, but 157 let the marimo widget break out (centered) wider than the text 158 column. The WidgetDAG is content-sized and its flex layout shrinks 159 its nodes to fit the container, so it needs its natural width or the 160 convolution previews get squeezed. */ 161 .prose.prose-wide-widgets { 162 max-width: none; 163 } 164 .prose.prose-wide-widgets > :where(p, h1, h2, h3, h4, h5, h6, ul, ol, blockquote, pre, table) { 165 max-width: 65ch; 166 } 167 /* Make the widget a block-level horizontal-scroll container so an 168 oversized graph scrolls within the widget instead of the page. */ 169 .prose.prose-wide-widgets .marimo-island-host { 170 display: block; 171 overflow-x: auto; 172 } 173 /* Keep the WidgetDAG at its natural size: render at content width and 174 stop the flex layout (and the prose img rule) from shrinking the 175 convolution previews to fit a narrow container. */ 176 .prose.prose-wide-widgets [data-wdag-root] { 177 width: max-content; 178 } 179 .prose.prose-wide-widgets [data-wdag-root] img { 180 flex-shrink: 0; 181 max-width: none; 182 } 183 /* Only break out beyond the text frame on viewports wide enough to 184 hold the widget; on narrow screens it stays in-column. */ 185 @media (min-width: 900px) { 186 .prose.prose-wide-widgets .marimo-island-host { 187 width: max-content; 188 max-width: min(900px, 96vw); 189 position: relative; 190 left: 50%; 191 transform: translateX(-50%); 192 } 193 } 194 .dark .prose { 195 color: #c9d1d9; 196 } 197 .dark .katex, 198 .dark .katex-display { 199 color: #c9d1d9; 200 } 201 202 /* Typography */ 203 .prose h1 { 204 font-size: var(--size-step-3); 205 font-weight: 600; 206 line-height: 1.2; 207 letter-spacing: -0.02em; 208 text-wrap: balance; 209 margin-top: 1.5em; 210 margin-bottom: 0.5em; 211 color: #24292f; 212 } 213 214 .prose h2 { 215 font-size: var(--size-step-2); 216 font-weight: 600; 217 line-height: 1.2; 218 letter-spacing: -0.02em; 219 text-wrap: balance; 220 margin-top: 1.5em; 221 margin-bottom: 0.5em; 222 color: #24292f; 223 } 224 225 .prose h3, 226 .prose h4 { 227 font-size: var(--size-step-1); 228 font-weight: 600; 229 line-height: 1.2; 230 text-wrap: balance; 231 margin-top: 1.5em; 232 margin-bottom: 0.5em; 233 color: #24292f; 234 } 235 236 .dark .prose h1, 237 .dark .prose h2, 238 .dark .prose h3, 239 .dark .prose h4 { 240 color: #c9d1d9; 241 } 242 243 /* Paragraph spacing */ 244 .prose p { 245 margin-top: 1.4em; 246 margin-bottom: 1.4em; 247 text-wrap: pretty; 248 } 249 250 .prose p:first-child { 251 margin-top: 0; 252 } 253 254 .prose p:last-child { 255 margin-bottom: 0; 256 } 257 258 /* Links */ 259 .prose a { 260 color: #0969da; 261 text-decoration: underline; 262 text-decoration-color: rgba(9, 105, 218, 0.3); 263 text-underline-offset: 2px; 264 transition: all 0.2s ease; 265 } 266 .prose a:hover { 267 color: #0550ae; 268 text-decoration-color: #0550ae; 269 } 270 .dark .prose a { 271 color: #58a6ff; 272 text-decoration-color: rgba(88, 166, 255, 0.3); 273 } 274 .dark .prose a:hover { 275 color: #79c0ff; 276 text-decoration-color: #79c0ff; 277 } 278 279 /* Code blocks */ 280 .prose pre { 281 background-color: #f8f9fb; 282 border: 1px solid #e2e6ea; 283 border-radius: 8px; 284 padding: 1.25em; 285 overflow-x: auto; 286 position: relative; 287 margin-top: 1.5em; 288 margin-bottom: 1.5em; 289 } 290 .dark .prose pre { 291 background-color: #161b22; 292 border-color: #30363d; 293 } 294 295 .prose pre code { 296 background-color: transparent; 297 border: none; 298 padding: 0; 299 border-radius: 0; 300 font-size: 0.8125em; 301 line-height: 1.6; 302 color: #24292f; 303 display: block; 304 } 305 306 .dark .prose pre code { 307 color: #e6edf3; 308 } 309 310 .prose code { 311 font-family: "JetBrains Mono", SFMono-Regular, C
311onsolas, Liberation Mono, Menlo, monospace; 312 font-size: 0.875em; 313 padding: 0.15em 0.4em; 314 background-color: #f0f3f6; 315 border: 1px solid #e2e6ea; 316 border-radius: 4px; 317 color: #24292f; 318 } 319 .dark .prose code { 320 background-color: #1c2128; 321 border-color: #30363d; 322 color: #e6edf3; 323 } 324 325 /* Fixed code line highlighting */ 326 .prose pre code .line { 327 display: block; 328 margin: 0; 329 padding: 0 1em; 330 margin-left: -1em; 331 margin-right: -1em; 332 min-height: 1.5em; 333 line-height: 1.5; 334 transition: background-color 0.2s ease; 335 } 336 337 .prose pre code .line:empty::before { 338 content: "\200B"; /* Zero-width space to maintain line height */ 339 } 340 341 .prose pre code .line:hover { 342 background-color: rgba(56, 139, 253, 0.1); 343 } 344 345 .dark .prose pre code .line:hover { 346 background-color: rgba(56, 139, 253, 0.15); 347 } 348 349 /* Code block titles */ 350 .code-block-wrapper { 351 margin: 1em 0; 352 border-radius: 8px; 353 overflow: hidden; 354 border: 1px solid #e2e6ea; 355 } 356 357 .dark .code-block-wrapper { 358 border-color: #30363d; 359 } 360 361 .code-block-wrapper .prose pre, 362 .code-block-wrapper pre { 363 border: none; 364 border-radius: 0; 365 } 366 367 .code-block-title { 368 background-color: #f0f3f6; 369 color: #57606a; 370 padding: 0.5rem 1rem; 371 font-size: 0.8125rem; 372 font-family: "JetBrains Mono", SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; 373 border-bottom: 1px solid #e2e6ea; 374 } 375 376 /* Dark mode code block titles */ 377 .dark .code-block-title { 378 background-color: #1c2128; 379 color: #8b949e; 380 border-bottom: 1px solid #30363d; 381 } 382 383 /* Ensure code blocks in wrappers have no top margin */ 384 .code-block-wrapper pre { 385 margin-top: 0; 386 border-top-left-radius: 0; 387 border-top-right-radius: 0; 388 } 389 390 /* Lists - fix rendering */ 391 .prose ul { 392 list-style-type: disc; 393 margin-left: 1.5em; 394 margin-top: 1em; 395 margin-bottom: 1em; 396 } 397 398 .prose ol { 399 list-style-type: decimal; 400 margin-left: 1.5em; 401 margin-top: 1em; 402 margin-bottom: 1em; 403 } 404 405 .prose li { 406 margin-bottom: 0.5em; 407 text-wrap: pretty; 408 } 409 410 .prose li > ul, 411 .prose li > ol { 412 margin-top: 0.5em; 413 margin-bottom: 0.5em; 414 } 415 416 /* Figure and figcaption styles */ 417 figure { 418 margin: 2rem auto 2.5rem auto; 419 display: block; 420 max-width: 100%; 421 text-align: center; 422 } 423 424 figure img { 425 margin: 0 auto; 426 display: inline-block; 427 max-width: 100%; 428 height: auto; 429 } 430 431 figcaption { 432 text-align: center; 433 font-size: 0.875rem; 434 color: #6b7280; 435 margin-top: 0.5rem; 436 padding: 0 1rem; 437 } 438 439 .dark figcaption { 440 color: #9ca3af; 441 } 442 443 /* Image spacing and centering */ 444 .prose img { 445 margin: 2rem auto 2.5rem auto; 446 display: block; 447 max-width: 100%; 448 height: auto; 449 } 450 451 .prose p + img, 452 .prose img + p { 453 margin-top: 1.5em; 454 } 455
456 /* Copy button for code blocks */ 457 .code-copy-button { 458 position: absolute; 459 top: 0.5rem; 460 right: 0.5rem; 461 width: 2rem; 462 height: 2rem; 463 display: flex; 464 align-items: center; 465 justify-content: center; 466 background-color: rgba(255, 255, 255, 0.8); 467 border: 1px solid #e2e6ea; 468 border-radius: 6px; 469 color: #57606a; 470 cursor: pointer; 471 opacity: 0; 472 transition: 473 opacity 0.15s, 474 background-color 0.15s; 475 } 476 477 pre:hover .code-copy-button { 478 opacity: 1; 479 } 480 481 .code-copy-button:hover { 482 background-color: #f0f3f6; 483 color: #24292f; 484 } 485 486 .code-copy-button svg { 487 width: 0.875rem; 488 height: 0.875rem; 489 } 490 491 .dark .code-copy-button { 492 background-color: rgba(22, 27, 34, 0.8); 493 border-color: #30363d; 494 color: #8b949e; 495 } 496 497 .dark .code-copy-button:hover { 498 background-color: #1c2128; 499 color: #e6edf3; 500 } 501 502 /* Blockquotes */ 503 .prose blockquote { 504 border-left: 4px solid #d0d7de; 505 padding-left: 1em; 506 color: #57606a; 507 margin-left: 0; 508 margin-top: 1.5em; 509 margin-bottom: 1.5em; 510 } 511 .dark .prose blockquote { 512 border-left-color: #30363d; 513 color: #8b949e; 514 } 515 516 /* Custom Prism.js styling */ 517 pre.language-python, 518 pre.language-javascript, 519 pre.language-bash, 520 pre.language-css, 521 pre.language-json, 522 pre.language-html, 523 pre.language-toml, 524 pre.language-yaml, 525 pre.language-yml, 526 pre.language-typescript, 527 pre.language-ts, 528 pre.language-jsx, 529 pre.language-tsx, 530 pre.language-markdown, 531 pre.language-md, 532 pre.language-sql, 533 pre.language-docker, 534 pre.language-dockerfile, 535 pre.language-rust, 536 pre.language-rs, 537 pre.language-go { 538 position: relative; 539 } 540 541 .token.comment, 542 .token.prolog, 543 .token.doctype, 544 .token.cdata { 545 color: #6e7781; 546 } 547 548 .token.punctuation { 549 color: #24292f; 550 } 551 552 .token.property, 553 .token.tag, 554 .token.boolean, 555 .token.number, 556 .token.constant, 557 .token.symbol, 558 .token.deleted { 559 color: #0550ae; 560 } 561 562 .token.selector, 563 .token.attr-name, 564 .token.string, 565 .token.char, 566 .token.builtin, 567 .token.inserted { 568 color: #0a3069; 569 } 570 571 .token.operator, 572 .token.entity, 573 .token.url, 574 .language-css .token.string, 575 .style .token.string { 576 color: #8250df; 577 } 578 579 .token.atrule, 580 .token.attr-value, 581 .token.keyword { 582 color: #116329; 583 } 584 585 .token.function, 586 .token.class-name { 587 color: #953800; 588 } 589 590 .token.regex, 591 .token.important, 592 .token.variable { 593 color: #953800; 594 } 595 596 .dark .token.comment, 597 .dark .token.prolog, 598 .dark .token.doctype, 599 .dark .token.cdata { 600 color: #8b949e; 601 } 602 603 .dark .token.punctuation { 604 color: #c9d1d9; 605 } 606 607 .dark .token.property, 608 .dark .token.tag, 609 .dark .token.boolean, 610 .dark .token.number, 611 .dark .token.constant, 612 .dark .token.symbol, 613 .dark .token.deleted { 614 color: #79c0ff; 615 } 616 617 .dark .token.selector, 618 .dark .token.attr-name, 619 .dark .token.string, 620 .dark .token.char, 621 .dark .token.builtin, 622 .dark .token.inserted { 623 color: #a5d6ff; 624 } 625 626 .dark .token.operator, 627 .dark .token.entity, 628 .dark .token.url, 629 .dark .language-css .token.string, 630 .dark .style .token.string { 631 color: #d2a8ff; 632 } 633 634 /* Fix Tailwind conflict with Prism TOML tokens */ 635 .prose pre code .token.table { 636 display: inline !important; 637 } 638 639 .dark .token.atrule, 640 .dark .token.attr-value, 641 .dark .token.keyword { 642 color: #7ee787; 643 } 644 645 .dark .token.function, 646 .dark .token.class-name { 647 color: #d2a8ff; 648 } 649 650 .dark .token.regex, 651 .dark .token.important, 652 .dark .token.variable { 653 color: #f0883e; 654 } 655 656 /* Responsive iframe container */ 657 .prose .video-container, 658 .prose .iframe-container { 659 position: relative; 660 width: 100%; 661 padding-bottom: 56.25%; /* 16:9 aspect ratio */ 662 height: 0; 663 overflow: hidden; 664 margin: 2rem 0; 665 } 666 667 .prose .video-container iframe, 668 .prose .iframe-container iframe { 669 position: absolute; 670 top: 0; 671 left: 0; 672 width: 100%; 673 height: 100%; 674 border: 0; 675 } 676 677 /* Direct iframe styling for backwards compatibility */ 678 .prose iframe { 679 width: 100%; 680 max-width: 100%; 681 height: auto; 682 aspect-ratio: 16 / 9; 683 margin: 2rem 0; 684 } 685 686 /* Table styling */ 687 .prose table { 688 width: 100%; 689 border-collapse: collapse; 690 margin: 2rem 0; 691 font-size: 0.95em; 692 border-radius: 8px; 693 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 694 } 695 696 .prose thead { 697 background-color: #f6f8fa; 698 border-bottom: 2px solid #d0d7de; 699 } 700 701 .dark .prose thead { 702 background-color: #161b22; 703 border-bottom-color: #30363d; 704 } 705 706 .prose th { 707 padding: 0.75rem 1rem; 708 text-align: left; 709 font-weight: 600; 710 color: #24292f; 711 } 712 713 .dark .prose th { 714 color: #f0f6fc; 715 } 716 717 .prose td { 718 padding: 0.75rem 1rem; 719 text-align: left; 720 border-bottom: 1px solid #d0d7de; 721 } 722 723 .dark .prose td { 724 border-bottom-color: #30363d; 725 } 726 727 .prose tbody tr:last-child td { 728 border-bottom: none; 729 } 730 731 .prose tbody tr { 732 background-color: #ffffff; 733 transition: background-color 0.2s ease; 734 } 735 736 .dark .prose tbody tr { 737 background-color: #0d1117; 738 } 739 740 .prose tbody tr:hover { 741 background-color: #f3f4f6; 742 } 743 744 .dark .prose tbody tr:hover { 745 background-color: #1c2128; 746 } 747 748 /* Responsive tables */ 749 @media (max-width: 640px) { 750 .prose table { 751 font-size: 0.875em; 752 } 753 754 .prose th, 755 .prose td { 756 padding: 0.5rem 0.75rem; 757 } 758 759 .prose iframe { 760 margin: 1.5rem 0; 761 } 762 763 .prose .video-container, 764 .prose .iframe-container { 765 margin: 1.5rem 0; 766 } 767 } 768 </style> 769
769<script> 770 // Set theme based on user preference 771 function setTheme(theme) { 772 if (theme === "dark") { 773 document.documentElement.classList.add("dark"); 774 document.documentElement.classList.remove("light"); 775 localStorage.setItem("theme", "dark"); 776 } else { 777 document.documentElement.classList.add("light"); 778 document.documentElement.classList.remove("dark"); 779 localStorage.setItem("theme", "light"); 780 } 781 } 782 783 // Check for saved theme preference or use the system preference 784 const savedTheme = localStorage.getItem("theme"); 785 if (savedTheme) { 786 setTheme(savedTheme); 787 } else if ( 788 window.matchMedia("(prefers-color-scheme: dark)").matches 789 ) { 790 setTheme("dark"); 791 } else { 792 setTheme("light"); 793 } 794 </script>
794 795 <!-- Prism JS - Core first, then components --> 796
796<script> 797 // Theme toggle 798 document.addEventListener("DOMContentLoaded", function () { 799 document 800 .getElementById("theme-toggle") 801 .addEventListener("click", function () { 802 const currentTheme = 803 localStorage.getItem("theme") || "light"; 804 if (currentTheme === "light") { 805 setTheme("dark"); 806 } else { 807 setTheme("light"); 808 } 809 }); 810 }); 811 812 // Function to copy text to clipboard with fallback 813 function copyTextToClipboard(text, callback) { 814 // Fallback for browsers that don't support clipboard API 815 if (!navigator.clipboard) { 816 const textArea = document.createElement("textarea"); 817 textArea.value = text; 818 819 // Make the textarea out of viewport 820 textArea.style.position = "fixed"; 821 textArea.style.left = "-999999px"; 822 textArea.style.top = "-999999px"; 823 document.body.appendChild(textArea); 824 textArea.focus(); 825 textArea.select(); 826 827 try { 828 const successful = document.execCommand("copy"); 829 callback(successful); 830 } catch (err) { 831 console.error("Fallback: Could not copy text: ", err); 832 callback(false); 833 } 834 835 document.body.removeChild(textArea); 836 return; 837 } 838 839 // Use Clipboard API if available 840 navigator.clipboard 841 .writeText(text) 842 .then(() => callback(true)) 843 .catch((err) => { 844 console.error( 845 "Clipboard API: Could not copy text: ", 846 err, 847 ); 848 callback(false); 849 }); 850 } 851 852 // Function to add copy buttons to code blocks 853 function addCopyButtons() { 854 document.querySelectorAll("pre").forEach(function (pre) { 855 // Skip if a copy button is already added 856 if (pre.querySelector(".code-copy-button")) return; 857 858 // Add copy button with icon 859 const copyButton = document.createElement("button"); 860 copyButton.className = "code-copy-button"; 861 copyButton.innerHTML = ` 862 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 863 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /> 864 </svg> 865 `; 866 867 // Add tooltip 868 copyButton.title = "Copy to clipboard"; 869 870 // Add click event to copy code 871 copyButton.addEventListener("click", function (e) { 872 e.preventDefault(); 873 e.stopPropagation(); 874 875 // Get the code content, preserving newlines properly 876 const codeElement = pre.querySelector("code") || pre; 877 const code = 878 codeElement.innerText || codeElement.textContent; 879 880 // Copy to clipboard with fallback 881 copyTextToClipboard(code, function (success) { 882 if (success) { 883 // Show success state 884 copyButton.innerHTML = ` 885 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 886 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> 887 </svg> 888 `; 889 setTimeout(() => { 890 copyButton.innerHTML = ` 891 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 892 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /> 893 </svg> 894 `; 895 }, 2000); 896 } else { 897 // Show error state 898 copyButton.innerHTML = ` 899 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 900 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 901 </svg> 902 `; 903 setTimeout(() => { 904 copyButton.innerHTML = ` 905 <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 906 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" /> 907 </svg> 908 `; 909 }, 2000); 910 } 911 }); 912 }); 913 914 // Append the button to the pre element 915 pre.appendChild(copyButton); 916 }); 917 } 918 919 // Add language classes and copy buttons when DOM is loaded 920 document.addEventListener("DOMContentLoaded", function () { 921 // Process code blocks for Prism.js 922 document.querySelectorAll("pre code").forEach(function (code) { 923 // Find the language from the class 924 let lang = "text"; 925 const classMatch = code.className.match(/language-(\w+)/); 926 if (classMatch) { 927 lang = classMatch[1]; 928 } else { 929 // Add a default language class if none exists 930 code.classList.add("language-text"); 931 } 932 933 // Make sure the parent pre element also has the language class 934 if ( 935 !code.parentElement.classList.contains( 936 `language-${lang}`, 937 ) 938 ) { 939 code.parentElement.classList.add(`language-${lang}`); 940 } 941 }); 942 943 // Highlight code blocks 944 if (window.Prism) { 945 Prism.highlightAll(); 946 } 947 948 // Fixed line wrapping for hover highlighting 949 // Wait a bit for Prism to finish its work 950 setTimeout(function () { 951 document 952 .querySelectorAll("pre code") 953 .forEach(function (code) { 954 // Only process if not already processed 955 if (code.querySelector(".line")) return; 956 957 // Get the HTML content after Prism highlighting 958 const content = code.innerHTML; 959 960 // Split by newlines but preserve the line breaks 961 const lines = content.split("\n"); 962 963 // Wrap each line, preserving empty lines 964 const wrappedLines = lines.map( 965 function (line, index) { 966 // For last empty line, don't wrap 967 if ( 968 index === lines.length - 1 && 969 line === "" 970 ) { 971 return ""; 972 } 973 // Use span with proper display handling 974 return ( 975 '<span class="line">
975' + line + "</span>" 976 ); 977 }, 978 ); 979 980 // Join without adding extra newlines 981 code.innerHTML = wrappedLines.join(""); 982 }); 983 }, 100); 984 985 // Add copy buttons 986 addCopyButtons(); 987 }); 988 989 // Add copy buttons when Prism is done highlighting 990 if (window.Prism) { 991 Prism.hooks.add("complete", function () { 992 addCopyButtons(); 993 }); 994 } 995 </script>
995 996 </head> 997 <body 998 class="bg-white dark:bg-dark-bg text-secondary dark:text-dark-secondary min-h-screen" 999 > 1000 1001 <header class="container mx-auto max-w-3xl px-4 sm:px-6"> 1002 <!-- Newspaper-style header --> 1003 <div 1004 class="mb-4 pb-4 border-b-4 border-gray-800 dark:border-gray-600" 1005 > 1006 <div class="flex justify-between items-center"> 1007 <a href="/" class="hover:no-underline"> 1008 <h1 1009 class="text-5xl font-bold text-gray-900 dark:text-gray-100 mb-2 pt-4" 1010 > 1011 koaning.io 1012 </h1> 1013 </a> 1014 <button 1015 id="theme-toggle" 1016 class="text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-dark-primary" 1017 > 1018 <svg 1019 xmlns="http://www.w3.org/2000/svg" 1020 width="20" 1021 height="20" 1022 fill="currentColor" 1023 class="bi bi-moon-stars dark:hidden"
1024 viewBox="0 0 16 16" 1025 > 1026 <path 1027 d="M6 .278a.768.768 0 0 1 .08.858 7.208 7.208 0 0 0-.878 3.46c0 4.021 3.278 7.277 7.318 7.277.527 0 1.04-.055 1.533-.16a.787.787 0 0 1 .81.316.733.733 0 0 1-.031.893A8.349 8.349 0 0 1 8.344 16C3.734 16 0 12.286 0 7.71 0 4.266 2.114 1.312 5.124.06A.752.752 0 0 1 6 .278zM4.858 1.311A7.269 7.269 0 0 0 1.025 7.71c0 4.02 3.279 7.276 7.319 7.276a7.316 7.316 0 0 0 5.205-2.162c-.337.042-.68.063-1.029.063-4.61 0-8.343-3.714-8.343-8.29 0-1.167.242-2.278.681-3.286z" 1028 /> 1029 <path 1030 d="M10.794 3.148a.217.217 0 0 1 .412 0l.387 1.162c.173.518.579.924 1.097 1.097l1.162.387a.217.217 0 0 1 0 .412l-1.162.387a1.734 1.734 0 0 0-1.097 1.097l-.387 1.162a.217.217 0 0 1-.412 0l-.387-1.162A1.734 1.734 0 0 0 9.31 6.593l-1.162-.387a.217.217 0 0 1 0-.412l1.162-.387a1.734 1.734 0 0 0 1.097-1.097l.387-1.162a.217.217 0 0 1 .412 0l.387 1.162zM13.863.099a.145.145 0 0 1 .274 0l.258.774c.115.346.386.617.732.732l.774.258a.145.145 0 0 1 0 .274l-.774.258a1.156 1.156 0 0 0-.732.732l-.258.774a.145.145 0 0 1-.274 0l-.258-.774a.145.145 0 0 1-.732-.732l-.774-.258a.145.145 0 0 1 0-.274l.774-.258c.346-.115.617-.386.732-.732L13.863.1z" 1031 /> 1032 </svg> 1033 <svg 1034 xmlns="http://www.w3.org/2000/svg" 1035 width="20" 1036 height="20" 1037 fill="currentColor" 1038 class="bi bi-sun hidden dark:block"
1039 viewBox="0 0 16 16" 1040 > 1041 <path 1042 d="M8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0 1a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM8 0a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 0zm0 13a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-1 0v-2A.5.5 0 0 1 8 13zm8-5a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2a.5.5 0 0 1 .5.5zM3 8a.5.5 0 0 1-.5.5h-2a.5.5 0 0 1 0-1h2A.5.5 0 0 1 3 8zm10.657-5.657a.5.5 0 0 1 0 .707l-1.414 1.415a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm-9.193 9.193a.5.5 0 0 1 0 .707L3.05 13.657a.5.5 0 0 1-.707-.707l1.414-1.414a.5.5 0 0 1 .707 0zm9.193 2.121a.5.5 0 0 1-.707 0l-1.414-1.414a.5.5 0 0 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .707zM4.464 4.465a.5.5 0 0 1-.707 0L2.343 3.05a.5.5 0 1 1 .707-.707l1.414 1.414a.5.5 0 0 1 0 .708z" 1043 /> 1044 </svg> 1045 </button> 1046 </div> 1047 <div class="flex justify-between items-center"> 1048 <p class="text-lg text-gray-700 dark:text-gray-300"> 1049 Blog of a data person 1050 </p> 1051 <!-- Desktop menu --> 1052 <div 1053 class="mt-3 hidden md:flex space-x-6 text-gray-700 dark:text-gray-300" 1054 > 1055 <a 1056 href="/posts" 1057 class="hover:text-primary dark:hover:text-dark-primary" 1058 >All Posts</a 1059 > 1060 <a 1061 href="/apps/" 1062 class="hover:text-primary dark:hover:text-dark-primary" 1063 >Apps</a 1064 > 1065 <a 1066 href="/about/" 1067 class="hover:text-primary dark:hover:text-dark-primary" 1068 >About</a 1069 > 1070 <a 1071 href="/feed.xml" 1072 class="hover:text-primary dark:hover:text-dark-primary" 1073 >RSS</a 1074 > 1075 <a 1076 href="https://koaning.substack.com/subscribe?utm_source=menu&simple=true&next=https%3A%2F%2Fkoaning.substack.com%2F" 1077 class="hover:text-primary dark:hover:text-dark-primary" 1078 >Subscribe</a 1079 > 1080 </div> 1081 1082 <!-- Mobile hamburger menu --> 1083 <div class="md:hidden" x-data="{ open: false }"> 1084 <button 1085 @click="open = !open" 1086 class="text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary focus:outline-none mt-3 z-20" 1087 > 1088 <svg 1089 xmlns="http://www.w3.org/2000/svg" 1090 class="h-6 w-6" 1091 fill="none"
1092 viewBox="0 0 24 24" 1093 stroke="currentColor" 1094 > 1095 <path 1096 stroke-linecap="round" 1097 stroke-linejoin="round" 1098 stroke-width="2" 1099 d="M4 6h16M4 12h16M4 18h16" 1100 /> 1101 </svg> 1102 </button> 1103 1104 <div 1105 x-show="open" 1106 @click.away="open = false" 1107 class="absolute right-0 mt-2 py-2 w-48 bg-white dark:bg-dark-card rounded-md shadow-lg z-10" 1108 > 1109 <a 1110 href="/" 1111 class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary" 1112 >Home</a 1113 > 1114 <a 1115 href="/posts" 1116 class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary" 1117 >All Posts</a 1118 > 1119 <a 1120 href="/apps/" 1121 class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary" 1122 >Apps</a 1123 > 1124 <a 1125 href="/about/" 1126 class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary" 1127 >About</a 1128 > 1129 <a 1130 href="/feed.xml" 1131 class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary" 1132 >RSS</a 1133 > 1134 <a 1135 href="https://koaning.substack.com/subscribe?utm_source=menu&simple=true&next=https%3A%2F%2Fkoaning.substack.com%2F" 1136 class="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-primary dark:hover:text-dark-primary" 1137 >Subscribe</a 1138 > 1139 </div> 1140 </div> 1141 </div> 1142 </div> 1143 </header> 1144 1145 1146 <main class="container mx-auto max-w-3xl px-4 sm:px-6 py-8"> 1147 1148<div class="prose dark:prose-invert max-w-3xl mx-auto"> 1149 <figure> 1150 <img src="https://pbs.twimg.com/profile_images/869526969809076226/o12QmaVF_400x400.jpg" alt="Vincent Warmerdam" class="px-10 max-w-lg max-h-lg" style="margin-left:auto; margin-right: auto; width: 100%; display: block;"> 1151 <figcaption>Hi. My name is Vincent. <b>AskMeAnything[tm]</b>.</figcaption> 1152</figure> 1153<p>This is the bio I typically use for conferences:</p> 1154<blockquote> 1155<p>Vincent is a senior data professional who worked as an engineer, researcher, team lead, and educator in the past. You might know him from tech talks with an attempt to defend common sense over hype in the data space. He is especially interested in understanding algorithmic systems so that one may prevent failure. As such, he has always had a preference to keep calm, to try and understand the failure scenario, and to check the dataset before flowing all the tensors.</p> 1156</blockquote> 1157<p>He currently works at marimo, where he spends his time rethinking everything related to computers.</p> 1158<p>
1158Sometimes I also add this bit:</p> 1159<blockquote> 1160<p>Vincent is also well known for creating a lot of open-source packages, which have been downloaded well over two million times. He's also well known for his <a href="https://calmcode.io">calmcode.io</a>/<a href="https://remember.cards">remember.cards</a> projects, as well his blog over at <a href="https://koaning.io">koaning.io</a>. You may have also seen him on YouTube where the explains, amoung other things, why ergonomic keyboards are friggin' cool.</p> 1161</blockquote> 1162<h2>Fun Facts</h2> 1163<p>I also keep a list of fun facts that people might know me for.</p> 1164<ul> 1165<li>me on the front page of <a href="https://twitter.com/fishnets88/status/883032331778498560">reddit</a></li> 1166<li>proud creator of <a href="https://calmcode.io">calmcode.io</a></li> 1167<li>a digital nomad with an <a href="http://koaning.io/posts/digital-nomad/">amazing postcard</a></li> 1168<li>a preferred Rstudio training partner</li> 1169<li>co-founder and co-chair of <a href="https://amsterdam.pydata.org/">PyData Amsterdam</a></li> 1170<li>co-founder of the <a href="https://satrdays.org/">satRdays Amsterdam conference</a></li> 1171<li>co-creator of some <a href="https://github.com/koaning/">open source packages</a></li> 1172<li>a data/open source evangelist with a track record of <a href="https://www.youtube.com/watch?v=C9p7suS-NGk&ab_channel=PyData">speaking</a></li> 1173<li>involved with many data projects</li> 1174<li>helping out with the <a href="https://normconf.com/">normconf</a> conference</li> 1175<li>featured in a <a href="https://www.youtube.com/watch?v=cDofhN-RJqg&t=373s&ab_channel=3Blue1Brown">3b1b maths video</a></li> 1176<li>featured in a <a href="https://www.youtube.com/watch?v=0B5eIE_1vpU">freeCodeCamp video</a></li> 1177</ul> 1178<h2>Past</h2> 1179<p>Before joining marimo I worked as a outreach engineer over at <a href="https://probabl.ai">probabl</a> with the <a href="https://scikit-learn.org/">scikit-learn people</a>. Before that I was a machine learning engineer over at <a href="https://explosion.ai/">Explosion</a> who are behind tools such as <a href="https://spacy.io/">spaCy</a> and <a href="https://prodi.gy/">Prodigy</a>. Working here was a blast! Before joining Explosion, I worked as a Research Advocate at Rasa where I collaborated with the research team to explain and understand conversational systems better.</p> 1180<p>I was also a consultant for a bit. But I am still recovering from that.</p> 1181<h2>Disclosures</h2> 1182<p>There are a few things I should disclose:</p> 1183<ul> 1184<li>I am a <a href="https://marimo.io">marimo</a> employee. They are very open and let me do my own thing on the side, but I should disclose this because it does influence what tools I use.</li> 1185<li>I created <a href="https://calmcode.io">calmcode</a> and this brings in a small amount of revenue. Most of the content there is free but I do have a premium version that is a bit more in-depth. I also host ads there via <a href="https://www.ethicalads.io/">EthicalAds</a>.</li> 1186<li>I will occasionally make YouTube videos for 3rd parties as well as do some dev-rel related consulting. Companies that I have advised this way include <a href="https://www.conductor.build/">conductor.build</a> and <a href="https://exe.dev">exe.dev</a>. My policy here is that it should be a fun activity, so I only consider doing this kind of work for companies that build tools that I really (!) enjoy using.</li> 1187<li>I provide ad-hoc advice services to folks who reach out to me personally, assuming there is no conflict of interest. Investors have reached out to me in the past for paid advice and I may certainly do these in the future too. These talks, understandibly, come with a strict non-disclosure agreement.</li> 1188<li>I review and use ergonomic keyboards. I refuse affiliate links, but I have accepted keyboards in return for a review on YouTube. Again, and vendors really need to understand that this is key, I do not accept affiliate links what so ever! Adding an incentive to my review would ruin the reputation of the channel. Whenever I produce content that is sponsored the video will make that abundantly clear.</li> 1189</ul> 1190<h2>Concluding</h2> 1191<p>I believe in open tools/collaboration and I certainly donât mind to evangelise from time to time. But it does feel like a lot of tools are still missing.</p> 1192<blockquote> 1193<p>
1193The future is pretty awesome, all we have to do is build it.</p> 1194</blockquote> 1195<h2>Note</h2> 1196<p>This blog has a matured a few times in the past. An earlier version built with Pelican can be found <a href="http://koaning.io.s3-website.eu-west-2.amazonaws.com/old/">here</a>. A more recent version, built with Distill, can be found <a href="https://koaning.github.io/koaning-io-old/">here</a>. On every upgrade I only decided to port a subset of the blogposts, partially to save time, but also because the blog matures over time ... and some of the old blogposts feel less relevant.</p> 1197 1198</div> 1199 1200 </main> 1201 1202 <footer class="border-t border-gray-200 dark:border-gray-700 py-8 mt-8"> 1203 <div 1204 class="container mx-auto px-4 sm:px-6 text-center text-gray-600 dark:text-gray-400" 1205 > 1206 <p>© 2026 Vincent D. Warmerdam. All rights reserved.</p> 1207 </div> 1208 </footer> 1209 1210 <!-- Search Modal --> 1211 <div 1212 id="searchModal" 1213 class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-start justify-center pt-20" 1214 > 1215 <div 1216 class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl mx-4" 1217 > 1218 <!-- Search Input --> 1219 <div class="relative"> 1220 <div 1221 class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none" 1222 > 1223 <svg 1224 class="h-5 w-5 text-gray-400" 1225 fill="none" 1226 stroke="currentColor"
1227 viewBox="0 0 24 24" 1228 > 1229 <path 1230 stroke-linecap="round" 1231 stroke-linejoin="round" 1232 stroke-width="2" 1233 d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" 1234 ></path> 1235 </svg> 1236 </div> 1237 <input 1238 type="text" 1239 id="modalSearchInput" 1240 placeholder="Search posts..." 1241 class="w-full pl-12 pr-4 py-4 text-lg border-0 border-b border-gray-200 dark:border-gray-700 bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:border-gray-400 rounded-t-lg" 1242 /> 1243 <div 1244 class="absolute inset-y-0 right-0 pr-4 flex items-center" 1245 > 1246 <span 1247 class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded border" 1248 >ESC</span 1249 > 1250 </div> 1251 </div> 1252 1253 <!-- Results --> 1254 <div id="modalResults" class="max-h-96 overflow-y-auto"> 1255 <div id="modalNoResults" class="hidden p-8 text-center"> 1256 <div class="text-gray-500 dark:text-gray-400"> 1257 <p class="text-sm"> 1258 No posts found. Try a different search term. 1259 </p> 1260 </div> 1261 </div> 1262 1263 <div 1264 id="modalResultsList" 1265 class="divide-y divide-gray-200 dark:divide-gray-700" 1266 > 1267 <!-- Results will be populated here --> 1268 </div> 1269 </div> 1270 1271 <!-- Footer --> 1272 <div 1273 class="px-4 py-3 bg-gray-50 dark:bg-gray-800 rounded-b-lg border-t border-gray-200 dark:border-gray-700" 1274 > 1275 <div 1276 class="flex justify-between text-xs text-gray-500 dark:text-gray-400" 1277 > 1278 <span>ââ to navigate</span> 1279 <span>âµ to select</span> 1280 <span>ESC to close</span> 1281 </div> 1282 </div> 1283 </div> 1284 </div> 1285 1286
1286<script> 1287 document.addEventListener("DOMContentLoaded", function () { 1288 const modal = document.getElementById("searchModal"); 1289 const modalInput = document.getElementById("modalSearchInput"); 1290 const modalResults = document.getElementById("modalResults"); 1291 const modalNoResults = 1292 document.getElementById("modalNoResults"); 1293 const modalResultsList = 1294 document.getElementById("modalResultsList"); 1295 1296 let modalSelectedIndex = -1; 1297 let modalVisibleItems = []; 1298 let allPosts = []; 1299 1300 // Available commands 1301 const commands = [ 1302 { 1303 title: "Toggle Dark Mode", 1304 description: "Switch between light and dark themes", 1305 action: () => { 1306 const currentTheme = 1307 localStorage.getItem("theme") || "light"; 1308 setTheme( 1309 currentTheme === "light" ? "dark" : "light", 1310 ); 1311 }, 1312 icon: "ð", 1313 }, 1314 { 1315 title: "Toggle Light Mode", 1316 description: "Switch between light and dark themes", 1317 action: () => { 1318 const currentTheme = 1319 localStorage.getItem("theme") || "light"; 1320 setTheme( 1321 currentTheme === "dark" ? "light" : "dark", 1322 ); 1323 }, 1324 icon: "âï¸", 1325 }, 1326 ]; 1327 1328 // Load posts data from the posts page 1329 function loadPostsData() { 1330 if (allPosts.length > 0) return Promise.resolve(); 1331 1332 return fetch("/posts/") 1333 .then((response) => response.text()) 1334 .then((html) => { 1335 const parser = new DOMParser(); 1336 const doc = parser.parseFromString( 1337 html, 1338 "text/html", 1339 ); 1340 const postItems = 1341 doc.querySelectorAll(".post-item"); 1342 1343 allPosts = Array.from(postItems).map((item) => ({ 1344 title: item.dataset.title, 1345 description: item.dataset.description || "", 1346 tags: item.dataset.tags 1347 ? item.dataset.tags 1348 .split(",") 1349 .filter((t) => t.trim()) 1350 : [], 1351 year: item.dataset.year, 1352 url: item.dataset.url, 1353 displayTitle: item 1354 .querySelector("h3") 1355 .textContent.trim(), 1356 displayDate: item 1357 .querySelector("time") 1358 .textContent.trim(), 1359 })); 1360 }) 1361 .catch((err) => { 1362 console.error("Failed to load posts data:", err); 1363 }); 1364 } 1365 1366 function updateModalSelection() { 1367 modalVisibleItems.forEach((item, index) => { 1368 const element = modalResultsList.children[index]; 1369 if (element) { 1370 if (index === modalSelectedIndex) { 1371 element.classList.add( 1372 "bg-gray-100", 1373 "dark:bg-gray-800", 1374 ); 1375 } else { 1376 element.classList.remove( 1377 "bg-gray-100", 1378 "dark:bg-gray-800", 1379 ); 1380 } 1381 } 1382 }); 1383 } 1384 1385 function searchPosts(query) { 1386 if (!query.trim()) { 1387 modalResultsList.innerHTML = ""; 1388 modalNoResults.classList.add("hidden"); 1389 modalVisibleItems = []; 1390 modalSelectedIndex = -1; 1391 return; 1392 } 1393 1394 const searchTerm = query.toLowerCase(); 1395 1396 // Search commands 1397 const matchingCommands = commands.filter( 1398 (cmd) => 1399 cmd.title.toLowerCase().includes(searchTerm) || 1400 cmd.description 1401 .toLowerCase() 1402 .includes(searchTerm) || 1403 searchTerm.includes("dark") || 1404 searchTerm.includes("light") || 1405 searchTerm.includes("theme"), 1406 ); 1407 1408 // Search posts 1409 const matchingPosts = allPosts.filter( 1410 (post) => 1411 post.title.includes(searchTerm) || 1412 post.description.includes(searchTerm), 1413 ); 1414 1415 modalVisibleItems = [...matchingCommands, ...matchingPosts]; 1416 modalSelectedIndex = -1; 1417 1418 if (modalVisibleItems.length === 0) { 1419 modalResultsList.innerHTML = ""; 1420 modalNoResults.classList.remove("hidden"); 1421 } else { 1422 modalNoResults.classList.add("hidden"); 1423 1424 const commandsHtml = matchingCommands 1425 .map( 1426 (cmd, index) => ` 1427 <div class="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors border-l-4 border-blue-500" data-command="${index}"> 1428 <div class="flex items-center gap-4">
1429 <span class="text-lg w-8 flex-shrink-0">${cmd.icon}</span> 1430 <div class="flex-1"> 1431 <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100"> 1432 ${cmd.title} 1433 </h3> 1434 <p class="text-sm text-gray-600 dark:text-gray-400">${cmd.description}</p> 1435 </div> 1436 <span class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded border">CMD</span> 1437 </div> 1438 </div> 1439 `, 1440 ) 1441 .join(""); 1442 1443 const postsHtml = matchingPosts 1444 .map( 1445 (post, index) => ` 1446 <div class="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors" data-url="${post.url}"> 1447 <div class="flex items-center gap-4"> 1448 <time class="text-sm font-mono text-gray-500 dark:text-gray-500 w-24 flex-shrink-0"> 1449 ${post.displayDate} 1450 </time> 1451 <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 flex-1"> 1452 ${post.displayTitle} 1453 </h3> 1454 ${ 1455 post.tags.length > 0 1456 ? ` 1457 <div class="flex gap-1 flex-shrink-0"> 1458 ${post.tags 1459 .slice(0, 2) 1460 .map( 1461 (tag) => ` 1462 <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-gray-600"> 1463 ${tag} 1464 </span> 1465 `, 1466 ) 1467 .join("")} 1468 ${post.tags.length > 2 ? `<span class="text-xs text-gray-400">+${post.tags.length - 2}</span>` : ""} 1469 </div> 1470 ` 1471 : "" 1472 } 1473 </div> 1474 </div> 1475 `, 1476 ) 1477 .join(""); 1478 1479 modalResultsList.innerHTML = commandsHtml + postsHtml; 1480 1481 // Add click handlers for commands 1482 modalResultsList 1483 .querySelectorAll("[data-command]") 1484 .forEach((element, index) => { 1485 element.addEventListener("click", () => { 1486 matchingCommands[index].action(); 1487 modal.classList.add("hidden"); 1488 modalInput.value = ""; 1489 modalResultsList.innerHTML = ""; 1490 modalSelectedIndex = -1; 1491 }); 1492 }); 1493 1494 // Add click handlers for posts 1495 modalResultsList 1496 .querySelectorAll("[data-url]") 1497 .forEach((element, index) => { 1498 element.addEventListener("click", () => { 1499 window.location.href = element.dataset.url; 1500 }); 1501 }); 1502 } 1503 } 1504 1505 // Keyboard shortcuts 1506 document.addEventListener("keydown", function (e) { 1507 // Cmd+K or Ctrl+K to open 1508 if ((e.metaKey || e.ctrlKey) && e.key === "k") { 1509 e.preventDefault(); 1510 loadPostsData().then(() => { 1511 modal.classList.remove("hidden"); 1512 modalInput.focus(); 1513 }); 1514 } 1515 1516 // ESC to close 1517 if ( 1518 e.key === "Escape" && 1519 !modal.classList.contains("hidden") 1520 ) { 1521 e.preventDefault(); 1522 modal.classList.add("hidden"); 1523 modalInput.value = ""; 1524 modalResultsList.innerHTML = ""; 1525 modalSelectedIndex = -1; 1526 } 1527 }); 1528 1529 // Modal input events 1530 modalInput.addEventListener("input", function () { 1531 searchPosts(this.value); 1532 }); 1533 1534 modalInput.addEventListener("keydown", function (e) { 1535 if (e.key === "ArrowDown") { 1536 e.preventDefault(); 1537 if (modalVisibleItems.length > 0) { 1538 modalSelectedIndex = Math.min( 1539 modalSelectedIndex + 1, 1540 modalVisibleItems.length - 1, 1541 ); 1542 updateModalSelection(); 1543 } 1544 } else if (e.key === "ArrowUp") { 1545 e.preventDefault(); 1546 if (modalVisibleItems.length > 0) { 1547 modalSelectedIndex = Math.max( 1548 modalSelectedIndex - 1, 1549 0, 1550 ); 1551 updateModalSelection(); 1552 } 1553 } else if (e.key === "Enter") { 1554 e.preventDefault(); 1555 if ( 1556 modalSelectedIndex >= 0 && 1557 modalSelectedIndex < modalVisibleItems.length 1558 ) { 1559 const selectedItem = 1560 modalVisibleItems[modalSelectedIndex]; 1561 if (selectedItem.action) { 1562 // It's a command 1563 selectedItem.action(); 1564 modal.classList.add("hidden"); 1565 modalInput.value = ""; 1566 modalResultsList.innerHTML = ""; 1567 modalSelectedIndex = -1; 1568 } else { 1569 // It's a post 1570 window.location.href = selectedItem.url; 1571 } 1572 } 1573 } 1574 }); 1575 1576 // Click outside to close 1577 modal.addEventListener("click", function (e) { 1578 if (e.target === modal) { 1579 modal.classList.add("hidden"); 1580 modalInput.value = ""; 1581 modalResultsList.innerHTML = ""; 1582 modalSelectedIndex = -1; 1583 } 1584 }); 1585 }); 1586 </script>
1586 1587 </body> 1588</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.