a standard.site publication renderer for SvelteKit.

feat: add DocumentRenderer with math and code highlighting support

- Add katex and shiki dependencies for LaTeX math and syntax highlighting
- Export DocumentRenderer component from library index
- Replace custom content rendering with DocumentRenderer in document page
- Apply code formatting fixes across documentation and examples

This introduces proper rich text rendering capabilities including:
- Math equations via KaTeX
- Syntax highlighting via Shiki
- Headers, lists, blockquotes with theme support
- Links, mentions, and inline formatting

Breaking changes: None - this is additive functionality

+1858 -122
+65 -83
EXAMPLES.md
··· 223 223 224 224 <article> 225 225 <h1>{data.post.value.title}</h1> 226 - 226 + 227 227 <!-- Simple date display --> 228 228 <DateDisplay date={data.post.value.publishedAt} /> 229 - 229 + 230 230 <!-- With label and icon --> 231 - <DateDisplay 232 - date={data.post.value.updatedAt} 233 - label="Last updated: " 234 - showIcon={true} 235 - class="text-sm text-ink-600 dark:text-ink-400" 231 + <DateDisplay 232 + date={data.post.value.updatedAt} 233 + label="Last updated: " 234 + showIcon={true} 235 + class="text-ink-600 dark:text-ink-400 text-sm" 236 236 /> 237 - 237 + 238 238 <!-- Custom locale --> 239 239 <DateDisplay date={data.post.value.publishedAt} locale="fr-FR" /> 240 240 </article> ··· 255 255 <TagList tags={data.post.value.tags || []} /> 256 256 257 257 <!-- With theme support --> 258 - <TagList 259 - tags={data.post.value.tags || []} 260 - {hasTheme} 261 - class="mt-4" 262 - /> 258 + <TagList tags={data.post.value.tags || []} {hasTheme} class="mt-4" /> 263 259 ``` 264 260 265 261 ### Using ThemedText ··· 274 270 </script> 275 271 276 272 <!-- Title with theme --> 277 - <ThemedText {hasTheme} element="h1" class="text-4xl font-bold mb-4"> 273 + <ThemedText {hasTheme} element="h1" class="mb-4 text-4xl font-bold"> 278 274 {data.post.value.title} 279 275 </ThemedText> 280 276 ··· 284 280 </ThemedText> 285 281 286 282 <!-- Accent color for links --> 287 - <ThemedText {hasTheme} variant="accent" element="span"> 288 - Read more → 289 - </ThemedText> 283 + <ThemedText {hasTheme} variant="accent" element="span">Read more →</ThemedText> 290 284 ``` 291 285 292 286 ### Combining Utility Components 293 287 294 288 ```svelte 295 289 <script lang="ts"> 296 - import { 297 - ThemedContainer, 298 - ThemedText, 299 - DateDisplay, 300 - TagList 301 - } from 'svelte-standard-site'; 290 + import { ThemedContainer, ThemedText, DateDisplay, TagList } from 'svelte-standard-site'; 302 291 import type { PageData } from './$types'; 303 292 304 293 const { data }: { data: PageData } = $props(); ··· 308 297 309 298 <ThemedContainer {theme} element="article" class="p-8"> 310 299 <!-- Title --> 311 - <ThemedText {hasTheme} element="h1" class="text-4xl font-bold mb-2"> 300 + <ThemedText {hasTheme} element="h1" class="mb-2 text-4xl font-bold"> 312 301 {data.post.value.title} 313 302 </ThemedText> 314 - 303 + 315 304 <!-- Description --> 316 - <ThemedText {hasTheme} opacity={70} element="p" class="text-lg mb-4"> 305 + <ThemedText {hasTheme} opacity={70} element="p" class="mb-4 text-lg"> 317 306 {data.post.value.description} 318 307 </ThemedText> 319 - 308 + 320 309 <!-- Metadata --> 321 - <div class="flex gap-4 mb-6"> 310 + <div class="mb-6 flex gap-4"> 322 311 <DateDisplay date={data.post.value.publishedAt} /> 323 312 {#if data.post.value.updatedAt} 324 - <DateDisplay 325 - date={data.post.value.updatedAt} 326 - label="Updated " 327 - showIcon={true} 328 - /> 313 + <DateDisplay date={data.post.value.updatedAt} label="Updated " showIcon={true} /> 329 314 {/if} 330 315 </div> 331 - 316 + 332 317 <!-- Tags --> 333 318 <TagList tags={data.post.value.tags || []} {hasTheme} /> 334 - 319 + 335 320 <!-- Content --> 336 - <div class="prose max-w-none mt-8"> 321 + <div class="prose mt-8 max-w-none"> 337 322 {@html data.post.value.content} 338 323 </div> 339 324 </ThemedContainer> ··· 347 332 348 333 ```svelte 349 334 <script lang="ts"> 350 - import { 351 - ThemedCard, 352 - ThemedText, 353 - DateDisplay, 354 - TagList 355 - } from 'svelte-standard-site'; 335 + import { ThemedCard, ThemedText, DateDisplay, TagList } from 'svelte-standard-site'; 356 336 import type { Document, Publication, AtProtoRecord } from 'svelte-standard-site'; 357 337 358 338 interface Props { ··· 361 341 } 362 342 363 343 let { document, publication }: Props = $props(); 364 - 344 + 365 345 const theme = $derived(publication?.value.basicTheme); 366 346 const hasTheme = $derived(!!theme); 367 347 const value = $derived(document.value); 368 348 </script> 369 349 370 - <ThemedCard {theme} href="/blog/{document.uri.split('/').pop()}" class="hover:shadow-lg transition-shadow"> 350 + <ThemedCard 351 + {theme} 352 + href="/blog/{document.uri.split('/').pop()}" 353 + class="transition-shadow hover:shadow-lg" 354 + > 371 355 <div class="flex gap-6"> 372 356 {#if value.coverImage} 373 - <img 374 - src={value.coverImage} 375 - alt={value.title} 376 - class="w-32 h-32 object-cover rounded-lg" 377 - /> 357 + <img src={value.coverImage} alt={value.title} class="h-32 w-32 rounded-lg object-cover" /> 378 358 {/if} 379 - 359 + 380 360 <div class="flex-1"> 381 - <ThemedText {hasTheme} element="h3" class="text-2xl font-bold mb-2"> 361 + <ThemedText {hasTheme} element="h3" class="mb-2 text-2xl font-bold"> 382 362 {value.title} 383 363 </ThemedText> 384 - 364 + 385 365 {#if value.description} 386 366 <ThemedText {hasTheme} opacity={70} element="p" class="mb-4 line-clamp-2"> 387 367 {value.description} 388 368 </ThemedText> 389 369 {/if} 390 - 391 - <div class="flex items-center gap-4 mb-3"> 370 + 371 + <div class="mb-3 flex items-center gap-4"> 392 372 <DateDisplay date={value.publishedAt} class="text-sm" /> 393 373 </div> 394 - 374 + 395 375 {#if value.tags?.length} 396 376 <TagList tags={value.tags} {hasTheme} /> 397 377 {/if} ··· 405 385 ```svelte 406 386 <script lang="ts"> 407 387 import { ThemedCard, ThemedText } from 'svelte-standard-site'; 408 - 388 + 409 389 interface Props { 410 390 name: string; 411 391 bio: string; 412 392 avatar?: string; 413 393 theme?: any; 414 394 } 415 - 395 + 416 396 let { name, bio, avatar, theme }: Props = $props(); 417 397 const hasTheme = $derived(!!theme); 418 398 </script> ··· 420 400 <ThemedCard {theme} class="p-6"> 421 401 <div class="flex items-start gap-4"> 422 402 {#if avatar} 423 - <img src={avatar} alt={name} class="w-16 h-16 rounded-full" /> 403 + <img src={avatar} alt={name} class="h-16 w-16 rounded-full" /> 424 404 {/if} 425 - 405 + 426 406 <div> 427 - <ThemedText {hasTheme} element="h3" class="text-xl font-bold mb-2"> 407 + <ThemedText {hasTheme} element="h3" class="mb-2 text-xl font-bold"> 428 408 {name} 429 409 </ThemedText> 430 - 410 + 431 411 <ThemedText {hasTheme} opacity={70} element="p"> 432 412 {bio} 433 413 </ThemedText> ··· 442 422 <script lang="ts"> 443 423 import { ThemedCard, ThemedText } from 'svelte-standard-site'; 444 424 import type { Snippet } from 'svelte'; 445 - 425 + 446 426 interface Props { 447 427 title: string; 448 428 description: string; 449 429 icon: Snippet; 450 430 theme?: any; 451 431 } 452 - 432 + 453 433 let { title, description, icon, theme }: Props = $props(); 454 434 const hasTheme = $derived(!!theme); 455 435 </script> 456 436 457 437 <ThemedCard {theme} class="p-6 text-center"> 458 - <div class="inline-flex items-center justify-center w-16 h-16 mb-4 rounded-full bg-primary-100 dark:bg-primary-900"> 438 + <div 439 + class="bg-primary-100 dark:bg-primary-900 mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full" 440 + > 459 441 {@render icon()} 460 442 </div> 461 - 462 - <ThemedText {hasTheme} element="h3" class="text-xl font-bold mb-2"> 443 + 444 + <ThemedText {hasTheme} element="h3" class="mb-2 text-xl font-bold"> 463 445 {title} 464 446 </ThemedText> 465 - 447 + 466 448 <ThemedText {hasTheme} opacity={70} element="p"> 467 449 {description} 468 450 </ThemedText> ··· 498 480 ```svelte 499 481 <script lang="ts"> 500 482 import { DateDisplay } from 'svelte-standard-site'; 501 - 483 + 502 484 // User preference from settings or profile 503 485 let userLocale = $state('fr-FR'); 504 486 </script> ··· 510 492 511 493 ```svelte 512 494 <script lang="ts"> 513 - import { 514 - StandardSiteLayout, 495 + import { 496 + StandardSiteLayout, 515 497 ThemedContainer, 516 498 ThemedText, 517 - DateDisplay 499 + DateDisplay 518 500 } from 'svelte-standard-site'; 519 501 import type { PageData } from './$types'; 520 - 502 + 521 503 const { data }: { data: PageData } = $props(); 522 - 504 + 523 505 // Detect user's language 524 506 let locale = $state('en-US'); 525 - 507 + 526 508 $effect(() => { 527 509 if (typeof navigator !== 'undefined') { 528 510 locale = navigator.language || 'en-US'; 529 511 } 530 512 }); 531 - 513 + 532 514 const theme = $derived(data.publication?.value.basicTheme); 533 515 const hasTheme = $derived(!!theme); 534 516 </script> 535 517 536 518 <StandardSiteLayout title={data.publication?.value.name}> 537 519 <ThemedContainer {theme}> 538 - <ThemedText {hasTheme} element="h1" class="text-4xl font-bold mb-4"> 520 + <ThemedText {hasTheme} element="h1" class="mb-4 text-4xl font-bold"> 539 521 {data.post.value.title} 540 522 </ThemedText> 541 - 523 + 542 524 <!-- Date automatically formats to user's locale --> 543 - <DateDisplay 544 - date={data.post.value.publishedAt} 525 + <DateDisplay 526 + date={data.post.value.publishedAt} 545 527 {locale} 546 - class="text-sm text-ink-600 dark:text-ink-400" 528 + class="text-ink-600 dark:text-ink-400 text-sm" 547 529 /> 548 - 549 - <div class="prose max-w-none mt-8"> 530 + 531 + <div class="prose mt-8 max-w-none"> 550 532 {@html data.post.value.content} 551 533 </div> 552 534 </ThemedContainer> 553 535 </StandardSiteLayout> 554 536 ``` 555 537 556 - ## Multi-Publication Site 538 + ## Multi-Publication Site 557 539 558 540 ```typescript 559 541 // src/routes/+page.server.ts
+3 -6
README.md
··· 224 224 - `locale?: string` - Locale override (default: browser locale) 225 225 226 226 **Features:** 227 + 227 228 - Automatically detects user's browser locale 228 229 - Supports custom locale override 229 230 - Examples: "January 19, 2026" (en-US), "19 janvier 2026" (fr-FR) ··· 264 265 Displays text with theme-aware colors. 265 266 266 267 ```svelte 267 - <ThemedText hasTheme={!!theme} element="h1" class="text-4xl"> 268 - Title 269 - </ThemedText> 270 - <ThemedText hasTheme={!!theme} opacity={70} element="p"> 271 - Description 272 - </ThemedText> 268 + <ThemedText hasTheme={!!theme} element="h1" class="text-4xl">Title</ThemedText> 269 + <ThemedText hasTheme={!!theme} opacity={70} element="p">Description</ThemedText> 273 270 ``` 274 271 275 272 **Props:**
+5 -3
package.json
··· 46 46 } 47 47 }, 48 48 "peerDependencies": { 49 - "svelte": "^5.0.0", 50 - "@sveltejs/kit": "^2.0.0" 49 + "@sveltejs/kit": "^2.0.0", 50 + "svelte": "^5.0.0" 51 51 }, 52 52 "devDependencies": { 53 53 "@sveltejs/adapter-auto": "^7.0.0", ··· 91 91 }, 92 92 "dependencies": { 93 93 "@atproto/api": "^0.18.16", 94 - "@lucide/svelte": "^0.562.0" 94 + "@lucide/svelte": "^0.562.0", 95 + "katex": "^0.16.27", 96 + "shiki": "^3.21.0" 95 97 } 96 98 }
+336
pnpm-lock.yaml
··· 14 14 '@lucide/svelte': 15 15 specifier: ^0.562.0 16 16 version: 0.562.0(svelte@5.46.4) 17 + katex: 18 + specifier: ^0.16.27 19 + version: 0.16.27 20 + shiki: 21 + specifier: ^3.21.0 22 + version: 3.21.0 17 23 devDependencies: 18 24 '@sveltejs/adapter-auto': 19 25 specifier: ^7.0.0 ··· 393 399 cpu: [x64] 394 400 os: [win32] 395 401 402 + '@shikijs/core@3.21.0': 403 + resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==} 404 + 405 + '@shikijs/engine-javascript@3.21.0': 406 + resolution: {integrity: sha512-ATwv86xlbmfD9n9gKRiwuPpWgPENAWCLwYCGz9ugTJlsO2kOzhOkvoyV/UD+tJ0uT7YRyD530x6ugNSffmvIiQ==} 407 + 408 + '@shikijs/engine-oniguruma@3.21.0': 409 + resolution: {integrity: sha512-OYknTCct6qiwpQDqDdf3iedRdzj6hFlOPv5hMvI+hkWfCKs5mlJ4TXziBG9nyabLwGulrUjHiCq3xCspSzErYQ==} 410 + 411 + '@shikijs/langs@3.21.0': 412 + resolution: {integrity: sha512-g6mn5m+Y6GBJ4wxmBYqalK9Sp0CFkUqfNzUy2pJglUginz6ZpWbaWjDB4fbQ/8SHzFjYbtU6Ddlp1pc+PPNDVA==} 413 + 414 + '@shikijs/themes@3.21.0': 415 + resolution: {integrity: sha512-BAE4cr9EDiZyYzwIHEk7JTBJ9CzlPuM4PchfcA5ao1dWXb25nv6hYsoDiBq2aZK9E3dlt3WB78uI96UESD+8Mw==} 416 + 417 + '@shikijs/types@3.21.0': 418 + resolution: {integrity: sha512-zGrWOxZ0/+0ovPY7PvBU2gIS9tmhSUUt30jAcNV0Bq0gb2S98gwfjIs1vxlmH5zM7/4YxLamT6ChlqqAJmPPjA==} 419 + 420 + '@shikijs/vscode-textmate@10.0.2': 421 + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} 422 + 396 423 '@standard-schema/spec@1.1.0': 397 424 resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 398 425 ··· 545 572 '@types/estree@1.0.8': 546 573 resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} 547 574 575 + '@types/hast@3.0.4': 576 + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} 577 + 578 + '@types/mdast@4.0.4': 579 + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} 580 + 581 + '@types/unist@3.0.3': 582 + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} 583 + 584 + '@ungap/structured-clone@1.3.0': 585 + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} 586 + 548 587 acorn@8.15.0: 549 588 resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} 550 589 engines: {node: '>=0.4.0'} ··· 561 600 resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} 562 601 engines: {node: '>= 0.4'} 563 602 603 + ccount@2.0.1: 604 + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} 605 + 606 + character-entities-html4@2.1.0: 607 + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} 608 + 609 + character-entities-legacy@3.0.0: 610 + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} 611 + 564 612 chokidar@4.0.3: 565 613 resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} 566 614 engines: {node: '>= 14.16.0'} ··· 573 621 resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} 574 622 engines: {node: '>=6'} 575 623 624 + comma-separated-tokens@2.0.3: 625 + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} 626 + 627 + commander@8.3.0: 628 + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} 629 + engines: {node: '>= 12'} 630 + 576 631 cookie@0.6.0: 577 632 resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} 578 633 engines: {node: '>= 0.6'} ··· 589 644 resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} 590 645 engines: {node: '>=0.10.0'} 591 646 647 + dequal@2.0.3: 648 + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} 649 + engines: {node: '>=6'} 650 + 592 651 detect-libc@2.1.2: 593 652 resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 594 653 engines: {node: '>=8'} 595 654 596 655 devalue@5.6.2: 597 656 resolution: {integrity: sha512-nPRkjWzzDQlsejL1WVifk5rvcFi/y1onBRxjaFMjZeR9mFpqu2gmAZ9xUB9/IEanEP/vBtGeGganC/GO1fmufg==} 657 + 658 + devlop@1.1.0: 659 + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} 598 660 599 661 enhanced-resolve@5.18.4: 600 662 resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==} ··· 628 690 graceful-fs@4.2.11: 629 691 resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} 630 692 693 + hast-util-to-html@9.0.5: 694 + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} 695 + 696 + hast-util-whitespace@3.0.0: 697 + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} 698 + 699 + html-void-elements@3.0.0: 700 + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} 701 + 631 702 is-reference@3.0.3: 632 703 resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} 633 704 ··· 636 707 637 708 jiti@2.6.1: 638 709 resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} 710 + hasBin: true 711 + 712 + katex@0.16.27: 713 + resolution: {integrity: sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw==} 639 714 hasBin: true 640 715 641 716 kleur@4.1.5: ··· 718 793 magic-string@0.30.21: 719 794 resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 720 795 796 + mdast-util-to-hast@13.2.1: 797 + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} 798 + 799 + micromark-util-character@2.1.1: 800 + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} 801 + 802 + micromark-util-encode@2.0.1: 803 + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} 804 + 805 + micromark-util-sanitize-uri@2.0.1: 806 + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} 807 + 808 + micromark-util-symbol@2.0.1: 809 + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} 810 + 811 + micromark-util-types@2.0.2: 812 + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} 813 + 721 814 mri@1.2.0: 722 815 resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} 723 816 engines: {node: '>=4'} ··· 736 829 737 830 obug@2.1.1: 738 831 resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} 832 + 833 + oniguruma-parser@0.12.1: 834 + resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} 835 + 836 + oniguruma-to-es@4.3.4: 837 + resolution: {integrity: sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==} 739 838 740 839 package-manager-detector@1.6.0: 741 840 resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} ··· 821 920 engines: {node: '>=14'} 822 921 hasBin: true 823 922 923 + property-information@7.1.0: 924 + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} 925 + 824 926 publint@0.3.16: 825 927 resolution: {integrity: sha512-MFqyfRLAExPVZdTQFwkAQELzA8idyXzROVOytg6nEJ/GEypXBUmMGrVaID8cTuzRS1U5L8yTOdOJtMXgFUJAeA==} 826 928 engines: {node: '>=18'} ··· 834 936 resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} 835 937 engines: {node: '>= 20.19.0'} 836 938 939 + regex-recursion@6.0.2: 940 + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} 941 + 942 + regex-utilities@2.3.0: 943 + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} 944 + 945 + regex@6.1.0: 946 + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} 947 + 837 948 rollup@4.55.1: 838 949 resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} 839 950 engines: {node: '>=18.0.0', npm: '>=8.0.0'} ··· 854 965 set-cookie-parser@2.7.2: 855 966 resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} 856 967 968 + shiki@3.21.0: 969 + resolution: {integrity: sha512-N65B/3bqL/TI2crrXr+4UivctrAGEjmsib5rPMMPpFp1xAx/w03v8WZ9RDDFYteXoEgY7qZ4HGgl5KBIu1153w==} 970 + 857 971 sirv@3.0.2: 858 972 resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} 859 973 engines: {node: '>=18'} ··· 862 976 resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 863 977 engines: {node: '>=0.10.0'} 864 978 979 + space-separated-tokens@2.0.2: 980 + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} 981 + 982 + stringify-entities@4.0.4: 983 + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} 984 + 865 985 svelte-check@4.3.5: 866 986 resolution: {integrity: sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==} 867 987 engines: {node: '>= 18.0.0'} ··· 899 1019 resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} 900 1020 engines: {node: '>=6'} 901 1021 1022 + trim-lines@3.0.1: 1023 + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} 1024 + 902 1025 tslib@2.8.1: 903 1026 resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 904 1027 ··· 913 1036 unicode-segmenter@0.14.5: 914 1037 resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} 915 1038 1039 + unist-util-is@6.0.1: 1040 + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} 1041 + 1042 + unist-util-position@5.0.0: 1043 + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} 1044 + 1045 + unist-util-stringify-position@4.0.0: 1046 + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} 1047 + 1048 + unist-util-visit-parents@6.0.2: 1049 + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} 1050 + 1051 + unist-util-visit@5.1.0: 1052 + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} 1053 + 916 1054 util-deprecate@1.0.2: 917 1055 resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} 1056 + 1057 + vfile-message@4.0.3: 1058 + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} 1059 + 1060 + vfile@6.0.3: 1061 + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} 918 1062 919 1063 vite@7.3.1: 920 1064 resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} ··· 969 1113 970 1114 zod@3.25.76: 971 1115 resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} 1116 + 1117 + zwitch@2.0.4: 1118 + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} 972 1119 973 1120 snapshots: 974 1121 ··· 1197 1344 '@rollup/rollup-win32-x64-msvc@4.55.1': 1198 1345 optional: true 1199 1346 1347 + '@shikijs/core@3.21.0': 1348 + dependencies: 1349 + '@shikijs/types': 3.21.0 1350 + '@shikijs/vscode-textmate': 10.0.2 1351 + '@types/hast': 3.0.4 1352 + hast-util-to-html: 9.0.5 1353 + 1354 + '@shikijs/engine-javascript@3.21.0': 1355 + dependencies: 1356 + '@shikijs/types': 3.21.0 1357 + '@shikijs/vscode-textmate': 10.0.2 1358 + oniguruma-to-es: 4.3.4 1359 + 1360 + '@shikijs/engine-oniguruma@3.21.0': 1361 + dependencies: 1362 + '@shikijs/types': 3.21.0 1363 + '@shikijs/vscode-textmate': 10.0.2 1364 + 1365 + '@shikijs/langs@3.21.0': 1366 + dependencies: 1367 + '@shikijs/types': 3.21.0 1368 + 1369 + '@shikijs/themes@3.21.0': 1370 + dependencies: 1371 + '@shikijs/types': 3.21.0 1372 + 1373 + '@shikijs/types@3.21.0': 1374 + dependencies: 1375 + '@shikijs/vscode-textmate': 10.0.2 1376 + '@types/hast': 3.0.4 1377 + 1378 + '@shikijs/vscode-textmate@10.0.2': {} 1379 + 1200 1380 '@standard-schema/spec@1.1.0': {} 1201 1381 1202 1382 '@sveltejs/acorn-typescript@1.0.8(acorn@8.15.0)': ··· 1333 1513 1334 1514 '@types/estree@1.0.8': {} 1335 1515 1516 + '@types/hast@3.0.4': 1517 + dependencies: 1518 + '@types/unist': 3.0.3 1519 + 1520 + '@types/mdast@4.0.4': 1521 + dependencies: 1522 + '@types/unist': 3.0.3 1523 + 1524 + '@types/unist@3.0.3': {} 1525 + 1526 + '@ungap/structured-clone@1.3.0': {} 1527 + 1336 1528 acorn@8.15.0: {} 1337 1529 1338 1530 aria-query@5.3.2: {} ··· 1341 1533 1342 1534 axobject-query@4.1.0: {} 1343 1535 1536 + ccount@2.0.1: {} 1537 + 1538 + character-entities-html4@2.1.0: {} 1539 + 1540 + character-entities-legacy@3.0.0: {} 1541 + 1344 1542 chokidar@4.0.3: 1345 1543 dependencies: 1346 1544 readdirp: 4.1.2 ··· 1350 1548 readdirp: 5.0.0 1351 1549 1352 1550 clsx@2.1.1: {} 1551 + 1552 + comma-separated-tokens@2.0.3: {} 1553 + 1554 + commander@8.3.0: {} 1353 1555 1354 1556 cookie@0.6.0: {} 1355 1557 ··· 1359 1561 1360 1562 deepmerge@4.3.1: {} 1361 1563 1564 + dequal@2.0.3: {} 1565 + 1362 1566 detect-libc@2.1.2: {} 1363 1567 1364 1568 devalue@5.6.2: {} 1569 + 1570 + devlop@1.1.0: 1571 + dependencies: 1572 + dequal: 2.0.3 1365 1573 1366 1574 enhanced-resolve@5.18.4: 1367 1575 dependencies: ··· 1412 1620 1413 1621 graceful-fs@4.2.11: {} 1414 1622 1623 + hast-util-to-html@9.0.5: 1624 + dependencies: 1625 + '@types/hast': 3.0.4 1626 + '@types/unist': 3.0.3 1627 + ccount: 2.0.1 1628 + comma-separated-tokens: 2.0.3 1629 + hast-util-whitespace: 3.0.0 1630 + html-void-elements: 3.0.0 1631 + mdast-util-to-hast: 13.2.1 1632 + property-information: 7.1.0 1633 + space-separated-tokens: 2.0.2 1634 + stringify-entities: 4.0.4 1635 + zwitch: 2.0.4 1636 + 1637 + hast-util-whitespace@3.0.0: 1638 + dependencies: 1639 + '@types/hast': 3.0.4 1640 + 1641 + html-void-elements@3.0.0: {} 1642 + 1415 1643 is-reference@3.0.3: 1416 1644 dependencies: 1417 1645 '@types/estree': 1.0.8 ··· 1419 1647 iso-datestring-validator@2.2.2: {} 1420 1648 1421 1649 jiti@2.6.1: {} 1650 + 1651 + katex@0.16.27: 1652 + dependencies: 1653 + commander: 8.3.0 1422 1654 1423 1655 kleur@4.1.5: {} 1424 1656 ··· 1477 1709 dependencies: 1478 1710 '@jridgewell/sourcemap-codec': 1.5.5 1479 1711 1712 + mdast-util-to-hast@13.2.1: 1713 + dependencies: 1714 + '@types/hast': 3.0.4 1715 + '@types/mdast': 4.0.4 1716 + '@ungap/structured-clone': 1.3.0 1717 + devlop: 1.1.0 1718 + micromark-util-sanitize-uri: 2.0.1 1719 + trim-lines: 3.0.1 1720 + unist-util-position: 5.0.0 1721 + unist-util-visit: 5.1.0 1722 + vfile: 6.0.3 1723 + 1724 + micromark-util-character@2.1.1: 1725 + dependencies: 1726 + micromark-util-symbol: 2.0.1 1727 + micromark-util-types: 2.0.2 1728 + 1729 + micromark-util-encode@2.0.1: {} 1730 + 1731 + micromark-util-sanitize-uri@2.0.1: 1732 + dependencies: 1733 + micromark-util-character: 2.1.1 1734 + micromark-util-encode: 2.0.1 1735 + micromark-util-symbol: 2.0.1 1736 + 1737 + micromark-util-symbol@2.0.1: {} 1738 + 1739 + micromark-util-types@2.0.2: {} 1740 + 1480 1741 mri@1.2.0: {} 1481 1742 1482 1743 mrmime@2.0.1: {} ··· 1487 1748 1488 1749 obug@2.1.1: {} 1489 1750 1751 + oniguruma-parser@0.12.1: {} 1752 + 1753 + oniguruma-to-es@4.3.4: 1754 + dependencies: 1755 + oniguruma-parser: 0.12.1 1756 + regex: 6.1.0 1757 + regex-recursion: 6.0.2 1758 + 1490 1759 package-manager-detector@1.6.0: {} 1491 1760 1492 1761 picocolors@1.1.1: {} ··· 1517 1786 1518 1787 prettier@3.8.0: {} 1519 1788 1789 + property-information@7.1.0: {} 1790 + 1520 1791 publint@0.3.16: 1521 1792 dependencies: 1522 1793 '@publint/pack': 0.1.2 ··· 1528 1799 1529 1800 readdirp@5.0.0: {} 1530 1801 1802 + regex-recursion@6.0.2: 1803 + dependencies: 1804 + regex-utilities: 2.3.0 1805 + 1806 + regex-utilities@2.3.0: {} 1807 + 1808 + regex@6.1.0: 1809 + dependencies: 1810 + regex-utilities: 2.3.0 1811 + 1531 1812 rollup@4.55.1: 1532 1813 dependencies: 1533 1814 '@types/estree': 1.0.8 ··· 1569 1850 1570 1851 set-cookie-parser@2.7.2: {} 1571 1852 1853 + shiki@3.21.0: 1854 + dependencies: 1855 + '@shikijs/core': 3.21.0 1856 + '@shikijs/engine-javascript': 3.21.0 1857 + '@shikijs/engine-oniguruma': 3.21.0 1858 + '@shikijs/langs': 3.21.0 1859 + '@shikijs/themes': 3.21.0 1860 + '@shikijs/types': 3.21.0 1861 + '@shikijs/vscode-textmate': 10.0.2 1862 + '@types/hast': 3.0.4 1863 + 1572 1864 sirv@3.0.2: 1573 1865 dependencies: 1574 1866 '@polka/url': 1.0.0-next.29 ··· 1577 1869 1578 1870 source-map-js@1.2.1: {} 1579 1871 1872 + space-separated-tokens@2.0.2: {} 1873 + 1874 + stringify-entities@4.0.4: 1875 + dependencies: 1876 + character-entities-html4: 2.1.0 1877 + character-entities-legacy: 3.0.0 1878 + 1580 1879 svelte-check@4.3.5(picomatch@4.0.3)(svelte@5.46.4)(typescript@5.9.3): 1581 1880 dependencies: 1582 1881 '@jridgewell/trace-mapping': 0.3.31 ··· 1627 1926 1628 1927 totalist@3.0.1: {} 1629 1928 1929 + trim-lines@3.0.1: {} 1930 + 1630 1931 tslib@2.8.1: {} 1631 1932 1632 1933 typescript@5.9.3: {} ··· 1637 1938 1638 1939 unicode-segmenter@0.14.5: {} 1639 1940 1941 + unist-util-is@6.0.1: 1942 + dependencies: 1943 + '@types/unist': 3.0.3 1944 + 1945 + unist-util-position@5.0.0: 1946 + dependencies: 1947 + '@types/unist': 3.0.3 1948 + 1949 + unist-util-stringify-position@4.0.0: 1950 + dependencies: 1951 + '@types/unist': 3.0.3 1952 + 1953 + unist-util-visit-parents@6.0.2: 1954 + dependencies: 1955 + '@types/unist': 3.0.3 1956 + unist-util-is: 6.0.1 1957 + 1958 + unist-util-visit@5.1.0: 1959 + dependencies: 1960 + '@types/unist': 3.0.3 1961 + unist-util-is: 6.0.1 1962 + unist-util-visit-parents: 6.0.2 1963 + 1640 1964 util-deprecate@1.0.2: {} 1641 1965 1966 + vfile-message@4.0.3: 1967 + dependencies: 1968 + '@types/unist': 3.0.3 1969 + unist-util-stringify-position: 4.0.0 1970 + 1971 + vfile@6.0.3: 1972 + dependencies: 1973 + '@types/unist': 3.0.3 1974 + vfile-message: 4.0.3 1975 + 1642 1976 vite@7.3.1(jiti@2.6.1)(lightningcss@1.30.2): 1643 1977 dependencies: 1644 1978 esbuild: 0.27.2 ··· 1659 1993 zimmerframe@1.1.4: {} 1660 1994 1661 1995 zod@3.25.76: {} 1996 + 1997 + zwitch@2.0.4: {}
+2 -1
src/lib/components/StandardSiteLayout.svelte
··· 87 87 <div class="text-ink-700 dark:text-ink-200 container mx-auto px-4 text-center text-sm"> 88 88 <p> 89 89 &copy; {new Date().getFullYear()} 90 - {title}. Powered by svelte-standard-site and the AT Protocol. Created by <a 90 + {title}. Powered by svelte-standard-site and the AT Protocol. Created by 91 + <a 91 92 href="https://ewancroft.uk" 92 93 target="_blank" 93 94 rel="noopener noreferrer"
+66
src/lib/components/document/BlockRenderer.svelte
··· 1 + <script lang="ts"> 2 + import TextBlock from './blocks/TextBlock.svelte'; 3 + import HeaderBlock from './blocks/HeaderBlock.svelte'; 4 + import BlockquoteBlock from './blocks/BlockquoteBlock.svelte'; 5 + import ImageBlock from './blocks/ImageBlock.svelte'; 6 + import CodeBlock from './blocks/CodeBlock.svelte'; 7 + import MathBlock from './blocks/MathBlock.svelte'; 8 + import UnorderedListBlock from './blocks/UnorderedListBlock.svelte'; 9 + import HorizontalRuleBlock from './blocks/HorizontalRuleBlock.svelte'; 10 + import IframeBlock from './blocks/IframeBlock.svelte'; 11 + import WebsiteBlock from './blocks/WebsiteBlock.svelte'; 12 + import ButtonBlock from './blocks/ButtonBlock.svelte'; 13 + import BskyPostBlock from './blocks/BskyPostBlock.svelte'; 14 + import PollBlock from './blocks/PollBlock.svelte'; 15 + import PageBlock from './blocks/PageBlock.svelte'; 16 + 17 + interface Props { 18 + block: any; 19 + hasTheme?: boolean; 20 + } 21 + 22 + const { block, hasTheme = false }: Props = $props(); 23 + </script> 24 + 25 + {#if block.$type === 'pub.leaflet.blocks.text'} 26 + <TextBlock {block} {hasTheme} /> 27 + {:else if block.$type === 'pub.leaflet.blocks.header'} 28 + <HeaderBlock {block} {hasTheme} /> 29 + {:else if block.$type === 'pub.leaflet.blocks.blockquote'} 30 + <BlockquoteBlock {block} {hasTheme} /> 31 + {:else if block.$type === 'pub.leaflet.blocks.image'} 32 + <ImageBlock {block} {hasTheme} /> 33 + {:else if block.$type === 'pub.leaflet.blocks.code'} 34 + <CodeBlock {block} {hasTheme} /> 35 + {:else if block.$type === 'pub.leaflet.blocks.math'} 36 + <MathBlock {block} {hasTheme} /> 37 + {:else if block.$type === 'pub.leaflet.blocks.unorderedList'} 38 + <UnorderedListBlock {block} {hasTheme} /> 39 + {:else if block.$type === 'pub.leaflet.blocks.horizontalRule'} 40 + <HorizontalRuleBlock {hasTheme} /> 41 + {:else if block.$type === 'pub.leaflet.blocks.iframe'} 42 + <IframeBlock {block} {hasTheme} /> 43 + {:else if block.$type === 'pub.leaflet.blocks.website'} 44 + <WebsiteBlock {block} {hasTheme} /> 45 + {:else if block.$type === 'pub.leaflet.blocks.button'} 46 + <ButtonBlock {block} {hasTheme} /> 47 + {:else if block.$type === 'pub.leaflet.blocks.bskyPost'} 48 + <BskyPostBlock {block} {hasTheme} /> 49 + {:else if block.$type === 'pub.leaflet.blocks.poll'} 50 + <PollBlock {block} {hasTheme} /> 51 + {:else if block.$type === 'pub.leaflet.blocks.page'} 52 + <PageBlock {block} {hasTheme} /> 53 + {:else} 54 + <!-- Unknown block type --> 55 + <div class="my-2 rounded-lg border border-orange-500/20 bg-orange-500/5 p-3"> 56 + <p class="text-sm text-orange-600 dark:text-orange-400"> 57 + Unknown block type: <code class="font-mono text-xs">{block.$type}</code> 58 + </p> 59 + <details class="mt-2"> 60 + <summary class="cursor-pointer text-xs text-orange-600/70 dark:text-orange-400/70"> 61 + Show block data 62 + </summary> 63 + <pre class="mt-2 overflow-x-auto text-xs">{JSON.stringify(block, null, 2)}</pre> 64 + </details> 65 + </div> 66 + {/if}
+40
src/lib/components/document/CanvasRenderer.svelte
··· 1 + <script lang="ts"> 2 + import BlockRenderer from './BlockRenderer.svelte'; 3 + 4 + interface CanvasPage { 5 + $type: 'pub.leaflet.pages.canvas'; 6 + id?: string; 7 + blocks: Array<{ 8 + $type: 'pub.leaflet.pages.canvas#block'; 9 + block: any; 10 + x: number; 11 + y: number; 12 + width: number; 13 + height?: number; 14 + rotation?: number; 15 + }>; 16 + } 17 + 18 + interface Props { 19 + page: CanvasPage; 20 + hasTheme?: boolean; 21 + } 22 + 23 + const { page, hasTheme = false }: Props = $props(); 24 + </script> 25 + 26 + <!-- Canvas layout uses absolute positioning --> 27 + <div class="relative min-h-screen w-full"> 28 + {#each page.blocks as blockWrapper, index} 29 + <div 30 + class="absolute" 31 + style:left="{blockWrapper.x}px" 32 + style:top="{blockWrapper.y}px" 33 + style:width="{blockWrapper.width}px" 34 + style:height={blockWrapper.height ? `${blockWrapper.height}px` : 'auto'} 35 + style:transform={blockWrapper.rotation ? `rotate(${blockWrapper.rotation}deg)` : undefined} 36 + > 37 + <BlockRenderer block={blockWrapper.block} {hasTheme} /> 38 + </div> 39 + {/each} 40 + </div>
+55
src/lib/components/document/DocumentRenderer.svelte
··· 1 + <script lang="ts"> 2 + import type { Document } from '$lib/types.js'; 3 + import LeafletContentRenderer from './LeafletContentRenderer.svelte'; 4 + import { mixThemeColor } from '$lib/utils/theme-helpers.js'; 5 + 6 + interface Props { 7 + document: Document; 8 + hasTheme?: boolean; 9 + } 10 + 11 + const { document, hasTheme = false }: Props = $props(); 12 + 13 + // Determine if we should render textContent or content 14 + const shouldRenderTextContent = $derived(!!document.textContent); 15 + const shouldRenderLeafletContent = $derived( 16 + !document.textContent && document.content && document.content.$type === 'pub.leaflet.content' 17 + ); 18 + </script> 19 + 20 + <div 21 + class="prose prose-lg max-w-none" 22 + style:color={hasTheme ? 'var(--theme-foreground)' : undefined} 23 + > 24 + {#if shouldRenderTextContent} 25 + <!-- Simple text content with proper whitespace handling --> 26 + <div class="leading-relaxed whitespace-pre-wrap">{document.textContent}</div> 27 + {:else if shouldRenderLeafletContent} 28 + <!-- Render the rich Leaflet content --> 29 + <LeafletContentRenderer content={document.content} {hasTheme} /> 30 + {:else if document.content} 31 + <!-- Fallback: show raw content for unknown types --> 32 + <div 33 + class="rounded-xl border p-6" 34 + style:border-color={hasTheme ? mixThemeColor('--theme-foreground', 20) : undefined} 35 + style:background-color={hasTheme ? mixThemeColor('--theme-foreground', 5) : undefined} 36 + > 37 + <p 38 + class="mb-3 text-sm font-semibold tracking-wider uppercase" 39 + style:color={hasTheme ? mixThemeColor('--theme-foreground', 60) : undefined} 40 + > 41 + Raw Content 42 + </p> 43 + <pre 44 + class="overflow-x-auto text-xs leading-relaxed" 45 + style:color={hasTheme ? 'var(--theme-foreground)' : undefined}>{JSON.stringify( 46 + document.content, 47 + null, 48 + 2 49 + )}</pre> 50 + </div> 51 + {:else} 52 + <!-- No content at all --> 53 + <p class="italic opacity-50">No content available</p> 54 + {/if} 55 + </div>
+41
src/lib/components/document/InlineMath.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface Props { 5 + tex: string; 6 + hasTheme?: boolean; 7 + } 8 + 9 + const { tex, hasTheme = false }: Props = $props(); 10 + 11 + let mathContainer = $state<HTMLSpanElement>(); 12 + let renderError = $state<string | null>(null); 13 + 14 + onMount(async () => { 15 + try { 16 + // Dynamically import KaTeX 17 + const katex = await import('katex'); 18 + await import('katex/dist/katex.min.css'); 19 + 20 + if (mathContainer) { 21 + katex.default.render(tex, mathContainer, { 22 + displayMode: false, // Inline mode 23 + throwOnError: false, 24 + errorColor: '#ef4444', 25 + trust: false 26 + }); 27 + } 28 + } catch (error) { 29 + console.error('Failed to render inline LaTeX:', error); 30 + renderError = error instanceof Error ? error.message : 'Failed to render LaTeX'; 31 + } 32 + }); 33 + </script> 34 + 35 + {#if renderError} 36 + <span class="rounded bg-red-100 px-1 text-xs text-red-700 dark:bg-red-950/50 dark:text-red-400" 37 + >Error: {tex}</span 38 + > 39 + {:else} 40 + <span bind:this={mathContainer} class="inline-block align-middle"></span> 41 + {/if}
+63
src/lib/components/document/LeafletContentRenderer.svelte
··· 1 + <script lang="ts"> 2 + import LinearDocumentRenderer from './LinearDocumentRenderer.svelte'; 3 + import CanvasRenderer from './CanvasRenderer.svelte'; 4 + 5 + interface LinearDocumentPage { 6 + $type: 'pub.leaflet.pages.linearDocument'; 7 + id?: string; 8 + blocks: Array<{ 9 + $type: 'pub.leaflet.pages.linearDocument#block'; 10 + block: any; 11 + alignment?: string; 12 + }>; 13 + } 14 + 15 + interface CanvasPage { 16 + $type: 'pub.leaflet.pages.canvas'; 17 + id?: string; 18 + blocks: Array<{ 19 + $type: 'pub.leaflet.pages.canvas#block'; 20 + block: any; 21 + x: number; 22 + y: number; 23 + width: number; 24 + height?: number; 25 + rotation?: number; 26 + }>; 27 + } 28 + 29 + type Page = LinearDocumentPage | CanvasPage; 30 + 31 + interface LeafletContent { 32 + $type: 'pub.leaflet.content'; 33 + pages: Page[]; 34 + } 35 + 36 + interface Props { 37 + content: LeafletContent; 38 + hasTheme?: boolean; 39 + } 40 + 41 + const { content, hasTheme = false }: Props = $props(); 42 + </script> 43 + 44 + {#if content.pages && content.pages.length > 0} 45 + {#each content.pages as page, index} 46 + {#if page.$type === 'pub.leaflet.pages.linearDocument'} 47 + <LinearDocumentRenderer {page} {hasTheme} /> 48 + {:else if page.$type === 'pub.leaflet.pages.canvas'} 49 + <CanvasRenderer {page} {hasTheme} /> 50 + {:else} 51 + <!-- Unknown page type --> 52 + <div class="my-4 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4"> 53 + <p class="text-sm text-yellow-600 dark:text-yellow-400"> 54 + Unknown page type: <code class="font-mono text-xs" 55 + >{(page as any).$type || 'unknown'}</code 56 + > 57 + </p> 58 + </div> 59 + {/if} 60 + {/each} 61 + {:else} 62 + <p class="italic opacity-50">No pages found in content</p> 63 + {/if}
+44
src/lib/components/document/LinearDocumentRenderer.svelte
··· 1 + <script lang="ts"> 2 + import BlockRenderer from './BlockRenderer.svelte'; 3 + 4 + interface LinearDocumentPage { 5 + $type: 'pub.leaflet.pages.linearDocument'; 6 + id?: string; 7 + blocks: Array<{ 8 + $type: 'pub.leaflet.pages.linearDocument#block'; 9 + block: any; 10 + alignment?: string; 11 + }>; 12 + } 13 + 14 + interface Props { 15 + page: LinearDocumentPage; 16 + hasTheme?: boolean; 17 + } 18 + 19 + const { page, hasTheme = false }: Props = $props(); 20 + 21 + function getAlignmentClass(alignment?: string): string { 22 + if (!alignment) return ''; 23 + switch (alignment) { 24 + case '#textAlignLeft': 25 + return 'text-left'; 26 + case '#textAlignCenter': 27 + return 'text-center'; 28 + case '#textAlignRight': 29 + return 'text-right'; 30 + case '#textAlignJustify': 31 + return 'text-justify'; 32 + default: 33 + return ''; 34 + } 35 + } 36 + </script> 37 + 38 + <div class="space-y-6"> 39 + {#each page.blocks as blockWrapper, index} 40 + <div class={getAlignmentClass(blockWrapper.alignment)}> 41 + <BlockRenderer block={blockWrapper.block} {hasTheme} /> 42 + </div> 43 + {/each} 44 + </div>
+272
src/lib/components/document/RichText.svelte
··· 1 + <script lang="ts"> 2 + import InlineMath from './InlineMath.svelte'; 3 + import { UnicodeString } from '@atproto/api'; 4 + 5 + interface Facet { 6 + index: { 7 + byteStart: number; 8 + byteEnd: number; 9 + }; 10 + features: Array<{ 11 + $type: string; 12 + [key: string]: any; 13 + }>; 14 + } 15 + 16 + interface Props { 17 + plaintext: string; 18 + facets?: Facet[]; 19 + hasTheme?: boolean; 20 + } 21 + 22 + const { plaintext, facets = [], hasTheme = false }: Props = $props(); 23 + 24 + interface RichTextSegment { 25 + text: string; 26 + facet?: Array<{ $type: string; [key: string]: any }>; 27 + } 28 + 29 + class RichText { 30 + unicodeText: UnicodeString; 31 + facets: Facet[]; 32 + 33 + constructor(props: { text: string; facets: Facet[] }) { 34 + this.unicodeText = new UnicodeString(props.text || ''); 35 + this.facets = props.facets || []; 36 + if (this.facets) { 37 + this.facets = this.facets 38 + .filter((facet) => facet.index.byteStart <= facet.index.byteEnd) 39 + .sort((a, b) => a.index.byteStart - b.index.byteStart); 40 + } 41 + } 42 + 43 + *segments(): Generator<RichTextSegment, void, void> { 44 + const facets = this.facets || []; 45 + if (!facets.length) { 46 + yield { text: this.unicodeText.utf16 || '' }; 47 + return; 48 + } 49 + 50 + let textCursor = 0; 51 + let facetCursor = 0; 52 + do { 53 + const currFacet = facets[facetCursor]; 54 + if (textCursor < currFacet.index.byteStart) { 55 + const sliced = this.unicodeText.slice(textCursor, currFacet.index.byteStart); 56 + yield { 57 + text: sliced || '' 58 + }; 59 + } else if (textCursor > currFacet.index.byteStart) { 60 + facetCursor++; 61 + continue; 62 + } 63 + if (currFacet.index.byteStart < currFacet.index.byteEnd) { 64 + const subtext = this.unicodeText.slice( 65 + currFacet.index.byteStart, 66 + currFacet.index.byteEnd 67 + ); 68 + const subtextStr = subtext || ''; 69 + if (!subtextStr.trim()) { 70 + // don't emit empty string entities 71 + yield { text: subtextStr }; 72 + } else { 73 + yield { text: subtextStr, facet: currFacet.features }; 74 + } 75 + } 76 + textCursor = currFacet.index.byteEnd; 77 + facetCursor++; 78 + } while (facetCursor < facets.length); 79 + if (textCursor < this.unicodeText.length) { 80 + const sliced = this.unicodeText.slice(textCursor, this.unicodeText.length); 81 + yield { 82 + text: sliced || '' 83 + }; 84 + } 85 + } 86 + } 87 + 88 + interface ProcessedSegment { 89 + parts: Array<{ text: string; isBr: boolean }>; 90 + isBold: boolean; 91 + isItalic: boolean; 92 + isUnderline: boolean; 93 + isStrikethrough: boolean; 94 + isCode: boolean; 95 + isHighlighted: boolean; 96 + isMath: boolean; 97 + isDidMention: boolean; 98 + isAtMention: boolean; 99 + link?: string; 100 + id?: string; 101 + did?: string; 102 + atURI?: string; 103 + } 104 + 105 + function processSegments(): ProcessedSegment[] { 106 + // Handle undefined or empty plaintext 107 + const text = plaintext || ''; 108 + const richText = new RichText({ text, facets }); 109 + const result: ProcessedSegment[] = []; 110 + 111 + for (const segment of richText.segments()) { 112 + const id = segment.facet?.find((f) => f.$type === 'pub.leaflet.richtext.facet#id'); 113 + const link = segment.facet?.find((f) => f.$type === 'pub.leaflet.richtext.facet#link'); 114 + const isBold = segment.facet?.some((f) => f.$type === 'pub.leaflet.richtext.facet#bold'); 115 + const isCode = segment.facet?.some((f) => f.$type === 'pub.leaflet.richtext.facet#code'); 116 + const isStrikethrough = segment.facet?.some( 117 + (f) => f.$type === 'pub.leaflet.richtext.facet#strikethrough' 118 + ); 119 + const isDidMention = segment.facet?.find( 120 + (f) => f.$type === 'pub.leaflet.richtext.facet#didMention' 121 + ); 122 + const isAtMention = segment.facet?.find( 123 + (f) => f.$type === 'pub.leaflet.richtext.facet#atMention' 124 + ); 125 + const isUnderline = segment.facet?.some( 126 + (f) => f.$type === 'pub.leaflet.richtext.facet#underline' 127 + ); 128 + const isItalic = segment.facet?.some((f) => f.$type === 'pub.leaflet.richtext.facet#italic'); 129 + const isHighlighted = segment.facet?.some( 130 + (f) => f.$type === 'pub.leaflet.richtext.facet#highlight' 131 + ); 132 + const isMath = segment.facet?.some((f) => f.$type === 'pub.leaflet.richtext.facet#math'); 133 + 134 + // Split text by newlines and mark br elements - handle undefined segment.text 135 + const segmentText = segment.text || ''; 136 + const textParts = segmentText.split('\n'); 137 + const parts = textParts.flatMap((part, i) => 138 + i < textParts.length - 1 139 + ? [ 140 + { text: part, isBr: false }, 141 + { text: '', isBr: true } 142 + ] 143 + : [{ text: part, isBr: false }] 144 + ); 145 + 146 + result.push({ 147 + parts, 148 + isBold: isBold || false, 149 + isItalic: isItalic || false, 150 + isUnderline: isUnderline || false, 151 + isStrikethrough: isStrikethrough || false, 152 + isCode: isCode || false, 153 + isHighlighted: isHighlighted || false, 154 + isMath: isMath || false, 155 + isDidMention: !!isDidMention, 156 + isAtMention: !!isAtMention, 157 + link: link?.uri, 158 + id: id?.id, 159 + did: isDidMention?.did, 160 + atURI: isAtMention?.atURI 161 + }); 162 + } 163 + 164 + return result; 165 + } 166 + 167 + const segments = $derived(processSegments()); 168 + </script> 169 + 170 + {#each segments as segment, i} 171 + {#each segment.parts as part, j} 172 + {#if part.isBr} 173 + <br /> 174 + {:else if segment.isMath} 175 + <InlineMath tex={part.text} {hasTheme} /> 176 + {:else} 177 + {@const classes = [ 178 + segment.isCode ? 'inline-code' : '', 179 + segment.id ? 'scroll-mt-12 scroll-mb-10' : '', 180 + segment.isBold ? 'font-bold' : '', 181 + segment.isItalic ? 'italic' : '', 182 + segment.isUnderline ? 'underline' : '', 183 + segment.isStrikethrough ? 'line-through decoration-tertiary' : '', 184 + segment.isHighlighted ? 'highlight' : '' 185 + ] 186 + .filter(Boolean) 187 + .join(' ')} 188 + 189 + {#if segment.isCode} 190 + <code class={classes} id={segment.id}>{part.text}</code> 191 + {:else if segment.isDidMention} 192 + <a 193 + href={`https://leaflet.pub/p/${segment.did}`} 194 + target="_blank" 195 + rel="noopener noreferrer" 196 + class="no-underline" 197 + > 198 + <span class="mention {classes}" class:themed={hasTheme}>{part.text}</span> 199 + </a> 200 + {:else if segment.isAtMention} 201 + <a 202 + href={segment.atURI} 203 + target="_blank" 204 + rel="noopener noreferrer" 205 + class="hover:underline {classes}" 206 + class:themed={hasTheme} 207 + > 208 + {part.text} 209 + </a> 210 + {:else if segment.link} 211 + <a 212 + href={segment.link.trim()} 213 + class="hover:underline {classes}" 214 + class:themed={hasTheme} 215 + target="_blank" 216 + rel="noopener noreferrer" 217 + > 218 + {part.text} 219 + </a> 220 + {:else} 221 + <span class={classes} id={segment.id}>{part.text}</span> 222 + {/if} 223 + {/if} 224 + {/each} 225 + {/each} 226 + 227 + <style> 228 + .mention { 229 + cursor: pointer; 230 + color: rgb(0 0 225); 231 + padding: 0 0.125rem; 232 + border-radius: 0.25rem; 233 + background-color: color-mix(in oklab, rgb(0 0 225), transparent 80%); 234 + border: 1px solid transparent; 235 + display: inline; 236 + white-space: normal; 237 + } 238 + 239 + .mention.themed { 240 + color: var(--theme-accent); 241 + background-color: color-mix(in oklab, var(--theme-accent), transparent 80%); 242 + } 243 + 244 + a { 245 + color: rgb(0 0 225); 246 + } 247 + 248 + a.themed { 249 + color: var(--theme-accent); 250 + } 251 + 252 + .inline-code { 253 + display: inline; 254 + font-size: 1em; 255 + background-color: color-mix(in oklab, currentColor, transparent 90%); 256 + font-family: ui-monospace, monospace; 257 + padding: 1px; 258 + margin: -1px; 259 + border-radius: 4px; 260 + box-decoration-break: clone; 261 + -webkit-box-decoration-break: clone; 262 + } 263 + 264 + .highlight { 265 + padding: 1px; 266 + margin: -1px; 267 + border-radius: 4px; 268 + box-decoration-break: clone; 269 + -webkit-box-decoration-break: clone; 270 + background-color: rgb(255, 177, 177); 271 + } 272 + </style>
+29
src/lib/components/document/blocks/BlockquoteBlock.svelte
··· 1 + <script lang="ts"> 2 + import RichText from '../RichText.svelte'; 3 + 4 + interface Props { 5 + block: { 6 + plaintext: string; 7 + facets?: any[]; 8 + }; 9 + hasTheme?: boolean; 10 + } 11 + 12 + const { block, hasTheme = false }: Props = $props(); 13 + </script> 14 + 15 + <blockquote class="blockquote mt-1 mb-2" class:themed={hasTheme}> 16 + <RichText plaintext={block.plaintext} facets={block.facets} {hasTheme} /> 17 + </blockquote> 18 + 19 + <style> 20 + .blockquote { 21 + border-left: 2px solid rgb(107 114 128); /* Default gray color */ 22 + padding-left: 0.75rem; 23 + margin-left: 0.5rem; 24 + } 25 + 26 + .blockquote.themed { 27 + border-color: var(--theme-accent); 28 + } 29 + </style>
+202
src/lib/components/document/blocks/BskyPostBlock.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface Props { 5 + block: { 6 + postRef: { 7 + uri: string; 8 + cid: string; 9 + }; 10 + }; 11 + hasTheme?: boolean; 12 + postData?: any; // The full post data if already fetched 13 + } 14 + 15 + const { block, hasTheme = false, postData }: Props = $props(); 16 + 17 + let post = $state<any>(null); 18 + let loading = $state(true); 19 + let error = $state<string | null>(null); 20 + 21 + // Extract post info from AT URI 22 + function extractPostInfo(uri: string): { did: string; rkey: string } | null { 23 + const match = uri.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/); 24 + if (!match) return null; 25 + return { did: match[1], rkey: match[2] }; 26 + } 27 + 28 + const postInfo = $derived(extractPostInfo(block.postRef.uri)); 29 + const postUrl = $derived( 30 + postInfo ? `https://bsky.app/profile/${postInfo.did}/post/${postInfo.rkey}` : null 31 + ); 32 + 33 + onMount(async () => { 34 + // Use postData if provided 35 + if (postData) { 36 + post = postData; 37 + loading = false; 38 + return; 39 + } 40 + 41 + // You would fetch the post data here from your API or Bluesky API 42 + // For now, we'll just show a simple link 43 + loading = false; 44 + }); 45 + </script> 46 + 47 + {#if loading} 48 + <div 49 + class="relative my-2 flex w-full flex-col gap-2 overflow-hidden rounded-md border bg-white p-3 text-sm dark:bg-gray-900" 50 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 51 + class:border-gray-200={!hasTheme} 52 + class:dark:border-gray-700={!hasTheme} 53 + > 54 + <div class="animate-pulse"> 55 + <div class="mb-2 h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700"></div> 56 + <div class="mb-2 h-4 w-3/4 rounded bg-gray-200 dark:bg-gray-700"></div> 57 + <div class="h-4 w-1/2 rounded bg-gray-200 dark:bg-gray-700"></div> 58 + </div> 59 + </div> 60 + {:else if error} 61 + <div 62 + class="relative my-2 flex w-full flex-col gap-2 overflow-hidden rounded-md border border-red-200 bg-red-50 p-3 text-sm dark:border-red-800 dark:bg-red-950" 63 + > 64 + <p class="text-red-600 dark:text-red-400">Failed to load Bluesky post: {error}</p> 65 + </div> 66 + {:else if post && post.author && post.record} 67 + <div 68 + class="relative my-2 flex w-full flex-col gap-2 overflow-hidden rounded-md border bg-white p-3 text-sm dark:bg-gray-900" 69 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 70 + class:border-gray-200={!hasTheme} 71 + class:dark:border-gray-700={!hasTheme} 72 + > 73 + <div class="flex w-full items-center gap-2"> 74 + {#if post.author.avatar} 75 + <img 76 + src={post.author.avatar} 77 + alt="{post.author.displayName}'s avatar" 78 + class="h-8 w-8 shrink-0 rounded-full border border-gray-200 dark:border-gray-700" 79 + /> 80 + {/if} 81 + <div class="flex grow flex-col gap-0.5 leading-tight"> 82 + <div class="font-bold text-gray-900 dark:text-gray-100"> 83 + {post.author.displayName} 84 + </div> 85 + <a 86 + href="https://bsky.app/profile/{post.author.handle}" 87 + target="_blank" 88 + rel="noopener noreferrer" 89 + class="text-xs text-gray-600 hover:underline dark:text-gray-400" 90 + > 91 + @{post.author.handle} 92 + </a> 93 + </div> 94 + </div> 95 + 96 + <div class="flex flex-col gap-2"> 97 + {#if post.record.text} 98 + <pre class="whitespace-pre-wrap text-gray-900 dark:text-gray-100">{post.record.text}</pre> 99 + {/if} 100 + 101 + {#if post.embed} 102 + <div> 103 + <!-- Embed rendering would go here --> 104 + <div class="text-sm text-gray-600 italic dark:text-gray-400">[Embedded content]</div> 105 + </div> 106 + {/if} 107 + </div> 108 + 109 + <div class="flex w-full items-center justify-between gap-2 text-gray-600 dark:text-gray-400"> 110 + {#if post.record.createdAt} 111 + <div class="text-xs"> 112 + {new Date(post.record.createdAt).toLocaleDateString('en-US', { 113 + month: 'short', 114 + day: 'numeric', 115 + year: 'numeric', 116 + hour: 'numeric', 117 + minute: 'numeric', 118 + hour12: true 119 + })} 120 + </div> 121 + {/if} 122 + <div class="flex items-center gap-2"> 123 + {#if post.replyCount != null && post.replyCount > 0} 124 + <span class="flex items-center gap-1 text-xs"> 125 + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 126 + <path 127 + stroke-linecap="round" 128 + stroke-linejoin="round" 129 + stroke-width="2" 130 + d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" 131 + /> 132 + </svg> 133 + {post.replyCount} 134 + </span> 135 + {/if} 136 + {#if post.quoteCount != null && post.quoteCount > 0} 137 + <span class="flex items-center gap-1 text-xs"> 138 + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 139 + <path 140 + stroke-linecap="round" 141 + stroke-linejoin="round" 142 + stroke-width="2" 143 + d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" 144 + /> 145 + </svg> 146 + {post.quoteCount} 147 + </span> 148 + {/if} 149 + <a 150 + href={postUrl} 151 + target="_blank" 152 + rel="noopener noreferrer" 153 + class="transition-opacity hover:opacity-70" 154 + title="View on Bluesky" 155 + > 156 + <svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24"> 157 + <path 158 + d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 159 + /> 160 + </svg> 161 + </a> 162 + </div> 163 + </div> 164 + </div> 165 + {:else if postUrl} 166 + <div 167 + class="my-2 rounded-md border bg-white p-6 dark:bg-gray-900" 168 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 169 + class:border-gray-200={!hasTheme} 170 + class:dark:border-gray-700={!hasTheme} 171 + > 172 + <div class="mb-3 flex items-center gap-2 text-sm font-medium"> 173 + <svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"> 174 + <path 175 + d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" 176 + /> 177 + </svg> 178 + <span style:color={hasTheme ? 'var(--theme-accent)' : undefined}> Bluesky Post </span> 179 + </div> 180 + <a 181 + href={postUrl} 182 + target="_blank" 183 + rel="noopener noreferrer" 184 + class="inline-flex items-center gap-2 text-sm font-medium transition-all hover:gap-3" 185 + style:color={hasTheme ? 'var(--theme-accent)' : undefined} 186 + > 187 + View on Bluesky 188 + <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> 189 + <path 190 + stroke-linecap="round" 191 + stroke-linejoin="round" 192 + stroke-width="2" 193 + d="M14 5l7 7m0 0l-7 7m7-7H3" 194 + /> 195 + </svg> 196 + </a> 197 + </div> 198 + {:else} 199 + <div class="my-4 rounded-lg border border-yellow-500/20 bg-yellow-500/5 p-4"> 200 + <p class="text-sm text-yellow-600 dark:text-yellow-400">Invalid Bluesky post reference</p> 201 + </div> 202 + {/if}
+24
src/lib/components/document/blocks/ButtonBlock.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + block: { 4 + text: string; 5 + url: string; 6 + }; 7 + hasTheme?: boolean; 8 + } 9 + 10 + const { block, hasTheme = false }: Props = $props(); 11 + </script> 12 + 13 + <div class="my-2"> 14 + <a 15 + href={block.url.trim()} 16 + target="_blank" 17 + rel="noopener noreferrer" 18 + class="inline-block rounded-md px-6 py-3 font-semibold transition-all hover:opacity-90" 19 + style:background-color={hasTheme ? 'var(--theme-accent)' : '#3b82f6'} 20 + style:color="white" 21 + > 22 + {block.text} 23 + </a> 24 + </div>
+68
src/lib/components/document/blocks/CodeBlock.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + 4 + interface Props { 5 + block: { 6 + plaintext: string; 7 + language?: string; 8 + syntaxHighlightingTheme?: string; 9 + }; 10 + hasTheme?: boolean; 11 + prerenderedCode?: string; 12 + } 13 + 14 + const { block, hasTheme = false, prerenderedCode }: Props = $props(); 15 + 16 + let html = $state<string | null>(null); 17 + 18 + onMount(async () => { 19 + // Use prerendered code if available 20 + if (prerenderedCode) { 21 + html = prerenderedCode; 22 + return; 23 + } 24 + 25 + try { 26 + const { codeToHtml, bundledLanguagesInfo, bundledThemesInfo } = await import('shiki'); 27 + 28 + const lang = 29 + bundledLanguagesInfo.find((l: any) => l.id === block.language)?.id || 'plaintext'; 30 + const theme = 31 + bundledThemesInfo.find((t: any) => t.id === block.syntaxHighlightingTheme)?.id || 32 + 'github-light'; 33 + 34 + html = await codeToHtml(block.plaintext, { lang, theme }); 35 + } catch (error) { 36 + console.error('Failed to highlight code:', error); 37 + // Fallback to plain text 38 + html = `<pre><code>${block.plaintext}</code></pre>`; 39 + } 40 + }); 41 + </script> 42 + 43 + {#if html} 44 + <div 45 + class="my-2 min-h-[42px] w-full rounded-md border" 46 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 47 + class:border-gray-200={!hasTheme} 48 + class:dark:border-gray-700={!hasTheme} 49 + > 50 + {@html html} 51 + </div> 52 + {:else} 53 + <div 54 + class="my-2 min-h-[42px] w-full rounded-md border" 55 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 56 + class:border-gray-200={!hasTheme} 57 + class:dark:border-gray-700={!hasTheme} 58 + > 59 + <pre class="p-4"><code>{block.plaintext}</code></pre> 60 + </div> 61 + {/if} 62 + 63 + <style> 64 + :global(.shiki) { 65 + padding: 1rem; 66 + overflow-x: auto; 67 + } 68 + </style>
+56
src/lib/components/document/blocks/HeaderBlock.svelte
··· 1 + <script lang="ts"> 2 + import RichText from '../RichText.svelte'; 3 + 4 + interface Props { 5 + block: { 6 + plaintext: string; 7 + level?: number; 8 + facets?: any[]; 9 + }; 10 + hasTheme?: boolean; 11 + } 12 + 13 + const { block, hasTheme = false }: Props = $props(); 14 + 15 + const level = $derived(block.level || 1); 16 + </script> 17 + 18 + {#if level === 1} 19 + <h2 class="h1Block mt-1 mb-2"> 20 + <RichText plaintext={block.plaintext} facets={block.facets} {hasTheme} /> 21 + </h2> 22 + {:else if level === 2} 23 + <h3 class="h2Block mt-1 mb-2"> 24 + <RichText plaintext={block.plaintext} facets={block.facets} {hasTheme} /> 25 + </h3> 26 + {:else if level === 3} 27 + <h4 class="h3Block mt-1 mb-2"> 28 + <RichText plaintext={block.plaintext} facets={block.facets} {hasTheme} /> 29 + </h4> 30 + {:else} 31 + <h6 class="h6Block mt-1 mb-2"> 32 + <RichText plaintext={block.plaintext} facets={block.facets} {hasTheme} /> 33 + </h6> 34 + {/if} 35 + 36 + <style> 37 + .h1Block { 38 + font-size: 2rem; 39 + font-weight: bold; 40 + } 41 + 42 + .h2Block { 43 + font-size: 1.625rem; 44 + font-weight: bold; 45 + } 46 + 47 + .h3Block { 48 + font-size: 1.125rem; 49 + font-weight: bold; 50 + } 51 + 52 + .h6Block { 53 + font-size: 1rem; 54 + font-weight: bold; 55 + } 56 + </style>
+14
src/lib/components/document/blocks/HorizontalRuleBlock.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + hasTheme?: boolean; 4 + } 5 + 6 + const { hasTheme = false }: Props = $props(); 7 + </script> 8 + 9 + <hr 10 + class="my-2 border-t" 11 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 12 + class:border-gray-200={!hasTheme} 13 + class:dark:border-gray-700={!hasTheme} 14 + />
+32
src/lib/components/document/blocks/IframeBlock.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + block: { 4 + url: string; 5 + height?: number; 6 + }; 7 + hasTheme?: boolean; 8 + } 9 + 10 + const { block, hasTheme = false }: Props = $props(); 11 + 12 + const height = $derived(block.height || 360); 13 + </script> 14 + 15 + <div class="my-2 w-full"> 16 + <div 17 + class="overflow-hidden rounded-md border" 18 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 19 + class:border-gray-200={!hasTheme} 20 + class:dark:border-gray-700={!hasTheme} 21 + > 22 + <iframe 23 + width="100%" 24 + {height} 25 + src={block.url} 26 + allow="fullscreen" 27 + loading="lazy" 28 + title="Embedded content" 29 + class="border-0" 30 + ></iframe> 31 + </div> 32 + </div>
+37
src/lib/components/document/blocks/ImageBlock.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + block: { 4 + image: { 5 + src: string; 6 + height?: number; 7 + width?: number; 8 + fallback?: string; 9 + local?: boolean; 10 + }; 11 + alt?: string; 12 + }; 13 + hasTheme?: boolean; 14 + } 15 + 16 + const { block, hasTheme = false }: Props = $props(); 17 + 18 + const imageSrc = $derived(block.image.fallback || block.image.src); 19 + const altText = $derived(block.alt || ''); 20 + </script> 21 + 22 + <div class="my-2 w-fit"> 23 + <img 24 + src={imageSrc} 25 + alt={altText} 26 + loading="lazy" 27 + decoding="async" 28 + height={block.image.height} 29 + width={block.image.width} 30 + class="h-auto max-w-full" 31 + /> 32 + {#if altText} 33 + <div class="mt-1 text-xs text-gray-600 italic dark:text-gray-400"> 34 + {altText} 35 + </div> 36 + {/if} 37 + </div>
+34
src/lib/components/document/blocks/MathBlock.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import katex from 'katex'; 4 + import 'katex/dist/katex.min.css'; 5 + 6 + interface Props { 7 + block: { 8 + tex: string; 9 + }; 10 + hasTheme?: boolean; 11 + } 12 + 13 + const { block, hasTheme = false }: Props = $props(); 14 + 15 + let html = $state(''); 16 + 17 + onMount(() => { 18 + html = katex.renderToString(block.tex, { 19 + displayMode: true, 20 + output: 'html', 21 + throwOnError: false 22 + }); 23 + }); 24 + </script> 25 + 26 + <div class="math-block my-2"> 27 + {@html html} 28 + </div> 29 + 30 + <style> 31 + .math-block :global(.katex-display) { 32 + margin: 0; 33 + } 34 + </style>
+66
src/lib/components/document/blocks/PageBlock.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + block: { 4 + pageId: string; 5 + }; 6 + hasTheme?: boolean; 7 + pages?: any[]; // Array of page data if available 8 + } 9 + 10 + const { block, hasTheme = false, pages }: Props = $props(); 11 + 12 + // Find the referenced page 13 + const referencedPage = $derived(pages?.find((p) => p.id === block.pageId)); 14 + </script> 15 + 16 + {#if referencedPage} 17 + <div 18 + class="relative my-2 flex h-[104px] w-full overflow-clip rounded-md border bg-white dark:bg-gray-900" 19 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 20 + class:border-gray-200={!hasTheme} 21 + class:dark:border-gray-700={!hasTheme} 22 + > 23 + <div class="flex h-full w-full overflow-clip"> 24 + <div class="my-2 ml-3 flex min-w-0 grow flex-col overflow-clip bg-transparent text-sm"> 25 + <div class="grow"> 26 + {#if referencedPage.title} 27 + <div class="line-clamp-1 text-base font-bold"> 28 + {referencedPage.title} 29 + </div> 30 + {/if} 31 + {#if referencedPage.description} 32 + <div class="line-clamp-2 text-sm text-gray-600 dark:text-gray-400"> 33 + {referencedPage.description} 34 + </div> 35 + {/if} 36 + </div> 37 + </div> 38 + <div 39 + class="m-2 -mb-2 w-[120px] shrink-0 origin-center rotate-[4deg] rounded-t-md border border-gray-200 bg-gradient-to-br from-gray-50 to-gray-100 dark:border-gray-700 dark:from-gray-800 dark:to-gray-900" 40 + ></div> 41 + </div> 42 + </div> 43 + {:else} 44 + <div 45 + class="my-2 rounded-md border bg-white p-6 dark:bg-gray-900" 46 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 47 + class:border-gray-200={!hasTheme} 48 + class:dark:border-gray-700={!hasTheme} 49 + > 50 + <div class="mb-3 flex items-center gap-2 text-sm font-medium"> 51 + <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> 52 + <path 53 + stroke-linecap="round" 54 + stroke-linejoin="round" 55 + d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" 56 + /> 57 + </svg> 58 + <span>Page Reference</span> 59 + </div> 60 + <p class="text-sm opacity-70"> 61 + Links to page: <code class="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-800" 62 + >{block.pageId}</code 63 + > 64 + </p> 65 + </div> 66 + {/if}
+122
src/lib/components/document/blocks/PollBlock.svelte
··· 1 + <script lang="ts"> 2 + interface PollOption { 3 + text: string; 4 + } 5 + 6 + interface PollVote { 7 + voter_did: string; 8 + record: { 9 + option: string[]; 10 + }; 11 + } 12 + 13 + interface Props { 14 + block: { 15 + pollRef: { 16 + uri: string; 17 + cid: string; 18 + }; 19 + }; 20 + pollData?: { 21 + record: { 22 + options: PollOption[]; 23 + }; 24 + atp_poll_votes: PollVote[]; 25 + }; 26 + hasTheme?: boolean; 27 + } 28 + 29 + const { block, pollData, hasTheme = false }: Props = $props(); 30 + 31 + const totalVotes = $derived(pollData?.atp_poll_votes.length || 0); 32 + 33 + function getVoteOption(voteRecord: any): string | null { 34 + try { 35 + return voteRecord.option && voteRecord.option.length > 0 ? voteRecord.option[0] : null; 36 + } catch { 37 + return null; 38 + } 39 + } 40 + 41 + function getVotesForOption(optionIndex: number): number { 42 + if (!pollData) return 0; 43 + return pollData.atp_poll_votes.filter((v) => getVoteOption(v.record) === optionIndex.toString()) 44 + .length; 45 + } 46 + 47 + function getHighestVotes(): number { 48 + if (!pollData) return 0; 49 + const options = pollData.record.options; 50 + return Math.max(...options.map((_, i) => getVotesForOption(i))); 51 + } 52 + </script> 53 + 54 + {#if !pollData} 55 + <div 56 + class="my-2 rounded-md border bg-white p-6 dark:bg-gray-900" 57 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 58 + class:border-gray-200={!hasTheme} 59 + class:dark:border-gray-700={!hasTheme} 60 + > 61 + <div class="mb-3 flex items-center gap-2 text-sm font-medium"> 62 + <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> 63 + <path 64 + stroke-linecap="round" 65 + stroke-linejoin="round" 66 + d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" 67 + /> 68 + </svg> 69 + <span>Poll Reference</span> 70 + </div> 71 + <p class="text-sm opacity-70"> 72 + Poll: <code class="rounded bg-gray-100 px-1 py-0.5 text-xs dark:bg-gray-800" 73 + >{block.pollRef.uri}</code 74 + > 75 + </p> 76 + </div> 77 + {:else} 78 + <div 79 + class="poll my-2 flex w-full flex-col gap-2 rounded-md border p-3" 80 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 81 + style:background-color={hasTheme 82 + ? 'color-mix(in oklab, var(--theme-accent), white 85%)' 83 + : '#f0f9ff'} 84 + class:border-gray-200={!hasTheme} 85 + class:dark:border-gray-700={!hasTheme} 86 + > 87 + <!-- Poll Results (Read-only) --> 88 + {#each pollData.record.options as option, index} 89 + {@const votes = getVotesForOption(index)} 90 + {@const isWinner = totalVotes > 0 && votes === getHighestVotes()} 91 + {@const percentage = totalVotes > 0 ? (votes / totalVotes) * 100 : 0} 92 + 93 + <div 94 + class="pollResult relative grow overflow-hidden rounded-md px-2 py-0.5 {isWinner 95 + ? 'border-2 border-blue-600 font-bold dark:border-blue-400' 96 + : 'border border-blue-600 dark:border-blue-400'}" 97 + > 98 + <div 99 + class="pollResultContent relative z-10 flex justify-between gap-2" 100 + style="color: {hasTheme ? 'var(--theme-accent)' : '#1e40af'};" 101 + > 102 + <div class="max-w-full grow truncate">{option.text}</div> 103 + <div class="tabular-nums">{votes}</div> 104 + </div> 105 + <div class="pollResultBG absolute top-0 right-0 bottom-0 left-0 z-0 flex w-full flex-row"> 106 + <div 107 + class="m-0.5 rounded-[2px]" 108 + style="background-color: {hasTheme 109 + ? 'var(--theme-accent)' 110 + : '#3b82f6'}; width: {percentage}%; min-width: 4px;" 111 + ></div> 112 + <div></div> 113 + </div> 114 + </div> 115 + {/each} 116 + 117 + <div class="pt-2 text-center text-sm text-gray-600 dark:text-gray-400"> 118 + {totalVotes} 119 + {totalVotes === 1 ? 'vote' : 'votes'} 120 + </div> 121 + </div> 122 + {/if}
+26
src/lib/components/document/blocks/TextBlock.svelte
··· 1 + <script lang="ts"> 2 + import RichText from '../RichText.svelte'; 3 + 4 + interface Props { 5 + block: { 6 + plaintext: string; 7 + facets?: any[]; 8 + textSize?: 'default' | 'small' | 'large'; 9 + }; 10 + hasTheme?: boolean; 11 + } 12 + 13 + const { block, hasTheme = false }: Props = $props(); 14 + 15 + const textSizeClass = $derived( 16 + block.textSize === 'small' 17 + ? 'text-sm text-secondary' 18 + : block.textSize === 'large' 19 + ? 'text-lg' 20 + : '' 21 + ); 22 + </script> 23 + 24 + <p class="textBlock mt-1 mb-2 {textSizeClass}"> 25 + <RichText plaintext={block.plaintext} facets={block.facets} {hasTheme} /> 26 + </p>
+84
src/lib/components/document/blocks/UnorderedListBlock.svelte
··· 1 + <script lang="ts"> 2 + import RichText from '../RichText.svelte'; 3 + import UnorderedListBlock from './UnorderedListBlock.svelte'; 4 + 5 + interface ListItem { 6 + content?: { 7 + plaintext: string; 8 + facets?: any[]; 9 + }; 10 + children?: ListItem[]; 11 + } 12 + 13 + interface Props { 14 + block: { 15 + children: ListItem[]; 16 + }; 17 + hasTheme?: boolean; 18 + } 19 + 20 + const { block, hasTheme = false }: Props = $props(); 21 + </script> 22 + 23 + <ul class="unordered-list pb-2"> 24 + {#each block.children as item} 25 + <li class="flex flex-row gap-2 pb-0"> 26 + <div 27 + class="listMarker shrink-0 mx-2 z-1 mt-[14px] h-[5px] w-[5px]" 28 + class:has-content={item.content} 29 + class:themed={hasTheme} 30 + /> 31 + <div class="flex flex-col w-full"> 32 + {#if item.content} 33 + <div class="textBlock mt-1 mb-2"> 34 + <RichText 35 + plaintext={item.content.plaintext} 36 + facets={item.content.facets} 37 + {hasTheme} 38 + /> 39 + </div> 40 + {/if} 41 + {#if item.children && item.children.length > 0} 42 + <UnorderedListBlock block={{ children: item.children }} {hasTheme} /> 43 + {/if} 44 + </div> 45 + </li> 46 + {/each} 47 + </ul> 48 + 49 + <style> 50 + .unordered-list { 51 + list-style: none; 52 + padding-left: 0; 53 + margin-left: -1px; 54 + } 55 + 56 + @media (min-width: 640px) { 57 + .unordered-list { 58 + margin-left: 9px; 59 + } 60 + } 61 + 62 + .unordered-list .unordered-list { 63 + margin-left: -7px; 64 + } 65 + 66 + @media (min-width: 640px) { 67 + .unordered-list .unordered-list { 68 + margin-left: 7px; 69 + } 70 + } 71 + 72 + .listMarker { 73 + background-color: transparent; 74 + } 75 + 76 + .listMarker.has-content { 77 + border-radius: 9999px; 78 + background-color: rgb(107 114 128); /* Default gray color */ 79 + } 80 + 81 + .listMarker.has-content.themed { 82 + background-color: var(--theme-accent); 83 + } 84 + </style>
+66
src/lib/components/document/blocks/WebsiteBlock.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + block: { 4 + url: string; 5 + title?: string; 6 + description?: string; 7 + preview?: { 8 + src: string; 9 + }; 10 + }; 11 + hasTheme?: boolean; 12 + } 13 + 14 + const { block, hasTheme = false }: Props = $props(); 15 + </script> 16 + 17 + <div 18 + class="group/linkBlock relative my-2 flex h-[104px] overflow-hidden rounded-md border bg-white transition-colors dark:bg-gray-900" 19 + style:border-color={hasTheme ? 'var(--theme-accent)' : undefined} 20 + class:border-gray-200={!hasTheme} 21 + class:hover:border-gray-300={!hasTheme} 22 + class:dark:border-gray-700={!hasTheme} 23 + class:dark:hover:border-gray-600={!hasTheme} 24 + > 25 + <a 26 + href={block.url} 27 + target="_blank" 28 + rel="noopener noreferrer" 29 + class="flex h-full w-full text-inherit no-underline hover:no-underline" 30 + > 31 + <div class="min-w-0 grow px-3 pt-2 pb-2"> 32 + <div class="flex h-full w-full min-w-0 flex-col"> 33 + {#if block.title} 34 + <div 35 + class="mb-0.5 line-clamp-1 text-base font-bold" 36 + style="overflow: hidden; text-overflow: ellipsis; word-break: break-all;" 37 + > 38 + {block.title} 39 + </div> 40 + {/if} 41 + 42 + {#if block.description} 43 + <div class="line-clamp-2 grow text-sm text-gray-600 dark:text-gray-400"> 44 + {block.description} 45 + </div> 46 + {/if} 47 + 48 + <div 49 + class="line-clamp-1 w-full min-w-0 text-xs text-gray-500 italic group-hover/linkBlock:text-blue-600 dark:text-gray-500 dark:group-hover/linkBlock:text-blue-400" 50 + style="word-break: break-word;" 51 + style:color={hasTheme ? 'var(--theme-accent)' : undefined} 52 + > 53 + {block.url} 54 + </div> 55 + </div> 56 + </div> 57 + 58 + {#if block.preview?.src} 59 + <div 60 + class="m-2 -mb-2 w-[120px] shrink-0 origin-center rotate-[4deg] rounded-t-md border border-gray-200 bg-cover dark:border-gray-700" 61 + style:background-image="url({block.preview.src})" 62 + style:background-position="center" 63 + ></div> 64 + {/if} 65 + </a> 66 + </div>
+3
src/lib/components/index.ts
··· 9 9 export { default as ThemedContainer } from './common/ThemedContainer.svelte'; 10 10 export { default as ThemedText } from './common/ThemedText.svelte'; 11 11 export { default as ThemedCard } from './common/ThemedCard.svelte'; 12 + 13 + // Document rendering 14 + export { default as DocumentRenderer } from './document/DocumentRenderer.svelte';
+3 -29
src/routes/[pub_rkey]/[doc_rkey]/+page.svelte
··· 4 4 import { extractRkey } from '$lib/utils/document.js'; 5 5 import { ThemedContainer, ThemedText, DateDisplay, TagList } from '$lib/components/index.js'; 6 6 import { mixThemeColor } from '$lib/utils/theme-helpers.js'; 7 + import DocumentRenderer from '$lib/components/document/DocumentRenderer.svelte'; 7 8 8 9 const { data }: { data: PageData } = $props(); 9 10 ··· 119 120 </div> 120 121 {/if} 121 122 122 - <!-- Content --> 123 - <div 124 - class="prose prose-lg max-w-none" 125 - style:color={hasTheme ? 'var(--theme-foreground)' : undefined} 126 - > 127 - {#if data.document.value.textContent} 128 - <div class="leading-relaxed">{data.document.value.textContent}</div> 129 - {:else if data.document.value.content} 130 - <div 131 - class="rounded-xl border p-6" 132 - style:border-color={hasTheme ? mixThemeColor('--theme-foreground', 20) : undefined} 133 - style:background-color={hasTheme ? mixThemeColor('--theme-foreground', 5) : undefined} 134 - > 135 - <p 136 - class="mb-3 text-sm font-semibold uppercase tracking-wider" 137 - style:color={hasTheme ? mixThemeColor('--theme-foreground', 60) : undefined} 138 - > 139 - Raw Content 140 - </p> 141 - <pre 142 - class="overflow-x-auto text-xs leading-relaxed" 143 - style:color={hasTheme ? 'var(--theme-foreground)' : undefined}>{JSON.stringify(data.document.value.content, null, 2)}</pre> 144 - </div> 145 - {:else} 146 - <ThemedText {hasTheme} opacity={50} element="p" class="italic"> 147 - No content available 148 - </ThemedText> 149 - {/if} 150 - </div> 123 + <!-- Document Content --> 124 + <DocumentRenderer document={data.document.value} {hasTheme} /> 151 125 152 126 <!-- Tags --> 153 127 {#if data.document.value.tags && data.document.value.tags.length > 0}