new render stuff, fixed lexicon

Orual 593dbcb3 119371ff

+3663 -394
+47 -36
Cargo.lock
··· 476 476 477 477 [[package]] 478 478 name = "aws-lc-rs" 479 - version = "1.15.1" 479 + version = "1.15.2" 480 480 source = "registry+https://github.com/rust-lang/crates.io-index" 481 - checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" 481 + checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" 482 482 dependencies = [ 483 483 "aws-lc-sys", 484 484 "zeroize", ··· 486 486 487 487 [[package]] 488 488 name = "aws-lc-sys" 489 - version = "0.34.0" 489 + version = "0.35.0" 490 490 source = "registry+https://github.com/rust-lang/crates.io-index" 491 - checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" 491 + checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" 492 492 dependencies = [ 493 493 "cc", 494 494 "cmake", ··· 900 900 901 901 [[package]] 902 902 name = "bumpalo" 903 - version = "3.19.0" 903 + version = "3.19.1" 904 904 source = "registry+https://github.com/rust-lang/crates.io-index" 905 - checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 905 + checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" 906 906 dependencies = [ 907 907 "allocator-api2", 908 908 ] ··· 1234 1234 1235 1235 [[package]] 1236 1236 name = "cmake" 1237 - version = "0.1.56" 1237 + version = "0.1.57" 1238 1238 source = "registry+https://github.com/rust-lang/crates.io-index" 1239 - checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" 1239 + checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" 1240 1240 dependencies = [ 1241 1241 "cc", 1242 1242 ] ··· 2805 2805 [[package]] 2806 2806 name = "dioxus-primitives" 2807 2807 version = "0.0.1" 2808 - source = "git+https://github.com/DioxusLabs/components#7e5862e574aeceb3a3a021d042c165a839f1860b" 2808 + source = "git+https://github.com/DioxusLabs/components#3564270718866d2e886f879973afc77d7c3a1689" 2809 2809 dependencies = [ 2810 2810 "dioxus 0.7.2", 2811 2811 "dioxus-sdk-time", ··· 3992 3992 3993 3993 [[package]] 3994 3994 name = "generator" 3995 - version = "0.8.7" 3995 + version = "0.8.8" 3996 3996 source = "registry+https://github.com/rust-lang/crates.io-index" 3997 - checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" 3997 + checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" 3998 3998 dependencies = [ 3999 3999 "cc", 4000 4000 "cfg-if", 4001 4001 "libc", 4002 4002 "log", 4003 4003 "rustversion", 4004 - "windows 0.61.3", 4004 + "windows-link 0.2.1", 4005 + "windows-result 0.4.1", 4005 4006 ] 4006 4007 4007 4008 [[package]] ··· 5149 5150 5150 5151 [[package]] 5151 5152 name = "insta" 5152 - version = "1.44.3" 5153 + version = "1.45.0" 5153 5154 source = "registry+https://github.com/rust-lang/crates.io-index" 5154 - checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" 5155 + checksum = "b76866be74d68b1595eb8060cb9191dca9c021db2316558e52ddc5d55d41b66c" 5155 5156 dependencies = [ 5156 5157 "console", 5157 5158 "once_cell", 5158 5159 "serde", 5159 5160 "similar", 5161 + "tempfile", 5160 5162 ] 5161 5163 5162 5164 [[package]] ··· 6067 6069 6068 6070 [[package]] 6069 6071 name = "libredox" 6070 - version = "0.1.10" 6072 + version = "0.1.11" 6071 6073 source = "registry+https://github.com/rust-lang/crates.io-index" 6072 - checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 6074 + checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" 6073 6075 dependencies = [ 6074 6076 "bitflags 2.10.0", 6075 6077 "libc", 6076 - "redox_syscall", 6078 + "redox_syscall 0.6.0", 6077 6079 ] 6078 6080 6079 6081 [[package]] ··· 6480 6482 [[package]] 6481 6483 name = "markdown-weaver" 6482 6484 version = "0.13.0" 6483 - source = "git+https://github.com/rsform/markdown-weaver#52075e20a194375f1bd4a0c78201ce3b3a52c82d" 6485 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6484 6486 dependencies = [ 6485 6487 "bitflags 2.10.0", 6486 6488 "getopts", ··· 6493 6495 [[package]] 6494 6496 name = "markdown-weaver-escape" 6495 6497 version = "0.11.0" 6496 - source = "git+https://github.com/rsform/markdown-weaver#52075e20a194375f1bd4a0c78201ce3b3a52c82d" 6498 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6497 6499 6498 6500 [[package]] 6499 6501 name = "markup5ever" ··· 7635 7637 dependencies = [ 7636 7638 "cfg-if", 7637 7639 "libc", 7638 - "redox_syscall", 7640 + "redox_syscall 0.5.18", 7639 7641 "smallvec", 7640 7642 "windows-link 0.2.1", 7641 7643 ] ··· 8172 8174 source = "registry+https://github.com/rust-lang/crates.io-index" 8173 8175 checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" 8174 8176 dependencies = [ 8175 - "toml_edit 0.23.9", 8177 + "toml_edit 0.23.10+spec-1.0.0", 8176 8178 ] 8177 8179 8178 8180 [[package]] ··· 8581 8583 version = "0.5.18" 8582 8584 source = "registry+https://github.com/rust-lang/crates.io-index" 8583 8585 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 8586 + dependencies = [ 8587 + "bitflags 2.10.0", 8588 + ] 8589 + 8590 + [[package]] 8591 + name = "redox_syscall" 8592 + version = "0.6.0" 8593 + source = "registry+https://github.com/rust-lang/crates.io-index" 8594 + checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" 8584 8595 dependencies = [ 8585 8596 "bitflags 2.10.0", 8586 8597 ] ··· 8926 8937 8927 8938 [[package]] 8928 8939 name = "rustls-pki-types" 8929 - version = "1.13.1" 8940 + version = "1.13.2" 8930 8941 source = "registry+https://github.com/rust-lang/crates.io-index" 8931 - checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" 8942 + checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" 8932 8943 dependencies = [ 8933 8944 "web-time", 8934 8945 "zeroize", ··· 9989 10000 9990 10001 [[package]] 9991 10002 name = "supports-hyperlinks" 9992 - version = "3.1.0" 10003 + version = "3.2.0" 9993 10004 source = "registry+https://github.com/rust-lang/crates.io-index" 9994 - checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" 10005 + checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" 9995 10006 9996 10007 [[package]] 9997 10008 name = "supports-unicode" ··· 10734 10745 10735 10746 [[package]] 10736 10747 name = "toml_datetime" 10737 - version = "0.7.3" 10748 + version = "0.7.5+spec-1.1.0" 10738 10749 source = "registry+https://github.com/rust-lang/crates.io-index" 10739 - checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" 10750 + checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" 10740 10751 dependencies = [ 10741 10752 "serde_core", 10742 10753 ] ··· 10767 10778 10768 10779 [[package]] 10769 10780 name = "toml_edit" 10770 - version = "0.23.9" 10781 + version = "0.23.10+spec-1.0.0" 10771 10782 source = "registry+https://github.com/rust-lang/crates.io-index" 10772 - checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" 10783 + checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" 10773 10784 dependencies = [ 10774 10785 "indexmap 2.12.1", 10775 - "toml_datetime 0.7.3", 10786 + "toml_datetime 0.7.5+spec-1.1.0", 10776 10787 "toml_parser", 10777 10788 "winnow 0.7.14", 10778 10789 ] 10779 10790 10780 10791 [[package]] 10781 10792 name = "toml_parser" 10782 - version = "1.0.4" 10793 + version = "1.0.6+spec-1.1.0" 10783 10794 source = "registry+https://github.com/rust-lang/crates.io-index" 10784 - checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" 10795 + checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" 10785 10796 dependencies = [ 10786 10797 "winnow 0.7.14", 10787 10798 ] ··· 10845 10856 10846 10857 [[package]] 10847 10858 name = "tracing" 10848 - version = "0.1.43" 10859 + version = "0.1.44" 10849 10860 source = "registry+https://github.com/rust-lang/crates.io-index" 10850 - checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" 10861 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 10851 10862 dependencies = [ 10852 10863 "log", 10853 10864 "pin-project-lite", ··· 10868 10879 10869 10880 [[package]] 10870 10881 name = "tracing-core" 10871 - version = "0.1.35" 10882 + version = "0.1.36" 10872 10883 source = "registry+https://github.com/rust-lang/crates.io-index" 10873 - checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" 10884 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 10874 10885 dependencies = [ 10875 10886 "once_cell", 10876 10887 "valuable",
+2 -2
Cargo.toml
··· 28 28 syntect = { version = "5.2.0", default-features = false } 29 29 n0-future = "=0.1.3" 30 30 tracing = { version = "0.1.41", default-features = false, features = ["std"] } 31 - markdown-weaver = { git = "https://github.com/rsform/markdown-weaver" } 32 - markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver" } 31 + markdown-weaver = { git = "https://github.com/rsform/markdown-weaver", branch = "para-end-context" } 32 + markdown-weaver-escape = { git = "https://github.com/rsform/markdown-weaver", branch = "para-end-context" } 33 33 # markdown-weaver = { path = "../markdown-weaver/markdown-weaver" } 34 34 # markdown-weaver-escape = { path = "../markdown-weaver/markdown-weaver-escape" } 35 35
+144 -95
crates/weaver-app/assets/styling/entry.css
··· 1 - /* Entry page layout with gutter navigation */ 2 - .entry-page-layout { 3 - display: grid; 4 - grid-template-columns: minmax(200px, 1fr) minmax(0, 95ch) minmax(200px, 1fr); 5 - gap: 2rem; 6 - width: 100%; 1 + /* Entry page layout - centered content with sidenote margin */ 2 + .entry-page { 3 + --content-width: 65ch; 4 + --sidenote-width: 14rem; 5 + --sidenote-gap: 1.5rem; 6 + 7 + display: flex; 8 + flex-direction: column; 7 9 min-height: 100vh; 8 10 background: var(--color-base); 9 - max-width: calc(95ch + 400px + 4rem); /* content + gutters + gaps */ 11 + } 12 + 13 + /* Header with inline nav + metadata */ 14 + .entry-header { 15 + display: flex; 16 + align-items: baseline; 17 + justify-content: center; 18 + gap: 1rem; 19 + padding: 1.5rem 1rem; 20 + max-width: calc(var(--content-width) + var(--sidenote-width) + var(--sidenote-gap) + 10rem); 10 21 margin: 0 auto; 11 - padding: 0 1rem 0 0; 22 + width: 100%; 12 23 box-sizing: border-box; 13 24 } 14 25 15 - /* Main content area */ 16 - .entry-content-main { 17 - grid-column: 2; 18 - background: var(--color-base); 26 + /* Nav links in header - minimal style */ 27 + .nav-button { 28 + display: flex; 29 + align-items: center; 30 + gap: 0.5rem; 31 + color: var(--color-subtle); 32 + text-decoration: none; 33 + font-size: 0.9rem; 34 + white-space: nowrap; 35 + transition: color 0.2s ease; 19 36 } 20 37 21 - /* Navigation gutters */ 22 - .nav-gutter { 23 - position: sticky; 24 - top: auto; 25 - bottom: 2rem; 26 - height: fit-content; 27 - align-self: end; 38 + .entry-header .nav-button:hover { 39 + color: var(--color-primary); 28 40 } 29 41 30 - .nav-prev { 31 - grid-column: 1; 42 + .entry-header .nav-button-prev { 43 + flex-shrink: 1; 44 + min-width: 0; 32 45 } 33 46 34 - .nav-next { 35 - grid-column: 3; 47 + .entry-header .nav-button-next { 48 + flex-shrink: 1; 49 + min-width: 0; 36 50 } 37 51 38 - /* Navigation buttons */ 39 - .nav-button { 52 + .entry-header .nav-arrow { 53 + font-weight: 600; 54 + color: var(--color-primary); 55 + flex-shrink: 0; 56 + } 57 + 58 + .entry-header .nav-title { 59 + overflow: hidden; 60 + text-overflow: ellipsis; 61 + } 62 + 63 + /* Metadata takes center, flexes to fill */ 64 + .entry-header .entry-metadata { 65 + flex: 1; 66 + max-width: var(--content-width); 67 + margin: 0; 68 + padding: 0; 69 + border: none; 70 + } 71 + 72 + /* Main content area */ 73 + .entry-content-wrapper { 74 + flex: 1; 40 75 display: flex; 41 - flex-direction: column; 42 - gap: 0.5rem; 43 - padding: 1rem; 44 - background: var(--color-surface); 45 - box-shadow: 0 1px 3px color-mix(in srgb, var(--color-text) 8%, transparent); 46 - text-decoration: none; 47 - color: var(--color-text); 48 - transition: 49 - box-shadow 0.2s ease, 50 - border-color 0.2s ease; 76 + justify-content: center; 77 + padding: 0 1rem; 51 78 } 52 79 53 - .nav-button:hover { 54 - box-shadow: 0 2px 6px color-mix(in srgb, var(--color-text) 12%, transparent); 80 + .entry-content-main { 81 + width: var(--content-width); 82 + max-width: 100%; 83 + position: relative; 55 84 } 56 85 57 - /* Dark mode: borders instead of shadows */ 58 - @media (prefers-color-scheme: dark) { 59 - .nav-button { 60 - box-shadow: none; 61 - border: 1px dashed var(--color-border); 62 - } 86 + /* When sidenotes exist, shift content left to make room */ 87 + .entry-content-main:has(.sidenote) { 88 + margin-right: calc(var(--sidenote-width) + var(--sidenote-gap)); 89 + } 63 90 64 - .nav-button:hover { 65 - box-shadow: none; 66 - border-color: var(--color-primary); 67 - } 91 + /* Footer navigation */ 92 + .entry-footer-nav { 93 + display: flex; 94 + justify-content: space-between; 95 + align-items: stretch; 96 + gap: 2rem; 97 + max-width: calc(var(--content-width) + 20rem); 98 + margin: 4rem auto 2rem; 99 + padding: 0 1rem; 100 + width: 100%; 101 + box-sizing: border-box; 68 102 } 69 103 70 - .nav-button-prev { 104 + .entry-footer-nav .nav-button-prev { 71 105 align-items: flex-start; 72 106 text-align: left; 73 107 } 74 108 75 - .nav-button-next { 109 + .entry-footer-nav .nav-button-next { 76 110 align-items: flex-end; 77 111 text-align: right; 112 + margin-left: auto; 78 113 } 79 114 80 - .nav-arrow { 81 - font-size: 1.5rem; 115 + .entry-footer-nav .nav-arrow { 116 + font-size: 1.25rem; 82 117 font-weight: bold; 83 118 color: var(--color-primary); 84 - transition: color 0.2s ease; 85 119 } 86 120 87 - .nav-button:hover .nav-arrow { 88 - color: var(--color-emphasis); 121 + .entry-footer-nav .nav-title { 122 + font-size: 0.95rem; 123 + font-weight: 500; 124 + line-height: 1.4; 89 125 } 90 126 91 - .nav-button:hover .nav-label { 92 - color: var(--color-secondary); 127 + /* Entry metadata in header context */ 128 + .entry-header .entry-metadata .entry-title { 129 + font-size: 1.75rem; 130 + margin: 0 0 0.5rem 0; 93 131 } 94 132 95 - .nav-title { 96 - font-size: 0.95rem; 97 - font-weight: 500; 98 - line-height: 1.4; 133 + .entry-header .entry-metadata .entry-header-row { 134 + flex-wrap: wrap; 99 135 } 100 136 101 - /* Entry metadata header */ 137 + /* Standalone entry metadata (when not in header) */ 102 138 .entry-metadata { 103 139 margin-bottom: calc(1rem * var(--spacing-scale, 1.5)); 104 140 padding-bottom: calc(0.5rem * var(--spacing-scale, 1.5)); ··· 235 271 background: var(--color-surface); 236 272 } 237 273 238 - /* Responsive layout - Tablet/small desktop */ 239 - @media (max-width: 1400px) { 240 - .entry-page-layout { 241 - grid-template-columns: 1fr; 242 - gap: 0; 243 - } 274 + /* TODO: footnote ordering needs non-flex solution for aside reflow to work */ 244 275 245 - .entry-content-main { 246 - grid-column: 1; 247 - padding: 2rem 1rem; 276 + /* Responsive: tablet */ 277 + @media (max-width: 1200px) { 278 + .entry-header { 279 + flex-wrap: wrap; 248 280 } 249 281 250 - .nav-gutter { 251 - position: relative; 252 - bottom: auto; 253 - grid-column: 1; 254 - padding: 0 1rem; 282 + .entry-header .entry-metadata { 283 + order: -1; 284 + flex-basis: 100%; 285 + max-width: none; 255 286 } 256 287 257 - .nav-prev { 258 - order: 2; 259 - margin-top: 2rem; 288 + .entry-header .nav-button-prev { 289 + order: 0; 260 290 } 261 291 262 - .nav-next { 263 - order: 3; 264 - margin-top: 1rem; 292 + .entry-header .nav-button-next { 293 + order: 1; 294 + margin-left: auto; 265 295 } 296 + } 266 297 267 - .entry-content-main { 268 - order: 1; 298 + /* Responsive: when sidenotes need to squeeze */ 299 + @media (max-width: 1000px) { 300 + .entry-content-main:has(.sidenote) { 301 + margin-right: calc(var(--sidenote-width) + 0.5rem); 302 + margin-left: -3rem; 269 303 } 270 304 } 271 305 272 - /* Tablet and smaller */ 306 + /* Responsive: narrower - compress left margin more */ 273 307 @media (max-width: 900px) { 274 - .entry-page-layout { 275 - max-width: 100%; 276 - grid-template-columns: minmax(0, 1fr); 308 + .entry-header .nav-title { 309 + max-width: 8rem; 310 + } 311 + 312 + .entry-content-main:has(.sidenote) { 313 + margin-left: -5rem; 277 314 } 278 315 } 279 316 280 - /* Small mobile phones */ 281 - @media (max-width: 480px) { 282 - .entry-content-main { 283 - padding: 1rem 0.75rem; 317 + /* Responsive: mobile - sidenotes go inline */ 318 + @media (max-width: 768px) { 319 + .entry-header { 320 + flex-direction: column; 321 + align-items: stretch; 322 + gap: 0.5rem; 284 323 } 285 324 286 - .nav-gutter { 287 - padding: 0 0.75rem; 325 + .entry-content-main:has(.sidenote) { 326 + margin-left: 0; 327 + margin-right: 0; 288 328 } 289 329 290 - .nav-button { 291 - padding: 0.75rem; 330 + .entry-footer-nav { 331 + flex-direction: column; 332 + gap: 1rem; 333 + margin-top: 2rem; 334 + } 335 + } 336 + 337 + /* Small mobile */ 338 + @media (max-width: 480px) { 339 + .entry-content-main { 340 + padding: 0 0.75rem; 292 341 } 293 342 294 343 .entry-metadata {
+4
crates/weaver-app/src/components/editor/actions.rs
··· 43 43 self.end.saturating_sub(self.start) 44 44 } 45 45 46 + #[allow(dead_code)] 46 47 pub fn is_empty(&self) -> bool { 47 48 self.len() == 0 48 49 } ··· 65 66 /// These represent semantic operations on the document, decoupled from 66 67 /// how they're triggered (keyboard, mouse, touch, voice, etc.). 67 68 #[derive(Debug, Clone, PartialEq)] 69 + #[allow(dead_code)] 68 70 pub enum EditorAction { 69 71 // === Text Insertion === 70 72 /// Insert text at the given range (replacing any selected content). ··· 178 180 /// SmolStr to efficiently handle both single characters and composed sequences 179 181 /// (from dead keys, IME, etc.). 180 182 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 183 + #[allow(dead_code)] 181 184 pub enum Key { 182 185 /// A character key. The string corresponds to the character typed, 183 186 /// taking into account locale, modifiers, and keyboard mapping. ··· 460 463 pub super_: bool, // `super` is a keyword 461 464 } 462 465 466 + #[allow(dead_code)] 463 467 impl Modifiers { 464 468 pub const NONE: Self = Self { 465 469 ctrl: false,
+9 -2
crates/weaver-app/src/components/editor/beforeinput.rs
··· 57 57 /// 58 58 /// See: https://w3c.github.io/input-events/#interface-InputEvent-Attributes 59 59 #[derive(Debug, Clone, PartialEq, Eq)] 60 + #[allow(dead_code)] 60 61 pub enum InputType { 61 62 // === Insertion === 62 63 /// Insert typed text. ··· 131 132 Unknown(String), 132 133 } 133 134 135 + #[allow(dead_code)] 134 136 impl InputType { 135 137 /// Parse from the browser's inputType string. 136 138 pub fn from_str(s: &str) -> Self { ··· 220 222 221 223 /// Result of handling a beforeinput event. 222 224 #[derive(Debug, Clone)] 225 + #[allow(dead_code)] 223 226 pub enum BeforeInputResult { 224 227 /// Event was handled, prevent default browser behavior. 225 228 Handled, ··· 235 238 } 236 239 237 240 /// Context for beforeinput handling. 241 + #[allow(dead_code)] 238 242 pub struct BeforeInputContext<'a> { 239 243 /// The input type. 240 244 pub input_type: InputType, ··· 253 257 /// 254 258 /// This is the main entry point for beforeinput-based input handling. 255 259 /// Returns whether the event was handled and default should be prevented. 260 + #[allow(dead_code)] 256 261 pub fn handle_beforeinput( 257 262 doc: &mut EditorDocument, 258 263 ctx: BeforeInputContext<'_>, ··· 557 562 let end_text = end_container.text_content().unwrap_or_default(); 558 563 559 564 // Check if containers are the editor element itself 560 - let start_is_editor = start_container.dyn_ref::<web_sys::Element>() 565 + let start_is_editor = start_container 566 + .dyn_ref::<web_sys::Element>() 561 567 .map(|e| e == &editor_element) 562 568 .unwrap_or(false); 563 - let end_is_editor = end_container.dyn_ref::<web_sys::Element>() 569 + let end_is_editor = end_container 570 + .dyn_ref::<web_sys::Element>() 564 571 .map(|e| e == &editor_element) 565 572 .unwrap_or(false); 566 573
+27 -15
crates/weaver-app/src/components/editor/component.rs
··· 1 1 //! The main MarkdownEditor component. 2 2 3 - use dioxus::prelude::*; 4 - use jacquard::IntoStatic; 5 - use jacquard::cowstr::ToCowStr; 6 - use jacquard::identity::resolver::IdentityResolver; 7 - use jacquard::smol_str::{SmolStr, ToSmolStr}; 8 - use jacquard::types::aturi::AtUri; 9 - use jacquard::types::blob::BlobRef; 10 - use jacquard::types::ident::AtIdentifier; 11 - use weaver_api::sh_weaver::embed::images::Image; 12 - use weaver_common::WeaverExt; 13 - 3 + #[allow(unused_imports)] 14 4 use super::actions::{ 15 5 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 16 6 handle_keydown_with_bindings, 17 7 }; 8 + #[allow(unused_imports)] 18 9 use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; 19 10 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 20 11 use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 21 12 use super::document::{CompositionState, EditorDocument, LoadedDocState}; 22 - use super::dom_sync::{ 23 - sync_cursor_from_dom, sync_cursor_from_dom_with_direction, update_paragraph_dom, 24 - }; 13 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 14 + use super::dom_sync::update_paragraph_dom; 15 + use super::dom_sync::{sync_cursor_from_dom, sync_cursor_from_dom_with_direction}; 25 16 use super::formatting; 26 17 use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 27 18 use super::offset_map::SnapDirection; 28 19 use super::paragraph::ParagraphRender; 29 20 use super::platform; 21 + #[allow(unused_imports)] 30 22 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 31 23 use super::render; 32 24 use super::storage; 33 25 use super::sync::{SyncStatus, load_and_merge_document}; 34 26 use super::toolbar::EditorToolbar; 35 27 use super::visibility::update_syntax_visibility; 28 + #[allow(unused_imports)] 36 29 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 37 30 use crate::auth::AuthState; 38 31 use crate::components::collab::CollaboratorAvatars; 39 32 use crate::components::editor::ReportButton; 40 33 use crate::components::editor::collab::CollabCoordinator; 41 34 use crate::fetch::Fetcher; 35 + use dioxus::prelude::*; 36 + use jacquard::IntoStatic; 37 + use jacquard::cowstr::ToCowStr; 38 + use jacquard::identity::resolver::IdentityResolver; 39 + use jacquard::smol_str::{SmolStr, ToSmolStr}; 40 + use jacquard::types::aturi::AtUri; 41 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 42 + use jacquard::types::blob::BlobRef; 43 + use jacquard::types::ident::AtIdentifier; 44 + use weaver_api::sh_weaver::embed::images::Image; 45 + use weaver_common::WeaverExt; 42 46 43 47 /// Result of loading document state. 44 48 enum LoadResult { ··· 47 51 /// Loading failed 48 52 Failed(String), 49 53 /// Still loading 54 + #[allow(dead_code)] 50 55 Loading, 51 56 } 52 57 ··· 412 417 let fetcher = use_context::<Fetcher>(); 413 418 let auth_state = use_context::<Signal<AuthState>>(); 414 419 420 + #[allow(unused_mut)] 415 421 let mut document = use_hook(|| { 416 422 let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); 417 423 ··· 459 465 let doc_for_memo = document.clone(); 460 466 let doc_for_refs = document.clone(); 461 467 let entry_index_for_memo = entry_index.clone(); 468 + #[allow(unused_mut)] 462 469 let mut paragraphs = use_memo(move || { 463 470 let edit = doc_for_memo.last_edit(); 464 471 let cache = render_cache.peek(); ··· 627 634 628 635 let mut new_tag = use_signal(String::new); 629 636 637 + #[allow(unused)] 630 638 let offset_map = use_memo(move || { 631 639 paragraphs() 632 640 .iter() ··· 639 647 .flat_map(|p| p.syntax_spans.iter().cloned()) 640 648 .collect::<Vec<_>>() 641 649 }); 650 + #[allow(unused_mut)] 642 651 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 643 652 644 653 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 710 719 }); 711 720 712 721 // Track last saved frontiers to detect changes (peek-only, no subscriptions) 722 + #[allow(unused_mut, unused)] 713 723 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 714 724 715 725 // Store interval handle so it's dropped when component unmounts (prevents panic) ··· 1153 1163 // Refresh callback: fetch and merge collaborator changes (incremental) 1154 1164 let on_refresh = if is_published { 1155 1165 let fetcher_for_refresh = fetcher.clone(); 1156 - let mut doc_for_refresh = document.clone(); 1166 + let doc_for_refresh = document.clone(); 1157 1167 let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); 1158 1168 1159 1169 Some(EventHandler::new(move |_| { ··· 1398 1408 move |evt| { 1399 1409 tracing::debug!("onclick fired - syncing cursor from DOM"); 1400 1410 let paras = cached_paragraphs(); 1411 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 1412 + let _ = evt; 1401 1413 1402 1414 // Check if click target is a math-clickable element 1403 1415 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+18 -31
crates/weaver-app/src/components/editor/cursor.rs
··· 7 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 8 //! 4. Setting cursor with web_sys Selection API 9 9 10 - use super::offset_map::{OffsetMapping, SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 10 + use super::offset_map::OffsetMapping; 11 + #[cfg(all(target_family = "wasm", target_os = "unknown"))] 12 + use super::offset_map::{SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 11 13 12 14 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 15 use wasm_bindgen::JsCast; ··· 46 48 Some((m, false)) => (m, char_offset), // Valid position, use as-is 47 49 Some((m, true)) => { 48 50 // Position is on invisible content, snap to nearest valid 49 - if let Some(snapped) = find_nearest_valid_position(offset_map, char_offset, snap_direction) { 51 + if let Some(snapped) = 52 + find_nearest_valid_position(offset_map, char_offset, snap_direction) 53 + { 50 54 tracing::trace!( 51 55 target: "weaver::cursor", 52 56 original_offset = char_offset, ··· 190 194 Err("no text node found in container".into()) 191 195 } 192 196 193 - /// Non-WASM stub for testing 194 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 195 - pub fn restore_cursor_position( 196 - _char_offset: usize, 197 - _offset_map: &[OffsetMapping], 198 - _editor_id: &str, 199 - _snap_direction: Option<SnapDirection>, 200 - ) -> Result<(), String> { 201 - Ok(()) 202 - } 203 - 204 197 /// Screen coordinates for a cursor position. 205 198 #[derive(Debug, Clone, Copy)] 206 199 pub struct CursorRect { ··· 232 225 let document = window.document()?; 233 226 234 227 // Get container element 235 - let container = document 236 - .get_element_by_id(&mapping.node_id) 237 - .or_else(|| { 238 - let selector = format!("[data-node-id='{}']", mapping.node_id); 239 - document.query_selector(&selector).ok().flatten() 240 - })?; 228 + let container = document.get_element_by_id(&mapping.node_id).or_else(|| { 229 + let selector = format!("[data-node-id='{}']", mapping.node_id); 230 + document.query_selector(&selector).ok().flatten() 231 + })?; 241 232 242 233 let range = document.create_range().ok()?; 243 234 ··· 288 279 y: cursor_rect.y - editor_rect.y(), 289 280 height: cursor_rect.height, 290 281 }) 291 - } 292 - 293 - #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 294 - pub fn get_cursor_rect( 295 - _char_offset: usize, 296 - _offset_map: &[OffsetMapping], 297 - _editor_id: &str, 298 - ) -> Option<CursorRect> { 299 - None 300 282 } 301 283 302 284 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] ··· 378 360 // Set start 379 361 if let Some(child_index) = start_mapping.child_index { 380 362 let _ = range.set_start(&start_container, child_index as u32); 381 - } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() { 363 + } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() 364 + { 382 365 let offset_in_range = start - start_mapping.char_range.start; 383 366 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; 384 - if let Ok((text_node, node_offset)) = find_text_node_at_offset(&container_element, target_utf16_offset) { 367 + if let Ok((text_node, node_offset)) = 368 + find_text_node_at_offset(&container_element, target_utf16_offset) 369 + { 385 370 let _ = range.set_start(&text_node, node_offset as u32); 386 371 } 387 372 } ··· 392 377 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 393 378 let offset_in_range = end - end_mapping.char_range.start; 394 379 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; 395 - if let Ok((text_node, node_offset)) = find_text_node_at_offset(&container_element, target_utf16_offset) { 380 + if let Ok((text_node, node_offset)) = 381 + find_text_node_at_offset(&container_element, target_utf16_offset) 382 + { 396 383 let _ = range.set_end(&text_node, node_offset as u32); 397 384 } 398 385 }
+49 -53
crates/weaver-app/src/components/editor/dom_sync.rs
··· 3 3 //! Handles syncing cursor/selection state between the browser DOM and our 4 4 //! internal document model, and updating paragraph DOM elements. 5 5 6 - use dioxus::prelude::*; 7 - 6 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 8 7 use super::cursor::restore_cursor_position; 8 + #[allow(unused_imports)] 9 9 use super::document::{EditorDocument, Selection}; 10 + #[allow(unused_imports)] 10 11 use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 11 12 use super::paragraph::ParagraphRender; 13 + #[allow(unused_imports)] 14 + use dioxus::prelude::*; 12 15 13 16 /// Sync internal cursor and selection state from browser DOM selection. 14 17 /// ··· 93 96 (Some(anchor), Some(focus)) => { 94 97 let old_offset = doc.cursor.read().offset; 95 98 // Warn if cursor is jumping a large distance - likely a bug 96 - let jump = if focus > old_offset { focus - old_offset } else { old_offset - focus }; 99 + let jump = if focus > old_offset { 100 + focus - old_offset 101 + } else { 102 + old_offset - focus 103 + }; 97 104 if jump > 100 { 98 105 tracing::warn!( 99 106 old_offset, ··· 179 186 } 180 187 } 181 188 // Couldn't find containing paragraph, fall through 182 - tracing::warn!("dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph"); 189 + tracing::warn!( 190 + "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph" 191 + ); 183 192 break None; 184 193 } 185 194 ··· 432 441 let mut cursor_para_updated = false; 433 442 434 443 // Build lookup for old paragraphs by ID (for syntax span comparison) 435 - let old_para_map: HashMap<&str, &ParagraphRender> = old_paragraphs 436 - .iter() 437 - .map(|p| (p.id.as_str(), p)) 438 - .collect(); 444 + let old_para_map: HashMap<&str, &ParagraphRender> = 445 + old_paragraphs.iter().map(|p| (p.id.as_str(), p)).collect(); 439 446 440 447 // Build pool of existing DOM elements by ID 441 448 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new(); ··· 452 459 453 460 // Track position for insertBefore - starts at first element child 454 461 // (use first_element_child to skip any stray text nodes) 455 - let mut cursor_node: Option<web_sys::Node> = 456 - editor.first_element_child().map(|e| e.into()); 462 + let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into()); 457 463 458 464 // Single pass through new paragraphs 459 465 for new_para in new_paragraphs.iter() { ··· 499 505 // 500 506 // HOWEVER: we must verify browser actually updated the DOM. 501 507 // PassThrough assumes browser handles edit, but sometimes it doesn't. 502 - let should_skip_cursor_update = !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && { 503 - let old_para = old_para_map.get(para_id.as_str()); 504 - let syntax_unchanged = old_para 505 - .map(|old| old.syntax_spans == new_para.syntax_spans) 506 - .unwrap_or(false); 508 + let should_skip_cursor_update = 509 + !FORCE_INNERHTML_UPDATE && is_cursor_para && !force && { 510 + let old_para = old_para_map.get(para_id.as_str()); 511 + let syntax_unchanged = old_para 512 + .map(|old| old.syntax_spans == new_para.syntax_spans) 513 + .unwrap_or(false); 507 514 508 - // Verify DOM content length matches expected - if not, browser didn't handle it 509 - // NOTE: Get inner element (the <p>) not outer div, to avoid counting 510 - // the newline from </p>\n in the HTML 511 - let dom_matches_expected = if syntax_unchanged { 512 - let inner_elem = existing_elem.first_element_child(); 513 - let dom_text = inner_elem 514 - .as_ref() 515 - .and_then(|e| e.text_content()) 516 - .unwrap_or_default(); 517 - let expected_len = new_para.byte_range.end - new_para.byte_range.start; 518 - let dom_len = dom_text.len(); 519 - let matches = dom_len == expected_len; 520 - // Always log for debugging 521 - tracing::debug!( 522 - para_id = %para_id, 523 - dom_len, 524 - expected_len, 525 - matches, 526 - dom_text = %dom_text, 527 - "DOM sync check" 528 - ); 529 - matches 530 - } else { 531 - false 532 - }; 515 + // Verify DOM content length matches expected - if not, browser didn't handle it 516 + // NOTE: Get inner element (the <p>) not outer div, to avoid counting 517 + // the newline from </p>\n in the HTML 518 + let dom_matches_expected = if syntax_unchanged { 519 + let inner_elem = existing_elem.first_element_child(); 520 + let dom_text = inner_elem 521 + .as_ref() 522 + .and_then(|e| e.text_content()) 523 + .unwrap_or_default(); 524 + let expected_len = new_para.byte_range.end - new_para.byte_range.start; 525 + let dom_len = dom_text.len(); 526 + let matches = dom_len == expected_len; 527 + // Always log for debugging 528 + tracing::debug!( 529 + para_id = %para_id, 530 + dom_len, 531 + expected_len, 532 + matches, 533 + dom_text = %dom_text, 534 + "DOM sync check" 535 + ); 536 + matches 537 + } else { 538 + false 539 + }; 533 540 534 - syntax_unchanged && dom_matches_expected 535 - }; 541 + syntax_unchanged && dom_matches_expected 542 + }; 536 543 537 544 if should_skip_cursor_update { 538 545 tracing::trace!( ··· 607 614 608 615 cursor_para_updated 609 616 } 610 - 611 - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 612 - pub fn update_paragraph_dom( 613 - _editor_id: &str, 614 - _old_paragraphs: &[ParagraphRender], 615 - _new_paragraphs: &[ParagraphRender], 616 - _cursor_offset: usize, 617 - _force: bool, 618 - ) -> bool { 619 - false 620 - }
+1
crates/weaver-app/src/components/editor/formatting.rs
··· 1 1 //! Formatting actions and utilities for applying markdown formatting. 2 2 3 3 use super::document::EditorDocument; 4 + #[allow(unused_imports)] 4 5 use super::input::{ListContext, detect_list_context, find_line_end}; 5 6 use dioxus::prelude::*; 6 7
+6
crates/weaver-app/src/components/editor/input.rs
··· 10 10 11 11 /// Check if we need to intercept this key event. 12 12 /// Returns true for content-modifying operations, false for navigation. 13 + #[allow(unused)] 13 14 pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool { 14 15 use dioxus::prelude::keyboard_types::Key; 15 16 ··· 42 43 } 43 44 44 45 /// Handle keyboard events and update document state. 46 + #[allow(unused)] 45 47 pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) { 46 48 use dioxus::prelude::keyboard_types::Key; 47 49 ··· 329 331 /// Handle paste events and insert text at cursor. 330 332 pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 331 333 evt.prevent_default(); 334 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 335 + let _ = doc; 332 336 333 337 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 334 338 { ··· 369 373 /// Handle cut events - extract text, write to clipboard, then delete. 370 374 pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 371 375 evt.prevent_default(); 376 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 377 + let _ = doc; 372 378 373 379 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 374 380 {
+1
crates/weaver-app/src/components/editor/log_buffer.rs
··· 98 98 } 99 99 100 100 /// Get all captured log entries as a single string. 101 + #[allow(dead_code)] 101 102 pub fn get_logs() -> String { 102 103 LOG_BUFFER.with(|buf| { 103 104 let buf = buf.borrow();
+8 -1
crates/weaver-app/src/components/editor/offset_map.rs
··· 114 114 /// 115 115 /// Returns the mapping and whether the cursor should snap to the next 116 116 /// visible position (for invisible content). 117 + #[allow(dead_code)] 117 118 pub fn find_mapping_for_char( 118 119 offset_map: &[OffsetMapping], 119 120 char_offset: usize, ··· 162 163 163 164 /// Result of finding a valid cursor position. 164 165 #[derive(Debug, Clone)] 166 + #[allow(dead_code)] 165 167 pub struct SnappedPosition<'a> { 166 168 pub mapping: &'a OffsetMapping, 167 169 pub offset_in_mapping: usize, 168 170 pub snapped: Option<SnapDirection>, 169 171 } 170 172 173 + #[allow(dead_code)] 171 174 impl SnappedPosition<'_> { 172 175 /// Get the absolute char offset for this position. 173 176 pub fn char_offset(&self) -> usize { ··· 181 184 /// If the position is already valid, returns it directly. Otherwise, 182 185 /// searches in the preferred direction first, falling back to the other 183 186 /// direction if needed. 187 + #[allow(dead_code)] 184 188 pub fn find_nearest_valid_position( 185 189 offset_map: &[OffsetMapping], 186 190 char_offset: usize, ··· 219 223 } 220 224 221 225 /// Search for a valid position in a specific direction. 226 + #[allow(dead_code)] 222 227 fn find_valid_in_direction( 223 228 offset_map: &[OffsetMapping], 224 229 char_offset: usize, ··· 275 280 } 276 281 277 282 /// Check if a char offset is at a valid (non-invisible) cursor position. 283 + #[allow(dead_code)] 278 284 pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { 279 285 find_mapping_for_char(offset_map, char_offset) 280 286 .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) ··· 473 479 let mappings = make_test_mappings(); 474 480 475 481 // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) 476 - let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); 482 + let pos = 483 + find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); 477 484 assert_eq!(pos.char_offset(), 5); // end of "alt" mapping 478 485 assert_eq!(pos.snapped, Some(SnapDirection::Backward)); 479 486 }
+1
crates/weaver-app/src/components/editor/platform.rs
··· 6 6 7 7 /// Cached platform detection results. 8 8 #[derive(Debug, Clone)] 9 + #[allow(dead_code)] 9 10 pub struct Platform { 10 11 pub ios: bool, 11 12 pub mac: bool,
+1
crates/weaver-app/src/components/editor/publish.rs
··· 8 8 use jacquard::types::collection::Collection; 9 9 use jacquard::types::ident::AtIdentifier; 10 10 use jacquard::types::recordkey::RecordKey; 11 + #[allow(unused_imports)] 11 12 use jacquard::types::string::{AtUri, Datetime, Nsid, Rkey}; 12 13 use jacquard::types::tid::Ticker; 13 14 use jacquard::{IntoStatic, from_data, prelude::*, to_data};
+3 -8
crates/weaver-app/src/components/editor/render.rs
··· 5 5 //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 6 6 7 7 use super::document::EditInfo; 8 + #[allow(unused_imports)] 8 9 use super::offset_map::{OffsetMapping, RenderResult}; 9 10 use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; 11 + #[allow(unused_imports)] 10 12 use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; 11 13 use loro::LoroText; 12 14 use markdown_weaver::Parser; 13 - use std::collections::HashMap; 14 15 use std::ops::Range; 15 16 use weaver_common::{EntryIndex, ResolvedContent}; 16 17 ··· 94 95 if gap_size > MIN_PARAGRAPH_BREAK_INCR { 95 96 let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; 96 97 let gap_end_char = para.char_range.start; 97 - let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; 98 98 let gap_end_byte = para.byte_range.start; 99 99 100 100 let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); ··· 724 724 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 725 725 let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 726 726 // next_para_id must account for all IDs allocated by the writer 727 - let mut next_para_id = parsed_para_id_start + parsed_para_count; 727 + let next_para_id = parsed_para_id_start + parsed_para_count; 728 728 let reused_count = reused_paragraphs.len(); 729 729 730 730 // Find which paragraph contains cursor (for stable ID assignment) ··· 740 740 parsed_count = parsed_paragraph_ranges.len(), 741 741 "ID assignment: cursor and edit info" 742 742 ); 743 - 744 - // Build hash->cached_para lookup for non-cursor matching 745 - let cached_by_hash: HashMap<u64, &CachedParagraph> = cache 746 - .map(|c| c.paragraphs.iter().map(|p| (p.source_hash, p)).collect()) 747 - .unwrap_or_default(); 748 743 749 744 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 750 745 let para_source = text_slice_to_string(text, char_range.clone());
+3
crates/weaver-app/src/components/editor/report.rs
··· 5 5 6 6 use dioxus::prelude::*; 7 7 8 + #[allow(unused_imports)] 8 9 use super::log_buffer; 10 + #[allow(unused_imports)] 9 11 use super::storage::load_from_storage; 10 12 11 13 /// Captured report data. ··· 112 114 let email = props.email.clone(); 113 115 let submit_report = move |_| { 114 116 let data = report_data(); 117 + #[allow(unused_variables)] 115 118 let mailto_url = data.to_mailto(&email, &comment()); 116 119 117 120 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+4
crates/weaver-app/src/components/editor/storage.rs
··· 19 19 use dioxus::prelude::*; 20 20 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 21 use gloo_storage::{LocalStorage, Storage}; 22 + #[allow(unused_imports)] 22 23 use jacquard::IntoStatic; 24 + #[allow(unused_imports)] 23 25 use jacquard::smol_str::{SmolStr, ToSmolStr}; 26 + #[allow(unused_imports)] 24 27 use jacquard::types::string::{AtUri, Cid}; 25 28 use loro::cursor::Cursor; 26 29 use serde::{Deserialize, Serialize}; ··· 78 81 } 79 82 80 83 /// Build the full storage key from a draft key. 84 + #[allow(dead_code)] 81 85 fn storage_key(key: &str) -> String { 82 86 format!("{}{}", DRAFT_KEY_PREFIX, key) 83 87 }
+2
crates/weaver-app/src/components/editor/sync.rs
··· 25 25 use jacquard::prelude::*; 26 26 use jacquard::smol_str::format_smolstr; 27 27 use jacquard::types::blob::MimeType; 28 + #[allow(unused_imports)] 28 29 use jacquard::types::collection::Collection; 29 30 use jacquard::types::ident::AtIdentifier; 30 31 use jacquard::types::recordkey::RecordKey; ··· 43 44 use weaver_api::sh_weaver::edit::root::Root; 44 45 use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; 45 46 use weaver_common::constellation::{GetBacklinksQuery, RecordId}; 47 + #[allow(unused_imports)] 46 48 use weaver_common::{WeaverError, WeaverExt}; 47 49 48 50 const ROOT_NSID: &str = "sh.weaver.edit.root";
+1
crates/weaver-app/src/components/editor/visibility.rs
··· 124 124 } 125 125 126 126 /// Check if cursor is in the same paragraph as a syntax span. 127 + #[allow(dead_code)] 127 128 fn cursor_in_same_paragraph( 128 129 cursor_offset: usize, 129 130 syntax_range: &Range<usize>,
+351 -32
crates/weaver-app/src/components/editor/writer.rs
··· 5 5 //! 6 6 //! Uses Parser::into_offset_iter() to track gaps between events, which 7 7 //! represent consumed formatting characters. 8 - 8 + #[allow(unused_imports)] 9 9 use super::offset_map::{OffsetMapping, RenderResult}; 10 10 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 11 11 use loro::LoroText; 12 12 use markdown_weaver::{ 13 13 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 14 + WeaverAttributes, 14 15 }; 15 16 use markdown_weaver_escape::{ 16 17 StrWrite, escape_href, escape_html, escape_html_body_text, ··· 30 31 segments: Vec<String>, 31 32 } 32 33 34 + #[allow(dead_code)] 33 35 impl SegmentedWriter { 34 36 pub fn new() -> Self { 35 37 Self { ··· 375 377 } 376 378 } 377 379 380 + /// Tracks the type of wrapper element emitted for WeaverBlock prefix 381 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 382 + enum WrapperElement { 383 + Aside, 384 + Div, 385 + } 386 + 378 387 /// HTML writer that preserves markdown formatting characters. 379 388 /// 380 389 /// This writer processes offset-iter events to detect gaps (consumed formatting) ··· 416 425 417 426 // Offset mapping tracking - current paragraph 418 427 offset_maps: Vec<OffsetMapping>, 419 - node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs 420 - auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value 428 + node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs 429 + auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value 421 430 static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index 422 - current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) 431 + current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) 423 432 next_node_id: usize, 424 433 current_node_id: Option<String>, // node ID for current text container 425 434 current_node_char_offset: usize, // UTF-16 offset within current node ··· 450 459 syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>, 451 460 refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>, 452 461 462 + // WeaverBlock prefix system 463 + /// Pending WeaverBlock attrs to apply to the next block element 464 + pending_block_attrs: Option<WeaverAttributes<'static>>, 465 + /// Type of wrapper element currently open (needs closing on block end) 466 + active_wrapper: Option<WrapperElement>, 467 + /// Buffer for WeaverBlock text content (to parse for attrs) 468 + weaver_block_buffer: String, 469 + /// Start char offset of current WeaverBlock (for syntax span) 470 + weaver_block_char_start: Option<usize>, 471 + 472 + // Footnote syntax linking (ref ↔ definition visibility) 473 + /// Maps footnote name → (syntax_span_index, char_start) for linking ref and def 474 + footnote_ref_spans: HashMap<String, (usize, usize)>, 475 + /// Current footnote definition being processed (name, syntax_span_index, char_start) 476 + current_footnote_def: Option<(String, usize, usize)>, 477 + 453 478 _phantom: std::marker::PhantomData<&'a ()>, 454 479 } 455 480 ··· 519 544 offset_maps_by_para: Vec::new(), 520 545 syntax_spans_by_para: Vec::new(), 521 546 refs_by_para: Vec::new(), 547 + pending_block_attrs: None, 548 + active_wrapper: None, 549 + weaver_block_buffer: String::new(), 550 + weaver_block_char_start: None, 551 + footnote_ref_spans: HashMap::new(), 552 + current_footnote_def: None, 522 553 _phantom: std::marker::PhantomData, 523 554 } 524 555 } ··· 578 609 offset_maps_by_para: self.offset_maps_by_para, 579 610 syntax_spans_by_para: self.syntax_spans_by_para, 580 611 refs_by_para: self.refs_by_para, 612 + pending_block_attrs: self.pending_block_attrs, 613 + active_wrapper: self.active_wrapper, 614 + weaver_block_buffer: self.weaver_block_buffer, 615 + weaver_block_char_start: self.weaver_block_char_start, 616 + footnote_ref_spans: self.footnote_ref_spans, 617 + current_footnote_def: self.current_footnote_def, 581 618 _phantom: std::marker::PhantomData, 582 619 } 583 620 } ··· 608 645 } 609 646 610 647 /// Get the next paragraph ID that would be assigned (for tracking allocations). 648 + #[allow(dead_code)] 611 649 pub fn next_paragraph_id(&self) -> Option<usize> { 612 650 self.auto_increment_prefix 613 651 } ··· 1106 1144 } 1107 1145 1108 1146 // Consume raw text events until end tag, for alt attributes 1147 + #[allow(dead_code)] 1109 1148 fn raw_text(&mut self) -> Result<(), fmt::Error> { 1110 1149 use Event::*; 1111 1150 let mut nest = 0; ··· 1170 1209 } 1171 1210 } 1172 1211 1212 + /// Parse WeaverBlock text content into attributes. 1213 + /// Format: comma-separated, colon for key:value, otherwise class. 1214 + /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")] 1215 + fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> { 1216 + let mut classes = Vec::new(); 1217 + let mut attrs = Vec::new(); 1218 + 1219 + for part in text.split(',') { 1220 + let part = part.trim(); 1221 + if part.is_empty() { 1222 + continue; 1223 + } 1224 + 1225 + if let Some((key, value)) = part.split_once(':') { 1226 + let key = key.trim(); 1227 + let value = value.trim(); 1228 + if !key.is_empty() && !value.is_empty() { 1229 + attrs.push((CowStr::from(key.to_string()), CowStr::from(value.to_string()))); 1230 + } 1231 + } else { 1232 + // No colon - treat as class, strip leading dot if present 1233 + let class = part.strip_prefix('.').unwrap_or(part); 1234 + if !class.is_empty() { 1235 + classes.push(CowStr::from(class.to_string())); 1236 + } 1237 + } 1238 + } 1239 + 1240 + WeaverAttributes { classes, attrs } 1241 + } 1242 + 1243 + /// Emit wrapper element start based on pending block attrs. 1244 + /// Returns true if a wrapper was emitted. 1245 + fn emit_wrapper_start(&mut self) -> Result<bool, fmt::Error> { 1246 + if let Some(attrs) = self.pending_block_attrs.take() { 1247 + let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside"); 1248 + 1249 + if !self.end_newline { 1250 + self.write("\n")?; 1251 + } 1252 + 1253 + if is_aside { 1254 + self.write("<aside")?; 1255 + self.active_wrapper = Some(WrapperElement::Aside); 1256 + } else { 1257 + self.write("<div")?; 1258 + self.active_wrapper = Some(WrapperElement::Div); 1259 + } 1260 + 1261 + // Write classes (excluding "aside" if using <aside> element) 1262 + let classes: Vec<_> = if is_aside { 1263 + attrs 1264 + .classes 1265 + .iter() 1266 + .filter(|c| c.as_ref() != "aside") 1267 + .collect() 1268 + } else { 1269 + attrs.classes.iter().collect() 1270 + }; 1271 + 1272 + if !classes.is_empty() { 1273 + self.write(" class=\"")?; 1274 + for (i, class) in classes.iter().enumerate() { 1275 + if i > 0 { 1276 + self.write(" ")?; 1277 + } 1278 + escape_html(&mut self.writer, class)?; 1279 + } 1280 + self.write("\"")?; 1281 + } 1282 + 1283 + // Write other attrs 1284 + for (attr, value) in &attrs.attrs { 1285 + self.write(" ")?; 1286 + escape_html(&mut self.writer, attr)?; 1287 + self.write("=\"")?; 1288 + escape_html(&mut self.writer, value)?; 1289 + self.write("\"")?; 1290 + } 1291 + 1292 + self.write(">\n")?; 1293 + Ok(true) 1294 + } else { 1295 + Ok(false) 1296 + } 1297 + } 1298 + 1299 + /// Close active wrapper element if one is open 1300 + fn close_wrapper(&mut self) -> Result<(), fmt::Error> { 1301 + if let Some(wrapper) = self.active_wrapper.take() { 1302 + match wrapper { 1303 + WrapperElement::Aside => self.write("</aside>\n")?, 1304 + WrapperElement::Div => self.write("</div>\n")?, 1305 + } 1306 + } 1307 + Ok(()) 1308 + } 1309 + 1173 1310 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> { 1174 1311 use Event::*; 1175 1312 ··· 1546 1683 escape_html(&mut self.writer, spaces)?; 1547 1684 self.write("</span>")?; 1548 1685 1549 - // Record syntax span info 1550 - // self.syntax_spans.push(SyntaxSpanInfo { 1551 - // syn_id, 1552 - // char_range: char_start..char_end, 1553 - // syntax_type: SyntaxType::Inline, 1554 - // formatted_range: None, 1555 - // }); 1556 - 1557 1686 // Count this span as a child 1558 1687 self.current_node_child_count += 1; 1559 1688 ··· 1570 1699 1571 1700 // After <br>, emit plain zero-width space for cursor positioning 1572 1701 self.write(" ")?; 1573 - //self.write("\u{200B}")?; 1574 1702 1575 1703 // Count the zero-width space text node as a child 1576 1704 self.current_node_child_count += 1; ··· 1636 1764 self.write("<div class=\"toggle-block\"><hr /></div>\n")?; 1637 1765 } 1638 1766 FootnoteReference(name) => { 1767 + // Get/create footnote number 1639 1768 let len = self.numbers.len() + 1; 1640 - self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 1641 - escape_html(&mut self.writer, &name)?; 1642 - self.write("\">")?; 1643 1769 let number = *self.numbers.entry(name.to_string()).or_insert(len); 1644 - write!(&mut self.writer, "{}", number)?; 1645 - self.write("</a></sup>")?; 1770 + 1771 + // Emit the [^name] syntax as a hideable syntax span 1772 + let raw_text = &self.source[range.clone()]; 1773 + let char_start = self.last_char_offset; 1774 + let syntax_char_len = raw_text.chars().count(); 1775 + let char_end = char_start + syntax_char_len; 1776 + let syn_id = self.gen_syn_id(); 1777 + 1778 + write!( 1779 + &mut self.writer, 1780 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1781 + syn_id, char_start, char_end 1782 + )?; 1783 + escape_html(&mut self.writer, raw_text)?; 1784 + self.write("</span>")?; 1785 + 1786 + // Track this span for linking with the footnote definition later 1787 + let span_index = self.syntax_spans.len(); 1788 + self.syntax_spans.push(SyntaxSpanInfo { 1789 + syn_id, 1790 + char_range: char_start..char_end, 1791 + syntax_type: SyntaxType::Inline, 1792 + formatted_range: None, // Set when we see the definition 1793 + }); 1794 + self.footnote_ref_spans 1795 + .insert(name.to_string(), (span_index, char_start)); 1796 + 1797 + // Record offset mapping for the syntax span content 1798 + self.record_mapping(range.clone(), char_start..char_end); 1799 + 1800 + // Count as child 1801 + self.current_node_child_count += 1; 1802 + 1803 + // Emit the visible footnote reference (superscript number) 1804 + write!( 1805 + &mut self.writer, 1806 + "<sup class=\"footnote-reference\"><a href=\"#fn-{}\">{}</a></sup>", 1807 + name, number 1808 + )?; 1809 + 1810 + // Update tracking 1811 + self.last_char_offset = char_end; 1812 + self.last_byte_offset = range.end; 1646 1813 } 1647 1814 TaskListMarker(checked) => { 1648 1815 // Emit the [ ] or [x] syntax ··· 1680 1847 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 1681 1848 } 1682 1849 } 1683 - WeaverBlock(_) => {} 1850 + WeaverBlock(text) => { 1851 + // Buffer WeaverBlock content for parsing on End 1852 + self.weaver_block_buffer.push_str(&text); 1853 + } 1684 1854 } 1685 1855 Ok(()) 1686 1856 } ··· 1830 2000 1831 2001 Ok(()) 1832 2002 } 1833 - Tag::Paragraph => { 2003 + Tag::Paragraph(_) => { 2004 + // Handle wrapper before block 2005 + self.emit_wrapper_start()?; 2006 + 1834 2007 // Record paragraph start for boundary tracking 1835 2008 // BUT skip if inside a list - list owns the paragraph boundary 1836 2009 if self.list_depth == 0 { ··· 1892 2065 classes, 1893 2066 attrs, 1894 2067 } => { 2068 + // Emit wrapper if pending (but don't close on heading end - wraps following block too) 2069 + self.emit_wrapper_start()?; 2070 + 1895 2071 // Record paragraph start for boundary tracking 1896 2072 // Treat headings as paragraph-level blocks 1897 2073 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); ··· 1980 2156 self.in_non_writing_block = true; // Suppress content output 1981 2157 Ok(()) 1982 2158 } else { 2159 + self.emit_wrapper_start()?; 1983 2160 self.table_alignments = alignments; 1984 2161 self.write("<table>") 1985 2162 } ··· 2018 2195 } 2019 2196 } 2020 2197 Tag::BlockQuote(kind) => { 2198 + self.emit_wrapper_start()?; 2199 + 2021 2200 let class_str = match kind { 2022 2201 None => "", 2023 2202 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", ··· 2037 2216 Ok(()) 2038 2217 } 2039 2218 Tag::CodeBlock(info) => { 2219 + self.emit_wrapper_start()?; 2220 + 2040 2221 // Track code block as paragraph-level block 2041 2222 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2042 2223 ··· 2110 2291 } 2111 2292 } 2112 2293 Tag::List(Some(1)) => { 2294 + self.emit_wrapper_start()?; 2113 2295 // Track list as paragraph-level block 2114 2296 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2115 2297 self.list_depth += 1; ··· 2120 2302 } 2121 2303 } 2122 2304 Tag::List(Some(start)) => { 2305 + self.emit_wrapper_start()?; 2123 2306 // Track list as paragraph-level block 2124 2307 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2125 2308 self.list_depth += 1; ··· 2132 2315 self.write("\">\n") 2133 2316 } 2134 2317 Tag::List(None) => { 2318 + self.emit_wrapper_start()?; 2135 2319 // Track list as paragraph-level block 2136 2320 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2137 2321 self.list_depth += 1; ··· 2239 2423 Ok(()) 2240 2424 } 2241 2425 Tag::DefinitionList => { 2426 + self.emit_wrapper_start()?; 2242 2427 if self.end_newline { 2243 2428 self.write("<dl>\n") 2244 2429 } else { ··· 2507 2692 id, 2508 2693 attrs, 2509 2694 } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 2510 - Tag::WeaverBlock(_, _) => { 2695 + Tag::WeaverBlock(_, attrs) => { 2511 2696 self.in_non_writing_block = true; 2697 + self.weaver_block_buffer.clear(); 2698 + self.weaver_block_char_start = Some(self.last_char_offset); 2699 + // Store attrs from Start tag, will merge with parsed text on End 2700 + if !attrs.classes.is_empty() || !attrs.attrs.is_empty() { 2701 + self.pending_block_attrs = Some(attrs.into_static()); 2702 + } 2512 2703 Ok(()) 2513 2704 } 2514 2705 Tag::FootnoteDefinition(name) => { 2515 - if self.end_newline { 2516 - self.write("<div class=\"footnote-definition\" id=\"")?; 2517 - } else { 2518 - self.write("\n<div class=\"footnote-definition\" id=\"")?; 2706 + // Emit the [^name]: prefix as a hideable syntax span 2707 + // The source should have "[^name]: " at the start 2708 + let prefix = format!("[^{}]: ", name); 2709 + let char_start = self.last_char_offset; 2710 + let prefix_char_len = prefix.chars().count(); 2711 + let char_end = char_start + prefix_char_len; 2712 + let syn_id = self.gen_syn_id(); 2713 + 2714 + if !self.end_newline { 2715 + self.write("\n")?; 2519 2716 } 2520 - escape_html(&mut self.writer, &name)?; 2521 - self.write("\"><sup class=\"footnote-definition-label\">")?; 2717 + 2718 + write!( 2719 + &mut self.writer, 2720 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2721 + syn_id, char_start, char_end 2722 + )?; 2723 + escape_html(&mut self.writer, &prefix)?; 2724 + self.write("</span>")?; 2725 + 2726 + // Track this span for linking with the footnote reference 2727 + let def_span_index = self.syntax_spans.len(); 2728 + self.syntax_spans.push(SyntaxSpanInfo { 2729 + syn_id, 2730 + char_range: char_start..char_end, 2731 + syntax_type: SyntaxType::Block, 2732 + formatted_range: None, // Set at FootnoteDefinition end 2733 + }); 2734 + 2735 + // Store the definition info for linking at end 2736 + self.current_footnote_def = Some((name.to_string(), def_span_index, char_start)); 2737 + 2738 + // Record offset mapping for the syntax span 2739 + self.record_mapping(range.start..range.start + prefix.len(), char_start..char_end); 2740 + 2741 + // Update tracking for the prefix 2742 + self.last_char_offset = char_end; 2743 + self.last_byte_offset = range.start + prefix.len(); 2744 + 2745 + // Emit the definition container 2746 + write!( 2747 + &mut self.writer, 2748 + "<div class=\"footnote-definition\" id=\"fn-{}\">", 2749 + name 2750 + )?; 2751 + 2752 + // Get/create footnote number for the label 2522 2753 let len = self.numbers.len() + 1; 2523 2754 let number = *self.numbers.entry(name.to_string()).or_insert(len); 2524 - write!(&mut self.writer, "{}", number)?; 2525 - self.write("</sup>") 2755 + write!( 2756 + &mut self.writer, 2757 + "<sup class=\"footnote-definition-label\">{}</sup>", 2758 + number 2759 + )?; 2760 + 2761 + Ok(()) 2526 2762 } 2527 2763 Tag::MetadataBlock(_) => { 2528 2764 self.in_non_writing_block = true; ··· 2566 2802 } 2567 2803 Ok(()) 2568 2804 } 2569 - TagEnd::Paragraph => { 2805 + TagEnd::Paragraph(_) => { 2570 2806 // Capture paragraph boundary info BEFORE writing closing HTML 2571 2807 // Skip if inside a list - list owns the paragraph boundary 2572 2808 let para_boundary = if self.list_depth == 0 { ··· 2585 2821 // Write closing HTML to current segment 2586 2822 self.end_node(); 2587 2823 self.write("</p>\n")?; 2824 + self.close_wrapper()?; 2588 2825 2589 2826 // Now finalize paragraph (starts new segment) 2590 2827 if let Some((byte_range, char_range)) = para_boundary { ··· 2609 2846 self.write("</")?; 2610 2847 write!(&mut self.writer, "{}", level)?; 2611 2848 self.write(">\n")?; 2849 + // Note: Don't close wrapper here - headings typically go with following block 2612 2850 2613 2851 // Now finalize paragraph (starts new segment) 2614 2852 if let Some((byte_range, char_range)) = para_boundary { ··· 2701 2939 } 2702 2940 } 2703 2941 self.write("</blockquote>\n")?; 2942 + self.close_wrapper()?; 2704 2943 2705 2944 // Now finalize paragraph if we had one 2706 2945 if let Some((byte_range, char_range)) = para_boundary { ··· 2857 3096 }); 2858 3097 2859 3098 self.write("</ol>\n")?; 3099 + self.close_wrapper()?; 2860 3100 2861 3101 // Finalize paragraph after closing HTML 2862 3102 if let Some((byte_range, char_range)) = para_boundary { ··· 2878 3118 }); 2879 3119 2880 3120 self.write("</ul>\n")?; 3121 + self.close_wrapper()?; 2881 3122 2882 3123 // Finalize paragraph after closing HTML 2883 3124 if let Some((byte_range, char_range)) = para_boundary { ··· 2889 3130 self.end_node(); 2890 3131 self.write("</li>\n") 2891 3132 } 2892 - TagEnd::DefinitionList => self.write("</dl>\n"), 3133 + TagEnd::DefinitionList => { 3134 + self.write("</dl>\n")?; 3135 + self.close_wrapper() 3136 + } 2893 3137 TagEnd::DefinitionListTitle => { 2894 3138 self.end_node(); 2895 3139 self.write("</dt>\n") ··· 2956 3200 TagEnd::Embed => Ok(()), 2957 3201 TagEnd::WeaverBlock(_) => { 2958 3202 self.in_non_writing_block = false; 3203 + 3204 + // Emit the { content } as a hideable syntax span 3205 + if let Some(char_start) = self.weaver_block_char_start.take() { 3206 + // Build the full syntax text: { buffered_content } 3207 + let syntax_text = format!("{{{}}}", self.weaver_block_buffer); 3208 + let syntax_char_len = syntax_text.chars().count(); 3209 + let char_end = char_start + syntax_char_len; 3210 + 3211 + let syn_id = self.gen_syn_id(); 3212 + 3213 + write!( 3214 + &mut self.writer, 3215 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 3216 + syn_id, char_start, char_end 3217 + )?; 3218 + escape_html(&mut self.writer, &syntax_text)?; 3219 + self.write("</span>")?; 3220 + 3221 + // Track the syntax span 3222 + self.syntax_spans.push(SyntaxSpanInfo { 3223 + syn_id, 3224 + char_range: char_start..char_end, 3225 + syntax_type: SyntaxType::Block, 3226 + formatted_range: None, 3227 + }); 3228 + 3229 + // Record offset mapping for the syntax span 3230 + self.record_mapping(range.clone(), char_start..char_end); 3231 + 3232 + // Update tracking 3233 + self.last_char_offset = char_end; 3234 + self.last_byte_offset = range.end; 3235 + } 3236 + 3237 + // Parse the buffered text for attrs and store for next block 3238 + if !self.weaver_block_buffer.is_empty() { 3239 + let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); 3240 + self.weaver_block_buffer.clear(); 3241 + // Merge with any existing pending attrs or set new 3242 + if let Some(ref mut existing) = self.pending_block_attrs { 3243 + existing.classes.extend(parsed.classes); 3244 + existing.attrs.extend(parsed.attrs); 3245 + } else { 3246 + self.pending_block_attrs = Some(parsed); 3247 + } 3248 + } 3249 + 2959 3250 Ok(()) 2960 3251 } 2961 - TagEnd::FootnoteDefinition => self.write("</div>\n"), 3252 + TagEnd::FootnoteDefinition => { 3253 + self.write("</div>\n")?; 3254 + 3255 + // Link the footnote definition span with its reference span 3256 + if let Some((name, def_span_index, _def_char_start)) = 3257 + self.current_footnote_def.take() 3258 + { 3259 + let def_char_end = self.last_char_offset; 3260 + 3261 + // Look up the reference span 3262 + if let Some(&(ref_span_index, ref_char_start)) = 3263 + self.footnote_ref_spans.get(&name) 3264 + { 3265 + // Create formatted_range spanning from ref start to def end 3266 + let formatted_range = ref_char_start..def_char_end; 3267 + 3268 + // Update both spans with the same formatted_range 3269 + // so they show/hide together based on cursor proximity 3270 + if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) { 3271 + ref_span.formatted_range = Some(formatted_range.clone()); 3272 + } 3273 + if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) { 3274 + def_span.formatted_range = Some(formatted_range); 3275 + } 3276 + } 3277 + } 3278 + 3279 + Ok(()) 3280 + } 2962 3281 TagEnd::MetadataBlock(_) => { 2963 3282 self.in_non_writing_block = false; 2964 3283 Ok(())
+49 -20
crates/weaver-app/src/components/entry.rs
··· 212 212 213 213 tracing::info!("Entry: {book_title} - {title}"); 214 214 215 + let prev_entry = book_entry_view().prev.clone(); 216 + let next_entry = book_entry_view().next.clone(); 217 + 215 218 rsx! { 216 219 EntryOgMeta { 217 220 title: title.to_string(), ··· 223 226 } 224 227 document::Link { rel: "stylesheet", href: ENTRY_CSS } 225 228 226 - div { class: "entry-page-layout", 227 - // Left gutter with prev button 228 - if let Some(ref prev) = book_entry_view().prev { 229 - div { class: "nav-gutter nav-prev", 229 + div { class: "entry-page", 230 + // Header: nav prev + metadata + nav next 231 + header { class: "entry-header", 232 + if let Some(ref prev) = prev_entry { 230 233 NavButton { 231 234 direction: "prev", 232 235 entry: prev.entry.clone(), ··· 234 237 book_title: book_title() 235 238 } 236 239 } 237 - } 238 240 239 - // Main content area 240 - div { class: "entry-content-main notebook-content", 241 - // Metadata header 242 241 { 243 242 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content); 244 243 rsx! { ··· 254 253 } 255 254 } 256 255 257 - // Rendered markdown 258 - EntryMarkdown { 259 - content: entry_record, 260 - ident 256 + if let Some(ref next) = next_entry { 257 + NavButton { 258 + direction: "next", 259 + entry: next.entry.clone(), 260 + ident: ident(), 261 + book_title: book_title() 262 + } 263 + } 264 + } 265 + 266 + // Main content area 267 + div { class: "entry-content-wrapper", 268 + div { class: "entry-content-main notebook-content", 269 + EntryMarkdown { 270 + content: entry_record, 271 + ident 272 + } 261 273 } 262 274 } 263 275 264 - // Right gutter with next button 265 - if let Some(ref next) = book_entry_view().next { 266 - div { class: "nav-gutter nav-next", 276 + // Footer navigation 277 + footer { class: "entry-footer-nav", 278 + if let Some(ref prev) = prev_entry { 279 + NavButton { 280 + direction: "prev", 281 + entry: prev.entry.clone(), 282 + ident: ident(), 283 + book_title: book_title() 284 + } 285 + } 286 + 287 + if let Some(ref next) = next_entry { 267 288 NavButton { 268 289 direction: "next", 269 290 entry: next.entry.clone(), ··· 648 669 } 649 670 } 650 671 651 - /// Navigation button for prev/next entries 672 + /// Navigation link for prev/next entries (minimal: arrow + title) 652 673 #[component] 653 674 pub fn NavButton( 654 675 direction: &'static str, ··· 662 683 .map(|t| t.as_ref()) 663 684 .unwrap_or("Untitled"); 664 685 665 - // Get path from view for URL, fallback to title 666 686 let entry_path = entry 667 687 .path 668 688 .as_ref() 669 689 .map(|p| p.as_ref().to_string()) 670 690 .unwrap_or_else(|| entry_title.to_string()); 671 691 672 - let arrow = if direction == "prev" { "←" } else { "→" }; 692 + let (arrow, title_first) = if direction == "prev" { 693 + ("←", false) 694 + } else { 695 + ("→", true) 696 + }; 673 697 674 698 rsx! { 675 699 Link { ··· 679 703 title: entry_path.into() 680 704 }, 681 705 class: "nav-button nav-button-{direction}", 682 - div { class: "nav-arrow", "{arrow}" } 683 - div { class: "nav-title", "{entry_title}" } 706 + if title_first { 707 + span { class: "nav-title", "{entry_title}" } 708 + span { class: "nav-arrow", "{arrow}" } 709 + } else { 710 + span { class: "nav-arrow", "{arrow}" } 711 + span { class: "nav-title", "{entry_title}" } 712 + } 684 713 } 685 714 } 686 715 }
+3
crates/weaver-renderer/src/atproto.rs
··· 22 22 pub use preprocess::AtProtoPreprocessContext; 23 23 pub use types::{BlobInfo, BlobName}; 24 24 pub use writer::{ClientWriter, EmbedContentProvider}; 25 + 26 + #[cfg(test)] 27 + mod tests;
+5 -5
crates/weaver-renderer/src/atproto/markdown_writer.rs
··· 48 48 49 49 fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 50 50 match tag { 51 - Tag::Paragraph => Ok(()), 51 + Tag::Paragraph(_) => Ok(()), 52 52 Tag::Heading { level, .. } => { 53 53 write!(self.writer, "{} ", "#".repeat(level as usize)) 54 54 } ··· 109 109 110 110 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 111 111 match tag { 112 - TagEnd::Paragraph => write!(self.writer, "\n\n"), 112 + TagEnd::Paragraph(_) => write!(self.writer, "\n\n"), 113 113 TagEnd::Heading(_) => write!(self.writer, "\n\n"), 114 114 TagEnd::BlockQuote(_) => write!(self.writer, "\n\n"), 115 115 TagEnd::CodeBlock => write!(self.writer, "```\n\n"), ··· 158 158 #[cfg(test)] 159 159 mod tests { 160 160 use super::*; 161 - use markdown_weaver::{Event, Tag, CowStr}; 161 + use markdown_weaver::{Event, Tag, CowStr, ParagraphContext}; 162 162 use markdown_weaver_escape::FmtWriter; 163 163 164 164 #[test] ··· 166 166 let mut output = String::new(); 167 167 let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 168 168 169 - writer.write_event(Event::Start(Tag::Paragraph)).unwrap(); 169 + writer.write_event(Event::Start(Tag::Paragraph(ParagraphContext::Complete))).unwrap(); 170 170 writer.write_event(Event::Text(CowStr::Borrowed("Hello"))).unwrap(); 171 - writer.write_event(Event::End(markdown_weaver::TagEnd::Paragraph)).unwrap(); 171 + writer.write_event(Event::End(markdown_weaver::TagEnd::Paragraph(ParagraphContext::Complete))).unwrap(); 172 172 173 173 assert_eq!(output, "Hello\n\n"); 174 174 }
+8
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__blockquote_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <blockquote> 6 + <p>This is a quote</p> 7 + <p>With multiple lines</p> 8 + </blockquote>
+8
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__code_block_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <pre><code class="wvc-code language-Rust"><span class="wvc-source wvc-rust"><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-storage wvc-type wvc-function wvc-rust">fn</span> </span><span class="wvc-entity wvc-name wvc-function wvc-rust">main</span></span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-parameters wvc-rust"><span class="wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust">(</span></span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-parameters wvc-rust"><span class="wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust">)</span></span></span></span><span class="wvc-meta wvc-function wvc-rust"> </span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust">{</span> 6 + <span class="wvc-support wvc-macro wvc-rust">println!</span><span class="wvc-meta wvc-group wvc-rust"><span class="wvc-punctuation wvc-section wvc-group wvc-begin wvc-rust">(</span></span><span class="wvc-meta wvc-group wvc-rust"><span class="wvc-string wvc-quoted wvc-double wvc-rust"><span class="wvc-punctuation wvc-definition wvc-string wvc-begin wvc-rust">&quot;</span>Hello<span class="wvc-punctuation wvc-definition wvc-string wvc-end wvc-rust">&quot;</span></span></span><span class="wvc-meta wvc-group wvc-rust"><span class="wvc-punctuation wvc-section wvc-group wvc-end wvc-rust">)</span></span><span class="wvc-punctuation wvc-terminator wvc-rust">;</span> 7 + </span><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-end wvc-rust">}</span></span></span> 8 + </span></code></pre>
+10
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__footnote_in_blockquote.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <blockquote> 6 + <p>Quote with footnote<sup class="footnote-reference"><a href="#q">1</a></sup>.</p> 7 + </blockquote> 8 + <div class="footnote-definition" id="q"><sup class="footnote-definition-label">1</sup> 9 + <p>Footnote for quote.</p> 10 + </div>
+11
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__footnote_multiple.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>First<sup class="footnote-reference"><a href="#1">1</a></sup> and second<sup class="footnote-reference"><a href="#2">2</a></sup> footnotes.</p> 6 + <div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup> 7 + <p>First note.</p> 8 + </div> 9 + <div class="footnote-definition" id="2"><sup class="footnote-definition-label">2</sup> 10 + <p>Second note.</p> 11 + </div>
+5
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__footnote_named.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>Reference<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Named footnote content.</span>.</p>
+5
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__footnote_sidenote_inline.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>Here is text<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Sidenote content.</span></p>
+5
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__footnote_traditional.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>Here is some text<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">This is the footnote definition.</span>.</p>
+5
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__footnote_with_inline_formatting.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>Text with footnote<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Note with <strong>bold</strong> and <em>italic</em>.</span>.</p>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__heading_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <h1>Heading 1</h1> 6 + <h2>Heading 2</h2> 7 + <h3>Heading 3</h3>
+16
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__list_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <ul> 6 + <li>Item 1</li> 7 + <li>Item 2 8 + <ul> 9 + <li>Nested</li> 10 + </ul> 11 + </li> 12 + </ul> 13 + <ol> 14 + <li>Ordered 1</li> 15 + <li>Ordered 2</li> 16 + </ol>
+6
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__math_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>Inline <span class="math math-inline"><math display="inline"><msup><mi>x</mi><mn>2</mn></msup></math></span> and display:</p> 6 + <p><span class="math math-display"><math display="block"><mi>y</mi><mo>=</mo><mi>m</mi><mi>x</mi><mo>+</mo><mi>b</mi></math></span></p>
+6
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__paragraph_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <p>This is a paragraph.</p> 6 + <p>This is another paragraph.</p>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__table_rendering.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <table><thead><tr><th style="text-align: left">Left</th><th style="text-align: center">Center</th><th style="text-align: right">Right</th></tr></thead><tbody> 6 + <tr><td style="text-align: left">A</td><td style="text-align: center">B</td><td style="text-align: right">C</td></tr> 7 + </tbody></table>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_aside_class.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <p>This paragraph should be in an aside.</p> 7 + </aside>
+9
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_before_blockquote.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <blockquote> 7 + <p>This blockquote is in an aside.</p> 8 + </aside> 9 + </blockquote>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_before_code_block.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <pre><code class="wvc-code language-Rust"><span class="wvc-source wvc-rust"><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-storage wvc-type wvc-function wvc-rust">fn</span> </span><span class="wvc-entity wvc-name wvc-function wvc-rust">main</span></span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-parameters wvc-rust"><span class="wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust">(</span></span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-parameters wvc-rust"><span class="wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust">)</span></span></span></span><span class="wvc-meta wvc-function wvc-rust"> </span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust">{</span></span><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-end wvc-rust">}</span></span></span> 7 + </span></code></pre>
+8
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_before_heading.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <h2>Heading in aside</h2> 7 + <p>Paragraph also in aside.</p> 8 + </aside>
+10
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_before_list.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <ul> 7 + <li>Item 1</li> 8 + <li>Item 2</li> 9 + </ul> 10 + </aside>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_custom_attributes.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <div class="foo" width="300px" data-test="value"> 6 + <p>Paragraph with class and attributes.</p> 7 + </div>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_custom_class.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <div class="highlight"> 6 + <p>This paragraph has a custom class.</p> 7 + </div>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_multiple_classes.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside class="highlight important"> 6 + <p>Multiple classes applied.</p> 7 + </aside>
+8
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_no_effect_on_following.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <p>First paragraph in aside.</p> 7 + </aside> 8 + <p>Second paragraph NOT in aside.</p>
+7
crates/weaver-renderer/src/atproto/snapshots/weaver_renderer__atproto__tests__weaver_block_with_footnote.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/atproto/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <p>Aside with a footnote<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Footnote in aside context.</span>.</p> 7 + </aside>
+228
crates/weaver-renderer/src/atproto/tests.rs
··· 1 + //! Tests for the AT Protocol ClientWriter 2 + //! 3 + //! These tests verify that ClientWriter produces the same output as StaticPageWriter 4 + //! for core markdown rendering, particularly footnotes/sidenotes. 5 + 6 + use super::writer::ClientWriter; 7 + use markdown_weaver::Parser; 8 + use markdown_weaver_escape::FmtWriter; 9 + 10 + /// Helper: Render markdown to HTML using ClientWriter 11 + fn render_markdown(input: &str) -> String { 12 + let options = crate::default_md_options(); 13 + let parser = Parser::new_ext(input, options); 14 + let mut output = String::new(); 15 + let writer: ClientWriter<'_, _, _, ()> = ClientWriter::new(parser, FmtWriter(&mut output)); 16 + writer.run().unwrap(); 17 + output 18 + } 19 + 20 + // ============================================================================= 21 + // Basic Rendering Tests 22 + // ============================================================================= 23 + 24 + #[test] 25 + fn test_smoke() { 26 + let output = render_markdown("Hello world"); 27 + assert!(output.contains("Hello world")); 28 + } 29 + 30 + #[test] 31 + fn test_paragraph_rendering() { 32 + let input = "This is a paragraph.\n\nThis is another paragraph."; 33 + let output = render_markdown(input); 34 + insta::assert_snapshot!(output); 35 + } 36 + 37 + #[test] 38 + fn test_heading_rendering() { 39 + let input = "# Heading 1\n\n## Heading 2\n\n### Heading 3"; 40 + let output = render_markdown(input); 41 + insta::assert_snapshot!(output); 42 + } 43 + 44 + #[test] 45 + fn test_list_rendering() { 46 + let input = "- Item 1\n- Item 2\n - Nested\n\n1. Ordered 1\n2. Ordered 2"; 47 + let output = render_markdown(input); 48 + insta::assert_snapshot!(output); 49 + } 50 + 51 + #[test] 52 + fn test_code_block_rendering() { 53 + let input = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```"; 54 + let output = render_markdown(input); 55 + insta::assert_snapshot!(output); 56 + } 57 + 58 + #[test] 59 + fn test_table_rendering() { 60 + let input = "| Left | Center | Right |\n|:-----|:------:|------:|\n| A | B | C |"; 61 + let output = render_markdown(input); 62 + insta::assert_snapshot!(output); 63 + } 64 + 65 + #[test] 66 + fn test_blockquote_rendering() { 67 + let input = "> This is a quote\n>\n> With multiple lines"; 68 + let output = render_markdown(input); 69 + insta::assert_snapshot!(output); 70 + } 71 + 72 + #[test] 73 + fn test_math_rendering() { 74 + let input = "Inline $x^2$ and display:\n\n$$\ny = mx + b\n$$"; 75 + let output = render_markdown(input); 76 + insta::assert_snapshot!(output); 77 + } 78 + 79 + #[test] 80 + fn test_empty_input() { 81 + let output = render_markdown(""); 82 + assert_eq!(output, ""); 83 + } 84 + 85 + #[test] 86 + fn test_html_and_special_characters() { 87 + // ClientWriter wraps inline HTML in spans to contain embeds etc 88 + let input = 89 + "Text with <special> & some text. Valid tags: <em>emphasis</em> and <strong>bold</strong>"; 90 + let output = render_markdown(input); 91 + assert!(output.contains("&amp;")); 92 + // Inline HTML gets wrapped in html-embed spans 93 + assert!(output.contains("html-embed-inline")); 94 + assert!(output.contains("<special>")); 95 + } 96 + 97 + #[test] 98 + fn test_unicode_content() { 99 + let input = "Unicode: 你好 🎉 café"; 100 + let output = render_markdown(input); 101 + assert!(output.contains("你好")); 102 + assert!(output.contains("🎉")); 103 + assert!(output.contains("café")); 104 + } 105 + 106 + // ============================================================================= 107 + // WeaverBlock Prefix Tests 108 + // ============================================================================= 109 + 110 + #[test] 111 + fn test_weaver_block_aside_class() { 112 + let input = "\n\n{.aside}\nThis paragraph should be in an aside."; 113 + let output = render_markdown(input); 114 + insta::assert_snapshot!(output); 115 + } 116 + 117 + #[test] 118 + fn test_weaver_block_custom_class() { 119 + let input = "\n\n{.highlight}\nThis paragraph has a custom class."; 120 + let output = render_markdown(input); 121 + insta::assert_snapshot!(output); 122 + } 123 + 124 + #[test] 125 + fn test_weaver_block_custom_attributes() { 126 + let input = "\n\n{.foo, width: 300px, data-test: value}\nParagraph with class and attributes."; 127 + let output = render_markdown(input); 128 + insta::assert_snapshot!(output); 129 + } 130 + 131 + #[test] 132 + fn test_weaver_block_before_heading() { 133 + let input = "\n\n{.aside}\n## Heading in aside\n\nParagraph also in aside."; 134 + let output = render_markdown(input); 135 + insta::assert_snapshot!(output); 136 + } 137 + 138 + #[test] 139 + fn test_weaver_block_before_blockquote() { 140 + let input = "\n\n{.aside}\n\n> This blockquote is in an aside."; 141 + let output = render_markdown(input); 142 + insta::assert_snapshot!(output); 143 + } 144 + 145 + #[test] 146 + fn test_weaver_block_before_list() { 147 + let input = "\n\n{.aside}\n\n- Item 1\n- Item 2"; 148 + let output = render_markdown(input); 149 + insta::assert_snapshot!(output); 150 + } 151 + 152 + #[test] 153 + fn test_weaver_block_before_code_block() { 154 + let input = "\n\n{.aside}\n\n```rust\nfn main() {}\n```"; 155 + let output = render_markdown(input); 156 + insta::assert_snapshot!(output); 157 + } 158 + 159 + #[test] 160 + fn test_weaver_block_multiple_classes() { 161 + let input = "\n\n{.aside, .highlight, .important}\nMultiple classes applied."; 162 + let output = render_markdown(input); 163 + insta::assert_snapshot!(output); 164 + } 165 + 166 + #[test] 167 + fn test_weaver_block_no_effect_on_following() { 168 + let input = "\n\n{.aside}\nFirst paragraph in aside.\n\nSecond paragraph NOT in aside."; 169 + let output = render_markdown(input); 170 + insta::assert_snapshot!(output); 171 + } 172 + 173 + // ============================================================================= 174 + // Footnote / Sidenote Tests 175 + // ============================================================================= 176 + 177 + #[test] 178 + fn test_footnote_traditional() { 179 + let input = "Here is some text[^1].\n[^1]: This is the footnote definition."; 180 + let output = render_markdown(input); 181 + insta::assert_snapshot!(output); 182 + } 183 + 184 + #[test] 185 + fn test_footnote_sidenote_inline() { 186 + let input = "Here is text[^note]\n[^note]: Sidenote content."; 187 + let output = render_markdown(input); 188 + insta::assert_snapshot!(output); 189 + } 190 + 191 + #[test] 192 + fn test_footnote_multiple() { 193 + let input = "First[^1] and second[^2] footnotes.\n[^1]: First note.\n[^2]: Second note."; 194 + let output = render_markdown(input); 195 + insta::assert_snapshot!(output); 196 + } 197 + 198 + #[test] 199 + fn test_footnote_with_inline_formatting() { 200 + let input = "Text with footnote[^fmt].\n[^fmt]: Note with **bold** and *italic*."; 201 + let output = render_markdown(input); 202 + insta::assert_snapshot!(output); 203 + } 204 + 205 + #[test] 206 + fn test_footnote_named() { 207 + let input = "Reference[^my-note].\n[^my-note]: Named footnote content."; 208 + let output = render_markdown(input); 209 + insta::assert_snapshot!(output); 210 + } 211 + 212 + #[test] 213 + fn test_footnote_in_blockquote() { 214 + let input = "> Quote with footnote[^q].\n[^q]: Footnote for quote."; 215 + let output = render_markdown(input); 216 + insta::assert_snapshot!(output); 217 + } 218 + 219 + // ============================================================================= 220 + // Combined WeaverBlock + Footnote Tests 221 + // ============================================================================= 222 + 223 + #[test] 224 + fn test_weaver_block_with_footnote() { 225 + let input = "{.aside}\nAside with a footnote[^aside].\n\n[^aside]: Footnote in aside context."; 226 + let output = render_markdown(input); 227 + insta::assert_snapshot!(output); 228 + }
+347 -29
crates/weaver-renderer/src/atproto/writer.rs
··· 5 5 6 6 use jacquard::types::string::AtUri; 7 7 use markdown_weaver::{ 8 - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 8 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, 9 + ParagraphContext, Tag, WeaverAttributes, 9 10 }; 10 11 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 11 12 use std::collections::HashMap; 12 13 use weaver_common::ResolvedContent; 14 + 15 + /// Tracks the type of wrapper element emitted for WeaverBlock prefix 16 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 17 + enum WrapperElement { 18 + Aside, 19 + Div, 20 + } 13 21 14 22 /// Synchronous callback for injecting embed content 15 23 /// ··· 69 77 embed_provider: Option<E>, 70 78 71 79 code_buffer: Option<(Option<String>, String)>, // (lang, content) 80 + 81 + /// Pending WeaverBlock attrs to apply to the next block element 82 + pending_block_attrs: Option<WeaverAttributes<'static>>, 83 + /// Type of wrapper element currently open (needs closing on block end) 84 + active_wrapper: Option<WrapperElement>, 85 + /// Buffer for WeaverBlock text content (to parse for attrs) 86 + weaver_block_buffer: String, 87 + /// Pending footnote reference waiting to see if definition follows immediately 88 + pending_footnote: Option<(String, usize)>, 89 + /// Buffer for content between footnote ref and resolution 90 + pending_footnote_content: String, 91 + /// Whether current footnote definition is being rendered as a sidenote 92 + in_sidenote: bool, 93 + /// Whether we're deferring paragraph close for sidenote handling 94 + defer_paragraph_close: bool, 95 + 72 96 _phantom: std::marker::PhantomData<&'a ()>, 73 97 } 74 98 ··· 95 119 numbers: self.numbers, 96 120 embed_provider: Some(provider), 97 121 code_buffer: self.code_buffer, 122 + pending_block_attrs: self.pending_block_attrs, 123 + active_wrapper: self.active_wrapper, 124 + weaver_block_buffer: self.weaver_block_buffer, 125 + pending_footnote: self.pending_footnote, 126 + pending_footnote_content: self.pending_footnote_content, 127 + in_sidenote: self.in_sidenote, 128 + defer_paragraph_close: self.defer_paragraph_close, 98 129 _phantom: std::marker::PhantomData, 99 130 } 100 131 } ··· 115 146 numbers: HashMap::new(), 116 147 embed_provider: None, 117 148 code_buffer: None, 149 + pending_block_attrs: None, 150 + active_wrapper: None, 151 + weaver_block_buffer: String::new(), 152 + pending_footnote: None, 153 + pending_footnote_content: String::new(), 154 + in_sidenote: false, 155 + defer_paragraph_close: false, 118 156 _phantom: std::marker::PhantomData, 119 157 } 120 158 } 121 159 160 + /// Parse WeaverBlock text content into attributes. 161 + fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> { 162 + let mut classes = Vec::new(); 163 + let mut attrs = Vec::new(); 164 + 165 + for part in text.split(',') { 166 + let part = part.trim(); 167 + if part.is_empty() { 168 + continue; 169 + } 170 + 171 + if let Some((key, value)) = part.split_once(':') { 172 + let key = key.trim(); 173 + let value = value.trim(); 174 + if !key.is_empty() && !value.is_empty() { 175 + attrs.push(( 176 + CowStr::from(key.to_string()), 177 + CowStr::from(value.to_string()), 178 + )); 179 + } 180 + } else { 181 + let class = part.strip_prefix('.').unwrap_or(part); 182 + if !class.is_empty() { 183 + classes.push(CowStr::from(class.to_string())); 184 + } 185 + } 186 + } 187 + 188 + WeaverAttributes { classes, attrs } 189 + } 190 + 191 + /// Close deferred paragraph if we're in that state. 192 + /// Called when a non-paragraph block element starts. 193 + fn close_deferred_paragraph(&mut self) -> Result<(), W::Error> { 194 + if self.defer_paragraph_close { 195 + // Flush pending footnote as traditional before closing 196 + self.flush_pending_footnote()?; 197 + self.write("</p>\n")?; 198 + self.close_wrapper()?; 199 + self.defer_paragraph_close = false; 200 + } 201 + Ok(()) 202 + } 203 + 204 + /// Flush any pending footnote reference as a traditional footnote 205 + fn flush_pending_footnote(&mut self) -> Result<(), W::Error> { 206 + if let Some((name, number)) = self.pending_footnote.take() { 207 + self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 208 + escape_html(&mut self.writer, &name)?; 209 + self.write("\">")?; 210 + write!(&mut self.writer, "{}", number)?; 211 + self.write("</a></sup>")?; 212 + if !self.pending_footnote_content.is_empty() { 213 + let content = std::mem::take(&mut self.pending_footnote_content); 214 + escape_html_body_text(&mut self.writer, &content)?; 215 + self.end_newline = content.ends_with('\n'); 216 + } 217 + } 218 + Ok(()) 219 + } 220 + 221 + /// Emit wrapper element start based on pending block attrs 222 + fn emit_wrapper_start(&mut self) -> Result<bool, W::Error> { 223 + if let Some(attrs) = self.pending_block_attrs.take() { 224 + let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside"); 225 + 226 + if !self.end_newline { 227 + self.write("\n")?; 228 + } 229 + 230 + if is_aside { 231 + self.write("<aside")?; 232 + self.active_wrapper = Some(WrapperElement::Aside); 233 + } else { 234 + self.write("<div")?; 235 + self.active_wrapper = Some(WrapperElement::Div); 236 + } 237 + 238 + let classes: Vec<_> = if is_aside { 239 + attrs 240 + .classes 241 + .iter() 242 + .filter(|c| c.as_ref() != "aside") 243 + .collect() 244 + } else { 245 + attrs.classes.iter().collect() 246 + }; 247 + 248 + if !classes.is_empty() { 249 + self.write(" class=\"")?; 250 + for (i, class) in classes.iter().enumerate() { 251 + if i > 0 { 252 + self.write(" ")?; 253 + } 254 + escape_html(&mut self.writer, class)?; 255 + } 256 + self.write("\"")?; 257 + } 258 + 259 + for (attr, value) in &attrs.attrs { 260 + self.write(" ")?; 261 + escape_html(&mut self.writer, attr)?; 262 + self.write("=\"")?; 263 + escape_html(&mut self.writer, value)?; 264 + self.write("\"")?; 265 + } 266 + 267 + self.write(">\n")?; 268 + Ok(true) 269 + } else { 270 + Ok(false) 271 + } 272 + } 273 + 274 + /// Close active wrapper element if one is open 275 + fn close_wrapper(&mut self) -> Result<(), W::Error> { 276 + if let Some(wrapper) = self.active_wrapper.take() { 277 + match wrapper { 278 + WrapperElement::Aside => self.write("</aside>\n")?, 279 + WrapperElement::Div => self.write("</div>\n")?, 280 + } 281 + } 282 + Ok(()) 283 + } 284 + 122 285 #[inline] 123 286 fn write_newline(&mut self) -> Result<(), W::Error> { 124 287 self.end_newline = true; ··· 139 302 while let Some(event) = self.events.next() { 140 303 self.process_event(event)?; 141 304 } 305 + self.finalize()?; 142 306 Ok(self.writer) 307 + } 308 + 309 + /// Finalize output, closing any deferred state 310 + fn finalize(&mut self) -> Result<(), W::Error> { 311 + // Flush any pending footnote as traditional 312 + self.flush_pending_footnote()?; 313 + // Close deferred paragraph if any 314 + if self.defer_paragraph_close { 315 + self.write("</p>\n")?; 316 + self.close_wrapper()?; 317 + self.defer_paragraph_close = false; 318 + } 319 + Ok(()) 143 320 } 144 321 145 322 /// Consume events until End tag without writing anything. ··· 216 393 // If buffering code, append to buffer instead of writing 217 394 if let Some((_, ref mut buffer)) = self.code_buffer { 218 395 buffer.push_str(&text); 396 + } else if self.pending_footnote.is_some() { 397 + // Buffer text while waiting to see if footnote def follows 398 + self.pending_footnote_content.push_str(&text); 219 399 } else if !self.in_non_writing_block { 220 400 escape_html_body_text(&mut self.writer, &text)?; 221 401 self.end_newline = text.ends_with('\n'); ··· 254 434 self.write(&html)?; 255 435 self.write("</span>")?; 256 436 } 257 - SoftBreak => self.write_newline()?, 258 - HardBreak => self.write("<br />\n")?, 437 + SoftBreak => { 438 + if self.pending_footnote.is_some() { 439 + self.pending_footnote_content.push('\n'); 440 + } else { 441 + self.write_newline()?; 442 + } 443 + } 444 + HardBreak => { 445 + if self.pending_footnote.is_some() { 446 + self.pending_footnote_content.push_str("<br />\n"); 447 + } else { 448 + self.write("<br />\n")?; 449 + } 450 + } 259 451 Rule => { 260 452 if self.end_newline { 261 453 self.write("<hr />\n")?; ··· 264 456 } 265 457 } 266 458 FootnoteReference(name) => { 459 + // Flush any existing pending footnote as traditional 460 + self.flush_pending_footnote()?; 461 + // Get/create footnote number 267 462 let len = self.numbers.len() + 1; 268 - self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 269 - escape_html(&mut self.writer, &name)?; 270 - self.write("\">")?; 271 463 let number = *self.numbers.entry(name.to_string()).or_insert(len); 272 - write!(&mut self.writer, "{}", number)?; 273 - self.write("</a></sup>")?; 464 + // Buffer this reference to see if definition follows immediately 465 + self.pending_footnote = Some((name.to_string(), number)); 274 466 } 275 467 TaskListMarker(checked) => { 276 468 if checked { ··· 279 471 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 280 472 } 281 473 } 282 - WeaverBlock(_) => {} 474 + WeaverBlock(text) => { 475 + // Buffer WeaverBlock content for parsing on End 476 + self.weaver_block_buffer.push_str(&text); 477 + } 283 478 } 284 479 Ok(()) 285 480 } ··· 287 482 fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 288 483 match tag { 289 484 Tag::HtmlBlock => self.write(r#"<span class="html-embed html-embed-block">"#), 290 - Tag::Paragraph => { 291 - if self.end_newline { 292 - self.write("<p>") 485 + Tag::Paragraph(_) => { 486 + if self.in_sidenote { 487 + // Inside sidenote span - don't emit paragraph tags 488 + Ok(()) 489 + } else if self.defer_paragraph_close { 490 + // We're continuing a virtual paragraph after a sidenote 491 + // Don't emit <p> (already open) 492 + // Clear defer flag - we'll set it again at end if another sidenote follows 493 + self.defer_paragraph_close = false; 494 + Ok(()) 293 495 } else { 294 - self.write("\n<p>") 496 + self.flush_pending_footnote()?; 497 + self.emit_wrapper_start()?; 498 + if self.end_newline { 499 + self.write("<p>") 500 + } else { 501 + self.write("\n<p>") 502 + } 295 503 } 296 504 } 297 505 Tag::Heading { ··· 300 508 classes, 301 509 attrs, 302 510 } => { 511 + self.close_deferred_paragraph()?; 512 + self.emit_wrapper_start()?; 303 513 if !self.end_newline { 304 514 self.write("\n")?; 305 515 } ··· 334 544 self.write(">") 335 545 } 336 546 Tag::Table(alignments) => { 547 + self.close_deferred_paragraph()?; 548 + self.emit_wrapper_start()?; 337 549 self.table_alignments = alignments; 338 550 self.write("<table>") 339 551 } ··· 359 571 } 360 572 } 361 573 Tag::BlockQuote(kind) => { 574 + self.close_deferred_paragraph()?; 575 + self.emit_wrapper_start()?; 362 576 let class_str = match kind { 363 577 None => "", 364 578 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", ··· 375 589 Ok(()) 376 590 } 377 591 Tag::CodeBlock(info) => { 592 + self.close_deferred_paragraph()?; 593 + self.emit_wrapper_start()?; 378 594 if !self.end_newline { 379 595 self.write_newline()?; 380 596 } ··· 398 614 } 399 615 } 400 616 Tag::List(Some(1)) => { 617 + self.close_deferred_paragraph()?; 618 + self.emit_wrapper_start()?; 401 619 if self.end_newline { 402 620 self.write("<ol>\n") 403 621 } else { ··· 405 623 } 406 624 } 407 625 Tag::List(Some(start)) => { 626 + self.close_deferred_paragraph()?; 627 + self.emit_wrapper_start()?; 408 628 if self.end_newline { 409 629 self.write("<ol start=\"")?; 410 630 } else { ··· 414 634 self.write("\">\n") 415 635 } 416 636 Tag::List(None) => { 637 + self.close_deferred_paragraph()?; 638 + self.emit_wrapper_start()?; 417 639 if self.end_newline { 418 640 self.write("<ul>\n") 419 641 } else { ··· 428 650 } 429 651 } 430 652 Tag::DefinitionList => { 653 + self.close_deferred_paragraph()?; 654 + self.emit_wrapper_start()?; 431 655 if self.end_newline { 432 656 self.write("<dl>\n") 433 657 } else { ··· 556 780 id, 557 781 attrs, 558 782 } => self.write_embed(embed_type, dest_url, title, id, attrs), 559 - Tag::WeaverBlock(_, _) => { 783 + Tag::WeaverBlock(_, attrs) => { 560 784 self.in_non_writing_block = true; 785 + self.weaver_block_buffer.clear(); 786 + // Store attrs from Start tag, will merge with parsed text on End 787 + if !attrs.classes.is_empty() || !attrs.attrs.is_empty() { 788 + self.pending_block_attrs = Some(attrs.into_static()); 789 + } 561 790 Ok(()) 562 791 } 563 792 Tag::FootnoteDefinition(name) => { 564 - if self.end_newline { 565 - self.write("<div class=\"footnote-definition\" id=\"")?; 793 + // Check if this matches a pending footnote reference (sidenote case) 794 + let is_sidenote = self 795 + .pending_footnote 796 + .as_ref() 797 + .map(|(n, _)| n.as_str() == name.as_ref()) 798 + .unwrap_or(false); 799 + 800 + if is_sidenote { 801 + // Emit sidenote structure at reference position 802 + let (_, number) = self.pending_footnote.take().unwrap(); 803 + let id = format!("sn-{}", number); 804 + 805 + // Emit: <label><input/><span class="sidenote"> 806 + self.write("<label for=\"")?; 807 + self.write(&id)?; 808 + self.write("\" class=\"sidenote-number\"></label>")?; 809 + self.write("<input type=\"checkbox\" id=\"")?; 810 + self.write(&id)?; 811 + self.write("\" class=\"margin-toggle\"/>")?; 812 + self.write("<span class=\"sidenote\">")?; 813 + 814 + self.in_sidenote = true; 566 815 } else { 567 - self.write("\n<div class=\"footnote-definition\" id=\"")?; 816 + // Traditional footnote - close any deferred paragraph (which also flushes pending ref) 817 + self.close_deferred_paragraph()?; 818 + 819 + if self.end_newline { 820 + self.write("<div class=\"footnote-definition\" id=\"")?; 821 + } else { 822 + self.write("\n<div class=\"footnote-definition\" id=\"")?; 823 + } 824 + escape_html(&mut self.writer, &name)?; 825 + self.write("\"><sup class=\"footnote-definition-label\">")?; 826 + let len = self.numbers.len() + 1; 827 + let number = *self.numbers.entry(name.to_string()).or_insert(len); 828 + write!(&mut self.writer, "{}", number)?; 829 + self.write("</sup>")?; 568 830 } 569 - escape_html(&mut self.writer, &name)?; 570 - self.write("\"><sup class=\"footnote-definition-label\">")?; 571 - let len = self.numbers.len() + 1; 572 - let number = *self.numbers.entry(name.to_string()).or_insert(len); 573 - write!(&mut self.writer, "{}", number)?; 574 - self.write("</sup>") 831 + Ok(()) 575 832 } 576 833 Tag::MetadataBlock(_) => { 577 834 self.in_non_writing_block = true; ··· 584 841 use markdown_weaver::TagEnd; 585 842 match tag { 586 843 TagEnd::HtmlBlock => self.write("</span>\n"), 587 - TagEnd::Paragraph => self.write("</p>\n"), 844 + TagEnd::Paragraph(ctx) => { 845 + if self.in_sidenote { 846 + // Inside sidenote span - don't emit paragraph tags 847 + Ok(()) 848 + } else if ctx == ParagraphContext::Interrupted && self.pending_footnote.is_some() { 849 + // Paragraph was interrupted AND we have a pending footnote, 850 + // defer the </p> close - the sidenote will be rendered inline 851 + self.defer_paragraph_close = true; 852 + Ok(()) 853 + } else if self.defer_paragraph_close { 854 + // We were deferring but now closing for real 855 + self.write("</p>\n")?; 856 + self.close_wrapper()?; 857 + self.defer_paragraph_close = false; 858 + Ok(()) 859 + } else { 860 + self.write("</p>\n")?; 861 + self.close_wrapper() 862 + } 863 + } 588 864 TagEnd::Heading(level) => { 589 865 self.write("</")?; 590 866 write!(&mut self.writer, "{}", level)?; 867 + // Don't close wrapper - headings typically go with following block 591 868 self.write(">\n") 592 869 } 593 870 TagEnd::Table => self.write("</tbody></table>\n"), ··· 605 882 self.table_cell_index += 1; 606 883 Ok(()) 607 884 } 608 - TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 885 + TagEnd::BlockQuote(_) => { 886 + // Close any deferred paragraph before closing blockquote 887 + // (footnotes inside blockquotes can't be sidenotes since def is outside) 888 + self.close_deferred_paragraph()?; 889 + self.write("</blockquote>\n")?; 890 + self.close_wrapper() 891 + } 609 892 TagEnd::CodeBlock => { 610 893 use std::sync::LazyLock; 611 894 use syntect::parsing::SyntaxSet; ··· 644 927 } 645 928 Ok(()) 646 929 } 647 - TagEnd::List(true) => self.write("</ol>\n"), 648 - TagEnd::List(false) => self.write("</ul>\n"), 930 + TagEnd::List(true) => { 931 + self.write("</ol>\n")?; 932 + self.close_wrapper() 933 + } 934 + TagEnd::List(false) => { 935 + self.write("</ul>\n")?; 936 + self.close_wrapper() 937 + } 649 938 TagEnd::Item => self.write("</li>\n"), 650 - TagEnd::DefinitionList => self.write("</dl>\n"), 939 + TagEnd::DefinitionList => { 940 + self.write("</dl>\n")?; 941 + self.close_wrapper() 942 + } 651 943 TagEnd::DefinitionListTitle => self.write("</dt>\n"), 652 944 TagEnd::DefinitionListDefinition => self.write("</dd>\n"), 653 945 TagEnd::Emphasis => self.write("</em>"), ··· 660 952 TagEnd::Embed => Ok(()), 661 953 TagEnd::WeaverBlock(_) => { 662 954 self.in_non_writing_block = false; 955 + // Parse the buffered text for attrs and store for next block 956 + if !self.weaver_block_buffer.is_empty() { 957 + let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); 958 + self.weaver_block_buffer.clear(); 959 + // Merge with any existing pending attrs or set new 960 + if let Some(ref mut existing) = self.pending_block_attrs { 961 + existing.classes.extend(parsed.classes); 962 + existing.attrs.extend(parsed.attrs); 963 + } else { 964 + self.pending_block_attrs = Some(parsed); 965 + } 966 + } 663 967 Ok(()) 664 968 } 665 - TagEnd::FootnoteDefinition => self.write("</div>\n"), 969 + TagEnd::FootnoteDefinition => { 970 + if self.in_sidenote { 971 + self.write("</span>")?; 972 + self.in_sidenote = false; 973 + // Write any buffered content that came after the ref 974 + if !self.pending_footnote_content.is_empty() { 975 + let content = std::mem::take(&mut self.pending_footnote_content); 976 + escape_html_body_text(&mut self.writer, &content)?; 977 + self.end_newline = content.ends_with('\n'); 978 + } 979 + } else { 980 + self.write("</div>\n")?; 981 + } 982 + Ok(()) 983 + } 666 984 TagEnd::MetadataBlock(_) => { 667 985 self.in_non_writing_block = false; 668 986 Ok(())
+20 -24
crates/weaver-renderer/src/base_html.rs
··· 96 96 escape_html_body_text(&mut self.writer, &text)?; 97 97 self.write("</code>")?; 98 98 } 99 - InlineMath(text) => { 100 - match crate::math::render_math(&text, false) { 101 - crate::math::MathResult::Success(mathml) => { 102 - self.write(r#"<span class="math math-inline">"#)?; 103 - self.write(&mathml)?; 104 - self.write("</span>")?; 105 - } 106 - crate::math::MathResult::Error { html, .. } => { 107 - self.write(&html)?; 108 - } 99 + InlineMath(text) => match crate::math::render_math(&text, false) { 100 + crate::math::MathResult::Success(mathml) => { 101 + self.write(r#"<span class="math math-inline">"#)?; 102 + self.write(&mathml)?; 103 + self.write("</span>")?; 109 104 } 110 - } 111 - DisplayMath(text) => { 112 - match crate::math::render_math(&text, true) { 113 - crate::math::MathResult::Success(mathml) => { 114 - self.write(r#"<span class="math math-display">"#)?; 115 - self.write(&mathml)?; 116 - self.write("</span>")?; 117 - } 118 - crate::math::MathResult::Error { html, .. } => { 119 - self.write(&html)?; 120 - } 105 + crate::math::MathResult::Error { html, .. } => { 106 + self.write(&html)?; 121 107 } 122 - } 108 + }, 109 + DisplayMath(text) => match crate::math::render_math(&text, true) { 110 + crate::math::MathResult::Success(mathml) => { 111 + self.write(r#"<span class="math math-display">"#)?; 112 + self.write(&mathml)?; 113 + self.write("</span>")?; 114 + } 115 + crate::math::MathResult::Error { html, .. } => { 116 + self.write(&html)?; 117 + } 118 + }, 123 119 Html(html) | InlineHtml(html) => { 124 120 self.write(&html)?; 125 121 } ··· 161 157 fn start_tag(&mut self, tag: Tag<'a>) -> Result<(), W::Error> { 162 158 match tag { 163 159 Tag::HtmlBlock => Ok(()), 164 - Tag::Paragraph => { 160 + Tag::Paragraph(_) => { 165 161 if self.end_newline { 166 162 self.write("<p>") 167 163 } else { ··· 461 457 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 462 458 match tag { 463 459 TagEnd::HtmlBlock => {} 464 - TagEnd::Paragraph => { 460 + TagEnd::Paragraph(_) => { 465 461 self.write("</p>\n")?; 466 462 } 467 463 TagEnd::Heading(level) => {
+138 -3
crates/weaver-renderer/src/css.rs
··· 125 125 padding: 1rem 0rem; 126 126 word-wrap: break-word; 127 127 overflow-wrap: break-word; 128 + counter-reset: sidenote-counter; 129 + max-width: 95ch; 130 + }} 131 + 132 + /* When sidenotes exist, body padding creates the gutter */ 133 + /* Left padding shrinks first as viewport narrows, right stays for sidenotes */ 134 + body:has(.sidenote) {{ 135 + padding-left: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem); 136 + padding-right: 15.5rem; 128 137 }} 129 138 130 139 /* Typography */ ··· 286 295 }} 287 296 288 297 .footnote-definition {{ 298 + order: 9999; 299 + margin: 0; 300 + padding: 0.5rem 0; 301 + font-size: 0.9em; 302 + }} 303 + 304 + .footnote-definition:first-of-type {{ 289 305 margin-top: 2rem; 290 - padding-top: 0.5rem; 291 - border-top: 1px solid var(--color-border); 292 - font-size: 0.9em; 306 + padding-top: 1rem; 307 + border-top: 2px solid var(--color-border); 308 + }} 309 + 310 + .footnote-definition:first-of-type::before {{ 311 + content: "Footnotes"; 312 + display: block; 313 + font-weight: 600; 314 + font-size: 1.1em; 315 + color: var(--color-subtle); 316 + margin-bottom: 0.75rem; 293 317 }} 294 318 295 319 .footnote-definition-label {{ 296 320 font-weight: 600; 297 321 margin-right: 0.5rem; 298 322 color: var(--color-primary); 323 + }} 324 + 325 + /* Aside blocks (via WeaverBlock prefix) */ 326 + aside, .aside {{ 327 + float: left; 328 + width: 40%; 329 + margin: 0 1.5rem 1rem 0; 330 + padding: 1rem; 331 + background: var(--color-surface); 332 + border-right: 3px solid var(--color-primary); 333 + font-size: 0.9em; 334 + clear: left; 335 + }} 336 + 337 + aside > *:first-child, 338 + .aside > *:first-child {{ 339 + margin-top: 0; 340 + }} 341 + 342 + aside > *:last-child, 343 + .aside > *:last-child {{ 344 + margin-bottom: 0; 345 + }} 346 + 347 + /* Reset blockquote styling inside asides */ 348 + aside > blockquote, 349 + .aside > blockquote {{ 350 + border-left: none; 351 + background: transparent; 352 + padding: 0; 353 + margin: 0; 354 + font-size: inherit; 355 + }} 356 + 357 + /* Indent utilities */ 358 + .indent-1 {{ margin-left: 1em; }} 359 + .indent-2 {{ margin-left: 2em; }} 360 + .indent-3 {{ margin-left: 3em; }} 361 + 362 + /* Tufte-style Sidenotes */ 363 + /* Hide checkbox for sidenote toggle */ 364 + .margin-toggle {{ 365 + display: none; 366 + }} 367 + 368 + /* Sidenote number marker (inline superscript) */ 369 + .sidenote-number {{ 370 + counter-increment: sidenote-counter; 371 + }} 372 + 373 + .sidenote-number::after {{ 374 + content: counter(sidenote-counter); 375 + font-size: 0.7em; 376 + position: relative; 377 + top: -0.5em; 378 + color: var(--color-primary); 379 + padding-left: 0.1em; 380 + }} 381 + 382 + /* Sidenote content (margin notes on wide screens) */ 383 + .sidenote {{ 384 + float: right; 385 + clear: right; 386 + margin-right: -15.5rem; 387 + width: 14rem; 388 + margin-top: 0.3rem; 389 + margin-bottom: 1rem; 390 + font-size: 0.85em; 391 + line-height: 1.4; 392 + color: var(--color-subtle); 393 + }} 394 + 395 + .sidenote::before {{ 396 + content: counter(sidenote-counter) ". "; 397 + color: var(--color-primary); 398 + }} 399 + 400 + /* Mobile sidenotes: toggle behavior */ 401 + @media (max-width: 900px) {{ 402 + /* Reset sidenote gutter on mobile */ 403 + body:has(.sidenote) {{ 404 + padding-right: 0; 405 + }} 406 + 407 + aside, .aside {{ 408 + float: none; 409 + width: 100%; 410 + margin: 1rem 0; 411 + }} 412 + 413 + .sidenote {{ 414 + display: none; 415 + }} 416 + 417 + .margin-toggle:checked + .sidenote {{ 418 + display: block; 419 + float: none; 420 + width: 95%; 421 + margin: 0.5rem 2.5%; 422 + padding: 0.5rem; 423 + background: var(--color-surface); 424 + border-left: 2px solid var(--color-primary); 425 + }} 426 + 427 + label.sidenote-number {{ 428 + cursor: pointer; 429 + }} 430 + 431 + label.sidenote-number::after {{ 432 + text-decoration: underline; 433 + }} 299 434 }} 300 435 301 436 /* Images */
+1 -1
crates/weaver-renderer/src/lib.rs
··· 365 365 pub fn get_context<'a>(event: &Event<'a>, prev: Option<&Self>) -> Self { 366 366 match event { 367 367 Event::Start(tag) => match tag { 368 - Tag::Paragraph => Self::Text, 368 + Tag::Paragraph(_) => Self::Text, 369 369 Tag::Heading { .. } => Self::Heading, 370 370 Tag::BlockQuote(_block_quote_kind) => Self::Text, 371 371 Tag::CodeBlock(_code_block_kind) => Self::CodeBlock,
+4 -1
crates/weaver-renderer/src/static_site/document.rs
··· 154 154 } 155 155 156 156 writer.write_all(b"</head>\n").await.into_diagnostic()?; 157 - writer.write_all(b"<body>\n").await.into_diagnostic()?; 157 + writer 158 + .write_all(b"<body style=\"background: var(--color-base); min-height: 100vh;\">\n") 159 + .await 160 + .into_diagnostic()?; 158 161 writer 159 162 .write_all(b"<div class=\"notebook-content\">\n") 160 163 .await
+10
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__footnote_in_blockquote.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <blockquote> 6 + <p>Quote with footnote<sup class="footnote-reference"><a href="#q">1</a></sup>.</p> 7 + </blockquote> 8 + <div class="footnote-definition" id="q"><sup class="footnote-definition-label">1</sup> 9 + <p>Footnote for quote.</p> 10 + </div>
+11
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__footnote_multiple.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <p>First<sup class="footnote-reference"><a href="#1">1</a></sup> and second<sup class="footnote-reference"><a href="#2">2</a></sup> footnotes.</p> 6 + <div class="footnote-definition" id="1"><sup class="footnote-definition-label">1</sup> 7 + <p>First note.</p> 8 + </div> 9 + <div class="footnote-definition" id="2"><sup class="footnote-definition-label">2</sup> 10 + <p>Second note.</p> 11 + </div>
+5
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__footnote_named.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <p>Reference<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Named footnote content.</span>.</p>
+5
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__footnote_sidenote_inline.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <p>Here is text<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Sidenote content.</span></p>
+5
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__footnote_traditional.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <p>Here is some text<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">This is the footnote definition.</span>.</p>
+5
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__footnote_with_inline_formatting.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <p>Text with footnote<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Note with <strong>bold</strong> and <em>italic</em>.</span>.</p>
+7
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_aside_class.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <p>This paragraph should be in an aside.</p> 7 + </aside>
+9
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_before_blockquote.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <blockquote> 7 + <p>This blockquote is in an aside.</p> 8 + </blockquote> 9 + </aside>
+7
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_before_code_block.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <pre><code class="wvc-code language-Rust"><span class="wvc-source wvc-rust"><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-storage wvc-type wvc-function wvc-rust">fn</span> </span><span class="wvc-entity wvc-name wvc-function wvc-rust">main</span></span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-parameters wvc-rust"><span class="wvc-punctuation wvc-section wvc-parameters wvc-begin wvc-rust">(</span></span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-function wvc-parameters wvc-rust"><span class="wvc-punctuation wvc-section wvc-parameters wvc-end wvc-rust">)</span></span></span></span><span class="wvc-meta wvc-function wvc-rust"> </span><span class="wvc-meta wvc-function wvc-rust"><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-begin wvc-rust">{</span></span><span class="wvc-meta wvc-block wvc-rust"><span class="wvc-punctuation wvc-section wvc-block wvc-end wvc-rust">}</span></span></span> 7 + </span></code></pre></aside>
+8
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_before_heading.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <h2>Heading in aside</h2> 7 + <p>Paragraph also in aside.</p> 8 + </aside>
+10
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_before_list.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <ul> 7 + <li>Item 1</li> 8 + <li>Item 2</li> 9 + </ul> 10 + </aside>
+7
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_custom_attributes.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <div class="foo" width="300px" data-test="value"> 6 + <p>Paragraph with class and attributes.</p> 7 + </div>
+7
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_custom_class.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <div class="highlight"> 6 + <p>This paragraph has a custom class.</p> 7 + </div>
+7
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_multiple_classes.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside class="highlight important"> 6 + <p>Multiple classes applied.</p> 7 + </aside>
+8
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_no_effect_on_following.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <p>First paragraph in aside.</p> 7 + </aside> 8 + <p>Second paragraph NOT in aside.</p>
+7
crates/weaver-renderer/src/static_site/snapshots/weaver_renderer__static_site__tests__weaver_block_with_footnote.snap
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site/tests.rs 3 + expression: output 4 + --- 5 + <aside> 6 + <p>Aside with a footnote<label for="sn-1" class="margin-toggle sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Footnote in aside context.</span>.</p> 7 + </aside>
+125
crates/weaver-renderer/src/static_site/tests.rs
··· 210 210 assert!(output.contains("🎉")); 211 211 assert!(output.contains("café")); 212 212 } 213 + 214 + // ============================================================================= 215 + // WeaverBlock Prefix Tests 216 + // ============================================================================= 217 + 218 + #[tokio::test] 219 + async fn test_weaver_block_aside_class() { 220 + let input = "\n\n{.aside}\nThis paragraph should be in an aside."; 221 + let output = render_markdown(input).await; 222 + insta::assert_snapshot!(output); 223 + } 224 + 225 + #[tokio::test] 226 + async fn test_weaver_block_custom_class() { 227 + let input = "\n\n{.highlight}\nThis paragraph has a custom class."; 228 + let output = render_markdown(input).await; 229 + insta::assert_snapshot!(output); 230 + } 231 + 232 + #[tokio::test] 233 + async fn test_weaver_block_custom_attributes() { 234 + let input = "\n\n{.foo, width: 300px, data-test: value}\nParagraph with class and attributes."; 235 + let output = render_markdown(input).await; 236 + insta::assert_snapshot!(output); 237 + } 238 + 239 + #[tokio::test] 240 + async fn test_weaver_block_before_heading() { 241 + let input = "\n\n{.aside}\n## Heading in aside\n\nParagraph also in aside."; 242 + let output = render_markdown(input).await; 243 + insta::assert_snapshot!(output); 244 + } 245 + 246 + #[tokio::test] 247 + async fn test_weaver_block_before_blockquote() { 248 + let input = "\n\n{.aside}\n\n> This blockquote is in an aside."; 249 + let output = render_markdown(input).await; 250 + insta::assert_snapshot!(output); 251 + } 252 + 253 + #[tokio::test] 254 + async fn test_weaver_block_before_list() { 255 + let input = "\n\n{.aside}\n\n- Item 1\n- Item 2"; 256 + let output = render_markdown(input).await; 257 + insta::assert_snapshot!(output); 258 + } 259 + 260 + #[tokio::test] 261 + async fn test_weaver_block_before_code_block() { 262 + let input = "\n\n{.aside}\n\n```rust\nfn main() {}\n```"; 263 + let output = render_markdown(input).await; 264 + insta::assert_snapshot!(output); 265 + } 266 + 267 + #[tokio::test] 268 + async fn test_weaver_block_multiple_classes() { 269 + let input = "\n\n{.aside, .highlight, .important}\nMultiple classes applied."; 270 + let output = render_markdown(input).await; 271 + insta::assert_snapshot!(output); 272 + } 273 + 274 + #[tokio::test] 275 + async fn test_weaver_block_no_effect_on_following() { 276 + let input = "\n\n{.aside}\nFirst paragraph in aside.\n\nSecond paragraph NOT in aside."; 277 + let output = render_markdown(input).await; 278 + insta::assert_snapshot!(output); 279 + } 280 + 281 + // ============================================================================= 282 + // Footnote / Sidenote Tests 283 + // ============================================================================= 284 + 285 + #[tokio::test] 286 + async fn test_footnote_traditional() { 287 + let input = "Here is some text[^1].\n[^1]: This is the footnote definition."; 288 + let output = render_markdown(input).await; 289 + insta::assert_snapshot!(output); 290 + } 291 + 292 + #[tokio::test] 293 + async fn test_footnote_sidenote_inline() { 294 + // When definition immediately follows reference in the same paragraph flow 295 + let input = "Here is text[^note]\n[^note]: Sidenote content."; 296 + let output = render_markdown(input).await; 297 + insta::assert_snapshot!(output); 298 + } 299 + 300 + #[tokio::test] 301 + async fn test_footnote_multiple() { 302 + let input = "First[^1] and second[^2] footnotes.\n[^1]: First note.\n[^2]: Second note."; 303 + let output = render_markdown(input).await; 304 + insta::assert_snapshot!(output); 305 + } 306 + 307 + #[tokio::test] 308 + async fn test_footnote_with_inline_formatting() { 309 + let input = "Text with footnote[^fmt].\n[^fmt]: Note with **bold** and *italic*."; 310 + let output = render_markdown(input).await; 311 + insta::assert_snapshot!(output); 312 + } 313 + 314 + #[tokio::test] 315 + async fn test_footnote_named() { 316 + let input = "Reference[^my-note].\n[^my-note]: Named footnote content."; 317 + let output = render_markdown(input).await; 318 + insta::assert_snapshot!(output); 319 + } 320 + 321 + #[tokio::test] 322 + async fn test_footnote_in_blockquote() { 323 + let input = "> Quote with footnote[^q].\n[^q]: Footnote for quote."; 324 + let output = render_markdown(input).await; 325 + insta::assert_snapshot!(output); 326 + } 327 + 328 + // ============================================================================= 329 + // Combined WeaverBlock + Footnote Tests 330 + // ============================================================================= 331 + 332 + #[tokio::test] 333 + async fn test_weaver_block_with_footnote() { 334 + let input = "{.aside}\nAside with a footnote[^aside].\n\n[^aside]: Footnote in aside context."; 335 + let output = render_markdown(input).await; 336 + insta::assert_snapshot!(output); 337 + }
+372 -28
crates/weaver-renderer/src/static_site/writer.rs
··· 1 1 use crate::{NotebookProcessor, base_html::TableState, static_site::context::StaticSiteContext}; 2 2 use dashmap::DashMap; 3 3 use markdown_weaver::{ 4 - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 4 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, 5 + ParagraphContext, Tag, WeaverAttributes, 5 6 }; 6 7 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 7 8 use n0_future::StreamExt; 8 9 use weaver_common::jacquard::{client::AgentSession, prelude::*}; 10 + 11 + /// Tracks the type of wrapper element emitted for WeaverBlock prefix 12 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 13 + enum WrapperElement { 14 + Aside, 15 + Div, 16 + } 9 17 10 18 pub struct StaticPageWriter<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> 11 19 { ··· 23 31 numbers: DashMap<CowStr<'input>, usize>, 24 32 25 33 code_buffer: Option<(Option<String>, String)>, // (lang, content) 34 + 35 + /// Pending WeaverBlock attrs to apply to the next block element 36 + pending_block_attrs: Option<WeaverAttributes<'static>>, 37 + /// Type of wrapper element currently open, and the block depth at which it was opened 38 + active_wrapper: Option<(WrapperElement, usize)>, 39 + /// Current block nesting depth (for wrapper close tracking) 40 + block_depth: usize, 41 + /// Buffer for WeaverBlock text content (to parse for attrs) 42 + weaver_block_buffer: String, 43 + /// Pending footnote reference waiting to see if definition follows immediately 44 + pending_footnote: Option<(CowStr<'static>, usize)>, 45 + /// Buffer for content between footnote ref and resolution 46 + pending_footnote_content: String, 47 + /// Whether current footnote definition is being rendered as a sidenote 48 + in_sidenote: bool, 49 + /// Whether we're deferring paragraph close for sidenote handling 50 + defer_paragraph_close: bool, 26 51 } 27 52 28 53 impl<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> ··· 39 64 table_cell_index: 0, 40 65 numbers: DashMap::new(), 41 66 code_buffer: None, 67 + pending_block_attrs: None, 68 + active_wrapper: None, 69 + block_depth: 0, 70 + weaver_block_buffer: String::new(), 71 + pending_footnote: None, 72 + pending_footnote_content: String::new(), 73 + in_sidenote: false, 74 + defer_paragraph_close: false, 42 75 } 43 76 } 44 77 78 + /// Parse WeaverBlock text content into attributes. 79 + /// Format: comma-separated, colon for key:value, otherwise class. 80 + /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")] 81 + fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> { 82 + let mut classes = Vec::new(); 83 + let mut attrs = Vec::new(); 84 + 85 + for part in text.split(',') { 86 + let part = part.trim(); 87 + if part.is_empty() { 88 + continue; 89 + } 90 + 91 + if let Some((key, value)) = part.split_once(':') { 92 + let key = key.trim(); 93 + let value = value.trim(); 94 + if !key.is_empty() && !value.is_empty() { 95 + attrs.push(( 96 + CowStr::from(key.to_string()), 97 + CowStr::from(value.to_string()), 98 + )); 99 + } 100 + } else { 101 + // No colon - treat as class, strip leading dot if present 102 + let class = part.strip_prefix('.').unwrap_or(part); 103 + if !class.is_empty() { 104 + classes.push(CowStr::from(class.to_string())); 105 + } 106 + } 107 + } 108 + 109 + WeaverAttributes { classes, attrs } 110 + } 111 + 45 112 /// Writes a new line. 46 113 #[inline] 47 114 fn write_newline(&mut self) -> Result<(), W::Error> { ··· 60 127 Ok(()) 61 128 } 62 129 130 + /// Close deferred paragraph if we're in that state. 131 + /// Called when a non-paragraph block element starts. 132 + fn close_deferred_paragraph(&mut self) -> Result<(), W::Error> { 133 + if self.defer_paragraph_close { 134 + // Flush pending footnote as traditional before closing 135 + self.flush_pending_footnote()?; 136 + self.write("</p>\n")?; 137 + self.block_depth -= 1; 138 + self.close_wrapper()?; 139 + self.defer_paragraph_close = false; 140 + } 141 + Ok(()) 142 + } 143 + 144 + /// Flush any pending footnote reference as a traditional footnote, 145 + /// then write any buffered content that came after the reference. 146 + fn flush_pending_footnote(&mut self) -> Result<(), W::Error> { 147 + if let Some((name, number)) = self.pending_footnote.take() { 148 + // Emit traditional footnote reference 149 + self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 150 + escape_html(&mut self.writer, &name)?; 151 + self.write("\">")?; 152 + write!(&mut self.writer, "{}", number)?; 153 + self.write("</a></sup>")?; 154 + // Write any buffered content 155 + if !self.pending_footnote_content.is_empty() { 156 + let content = std::mem::take(&mut self.pending_footnote_content); 157 + escape_html_body_text(&mut self.writer, &content)?; 158 + self.end_newline = content.ends_with('\n'); 159 + } 160 + } 161 + Ok(()) 162 + } 163 + 164 + /// Emit wrapper element start based on pending block attrs 165 + /// Returns true if a wrapper was emitted 166 + fn emit_wrapper_start(&mut self) -> Result<bool, W::Error> { 167 + if let Some(attrs) = self.pending_block_attrs.take() { 168 + let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside"); 169 + 170 + if !self.end_newline { 171 + self.write("\n")?; 172 + } 173 + 174 + if is_aside { 175 + self.write("<aside")?; 176 + self.active_wrapper = Some((WrapperElement::Aside, self.block_depth)); 177 + } else { 178 + self.write("<div")?; 179 + self.active_wrapper = Some((WrapperElement::Div, self.block_depth)); 180 + } 181 + 182 + // Write classes (excluding "aside" if using <aside> element) 183 + let classes: Vec<_> = if is_aside { 184 + attrs 185 + .classes 186 + .iter() 187 + .filter(|c| c.as_ref() != "aside") 188 + .collect() 189 + } else { 190 + attrs.classes.iter().collect() 191 + }; 192 + 193 + if !classes.is_empty() { 194 + self.write(" class=\"")?; 195 + for (i, class) in classes.iter().enumerate() { 196 + if i > 0 { 197 + self.write(" ")?; 198 + } 199 + escape_html(&mut self.writer, class)?; 200 + } 201 + self.write("\"")?; 202 + } 203 + 204 + // Write other attrs 205 + for (attr, value) in &attrs.attrs { 206 + self.write(" ")?; 207 + escape_html(&mut self.writer, attr)?; 208 + self.write("=\"")?; 209 + escape_html(&mut self.writer, value)?; 210 + self.write("\"")?; 211 + } 212 + 213 + self.write(">\n")?; 214 + Ok(true) 215 + } else { 216 + Ok(false) 217 + } 218 + } 219 + 220 + /// Close active wrapper element if one is open and we're at the right depth 221 + fn close_wrapper(&mut self) -> Result<(), W::Error> { 222 + if let Some((wrapper, open_depth)) = self.active_wrapper.take() { 223 + if self.block_depth == open_depth { 224 + match wrapper { 225 + WrapperElement::Aside => self.write("</aside>\n")?, 226 + WrapperElement::Div => self.write("</div>\n")?, 227 + } 228 + } else { 229 + // Not at the right depth yet, put it back 230 + self.active_wrapper = Some((wrapper, open_depth)); 231 + } 232 + } 233 + Ok(()) 234 + } 235 + 63 236 fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> { 64 237 use markdown_weaver::TagEnd; 65 238 match tag { 66 239 TagEnd::HtmlBlock => {} 67 - TagEnd::Paragraph => { 68 - self.write("</p>\n")?; 240 + TagEnd::Paragraph(ctx) => { 241 + if self.in_sidenote { 242 + // Inside sidenote span - don't emit paragraph tags 243 + } else if ctx == ParagraphContext::Interrupted && self.pending_footnote.is_some() { 244 + // Paragraph was interrupted AND we have a pending footnote, 245 + // defer the </p> close - the sidenote will be rendered inline 246 + self.defer_paragraph_close = true; 247 + // Don't decrement block_depth yet - we're continuing the virtual paragraph 248 + } else if self.defer_paragraph_close { 249 + // We were deferring but now closing for real 250 + self.write("</p>\n")?; 251 + self.block_depth -= 1; 252 + self.close_wrapper()?; 253 + self.defer_paragraph_close = false; 254 + } else { 255 + self.write("</p>\n")?; 256 + self.block_depth -= 1; 257 + self.close_wrapper()?; 258 + } 69 259 } 70 260 TagEnd::Heading(level) => { 71 261 self.write("</")?; 72 262 write!(&mut self.writer, "{}", level)?; 263 + self.block_depth -= 1; 264 + // Don't close wrapper - headings typically go with following block 73 265 self.write(">\n")?; 74 266 } 75 267 TagEnd::Table => { 76 268 self.write("</tbody></table>\n")?; 269 + self.block_depth -= 1; 270 + self.close_wrapper()?; 77 271 } 78 272 TagEnd::TableHead => { 79 273 self.write("</tr></thead><tbody>\n")?; ··· 94 288 self.table_cell_index += 1; 95 289 } 96 290 TagEnd::BlockQuote(_) => { 291 + // Close any deferred paragraph before closing blockquote 292 + // (footnotes inside blockquotes can't be sidenotes since def is outside) 293 + self.close_deferred_paragraph()?; 97 294 self.write("</blockquote>\n")?; 295 + self.block_depth -= 1; 296 + self.close_wrapper()?; 98 297 } 99 298 TagEnd::CodeBlock => { 100 299 if let Some((lang, buffer)) = self.code_buffer.take() { ··· 127 326 } else { 128 327 self.write("</code></pre>\n")?; 129 328 } 329 + self.block_depth -= 1; 330 + self.close_wrapper()?; 130 331 } 131 332 TagEnd::List(true) => { 132 333 self.write("</ol>\n")?; 334 + self.block_depth -= 1; 335 + self.close_wrapper()?; 133 336 } 134 337 TagEnd::List(false) => { 135 338 self.write("</ul>\n")?; 339 + self.block_depth -= 1; 340 + self.close_wrapper()?; 136 341 } 137 342 TagEnd::Item => { 138 343 self.write("</li>\n")?; 139 344 } 140 345 TagEnd::DefinitionList => { 141 346 self.write("</dl>\n")?; 347 + self.block_depth -= 1; 348 + self.close_wrapper()?; 142 349 } 143 350 TagEnd::DefinitionListTitle => { 144 351 self.write("</dt>\n")?; ··· 168 375 TagEnd::Embed => (), // shouldn't happen, handled in start 169 376 TagEnd::WeaverBlock(_) => { 170 377 self.in_non_writing_block = false; 378 + eprintln!( 379 + "[TagEnd::WeaverBlock] buffer: {:?}", 380 + self.weaver_block_buffer 381 + ); 382 + // Parse the buffered text for attrs and store for next block 383 + if !self.weaver_block_buffer.is_empty() { 384 + let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); 385 + eprintln!("[TagEnd::WeaverBlock] parsed: {:?}", parsed); 386 + self.weaver_block_buffer.clear(); 387 + // Merge with any existing pending attrs or set new 388 + if let Some(ref mut existing) = self.pending_block_attrs { 389 + existing.classes.extend(parsed.classes); 390 + existing.attrs.extend(parsed.attrs); 391 + } else { 392 + self.pending_block_attrs = Some(parsed); 393 + } 394 + eprintln!( 395 + "[TagEnd::WeaverBlock] pending_block_attrs now: {:?}", 396 + self.pending_block_attrs 397 + ); 398 + } 171 399 } 172 400 TagEnd::FootnoteDefinition => { 173 - self.write("</div>\n")?; 401 + if self.in_sidenote { 402 + self.write("</span>")?; 403 + self.in_sidenote = false; 404 + // Write any buffered content that came after the ref 405 + if !self.pending_footnote_content.is_empty() { 406 + let content = std::mem::take(&mut self.pending_footnote_content); 407 + escape_html_body_text(&mut self.writer, &content)?; 408 + self.end_newline = content.ends_with('\n'); 409 + } 410 + } else { 411 + self.write("</div>\n")?; 412 + } 174 413 } 175 414 TagEnd::MetadataBlock(_) => { 176 415 self.in_non_writing_block = false; ··· 191 430 while let Some(event) = self.context.next().await { 192 431 self.process_event(event).await? 193 432 } 433 + self.finalize() 434 + } 435 + 436 + /// Finalize output, closing any deferred state 437 + fn finalize(&mut self) -> Result<(), W::Error> { 438 + // Flush any pending footnote as traditional 439 + self.flush_pending_footnote()?; 440 + // Close deferred paragraph if any 441 + if self.defer_paragraph_close { 442 + self.write("</p>\n")?; 443 + self.block_depth -= 1; 444 + self.close_wrapper()?; 445 + self.defer_paragraph_close = false; 446 + } 194 447 Ok(()) 195 448 } 196 449 ··· 198 451 use markdown_weaver::Event::*; 199 452 match event { 200 453 Start(tag) => { 454 + println!("Start tag: {:?}", tag); 201 455 self.start_tag(tag).await?; 202 456 } 203 457 End(tag) => { ··· 207 461 // If buffering code, append to buffer instead of writing 208 462 if let Some((_, ref mut buffer)) = self.code_buffer { 209 463 buffer.push_str(&text); 464 + } else if self.pending_footnote.is_some() { 465 + // Buffer text while waiting to see if footnote def follows 466 + self.pending_footnote_content.push_str(&text); 210 467 } else if !self.in_non_writing_block { 211 468 escape_html_body_text(&mut self.writer, &text)?; 212 469 self.end_newline = text.ends_with('\n'); ··· 231 488 self.write(&html)?; 232 489 } 233 490 SoftBreak => { 234 - self.write_newline()?; 491 + if self.pending_footnote.is_some() { 492 + self.pending_footnote_content.push('\n'); 493 + } else { 494 + self.write_newline()?; 495 + } 235 496 } 236 497 HardBreak => { 237 - self.write("<br />\n")?; 498 + if self.pending_footnote.is_some() { 499 + self.pending_footnote_content.push_str("<br />\n"); 500 + } else { 501 + self.write("<br />\n")?; 502 + } 238 503 } 239 504 Rule => { 240 505 if self.end_newline { ··· 244 509 } 245 510 } 246 511 FootnoteReference(name) => { 512 + // Flush any existing pending footnote as traditional 513 + self.flush_pending_footnote()?; 514 + // Get/create footnote number 247 515 let len = self.numbers.len() + 1; 248 - self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 249 - escape_html(&mut self.writer, &name)?; 250 - self.write("\">")?; 251 - let number = *self.numbers.entry(name.into_static()).or_insert(len); 252 - write!(&mut self.writer, "{}", number)?; 253 - self.write("</a></sup>")?; 516 + let number = *self 517 + .numbers 518 + .entry(name.clone().into_static()) 519 + .or_insert(len); 520 + // Buffer this reference to see if definition follows immediately 521 + self.pending_footnote = Some((name.into_static(), number)); 254 522 } 255 523 TaskListMarker(true) => { 256 524 self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; ··· 258 526 TaskListMarker(false) => { 259 527 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 260 528 } 261 - WeaverBlock(_text) => {} 529 + WeaverBlock(text) => { 530 + // Buffer WeaverBlock content for parsing on End 531 + eprintln!("[WeaverBlock event] text: {:?}", text); 532 + self.weaver_block_buffer.push_str(&text); 533 + } 262 534 } 263 535 Ok(()) 264 536 } ··· 315 587 async fn start_tag(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 316 588 match tag { 317 589 Tag::HtmlBlock => Ok(()), 318 - Tag::Paragraph => { 319 - if self.end_newline { 320 - self.write("<p>") 590 + Tag::Paragraph(_) => { 591 + if self.in_sidenote { 592 + // Inside sidenote span - don't emit paragraph tags 593 + Ok(()) 594 + } else if self.defer_paragraph_close { 595 + // We're continuing a virtual paragraph after a sidenote 596 + // Don't emit <p> or increment block_depth (already counted) 597 + // Clear defer flag - we'll set it again at end if another sidenote follows 598 + self.defer_paragraph_close = false; 599 + Ok(()) 321 600 } else { 322 - self.write("\n<p>") 601 + self.flush_pending_footnote()?; 602 + self.emit_wrapper_start()?; 603 + self.block_depth += 1; 604 + if self.end_newline { 605 + self.write("<p>") 606 + } else { 607 + self.write("\n<p>") 608 + } 323 609 } 324 610 } 325 611 Tag::Heading { ··· 328 614 classes, 329 615 attrs, 330 616 } => { 617 + self.close_deferred_paragraph()?; 618 + self.emit_wrapper_start()?; 619 + self.block_depth += 1; 331 620 if self.end_newline { 332 621 self.write("<")?; 333 622 } else { ··· 363 652 self.write(">") 364 653 } 365 654 Tag::Table(alignments) => { 655 + self.close_deferred_paragraph()?; 656 + self.emit_wrapper_start()?; 657 + self.block_depth += 1; 366 658 self.table_alignments = alignments; 367 659 self.write("<table>") 368 660 } ··· 392 684 } 393 685 } 394 686 Tag::BlockQuote(kind) => { 687 + self.close_deferred_paragraph()?; 688 + self.emit_wrapper_start()?; 689 + self.block_depth += 1; 395 690 let class_str = match kind { 396 691 None => "", 397 692 Some(kind) => match kind { ··· 409 704 } 410 705 } 411 706 Tag::CodeBlock(info) => { 707 + self.close_deferred_paragraph()?; 708 + self.emit_wrapper_start()?; 709 + self.block_depth += 1; 412 710 if !self.end_newline { 413 711 self.write_newline()?; 414 712 } ··· 432 730 } 433 731 } 434 732 Tag::List(Some(1)) => { 733 + self.close_deferred_paragraph()?; 734 + self.emit_wrapper_start()?; 735 + self.block_depth += 1; 435 736 if self.end_newline { 436 737 self.write("<ol>\n") 437 738 } else { ··· 439 740 } 440 741 } 441 742 Tag::List(Some(start)) => { 743 + self.close_deferred_paragraph()?; 744 + self.emit_wrapper_start()?; 745 + self.block_depth += 1; 442 746 if self.end_newline { 443 747 self.write("<ol start=\"")?; 444 748 } else { ··· 448 752 self.write("\">\n") 449 753 } 450 754 Tag::List(None) => { 755 + self.close_deferred_paragraph()?; 756 + self.emit_wrapper_start()?; 757 + self.block_depth += 1; 451 758 if self.end_newline { 452 759 self.write("<ul>\n") 453 760 } else { ··· 462 769 } 463 770 } 464 771 Tag::DefinitionList => { 772 + self.close_deferred_paragraph()?; 773 + self.emit_wrapper_start()?; 774 + self.block_depth += 1; 465 775 if self.end_newline { 466 776 self.write("<dl>\n") 467 777 } else { ··· 632 942 } 633 943 Ok(()) 634 944 } 635 - Tag::WeaverBlock(_, _attrs) => { 636 - println!("Weaver block"); 945 + Tag::WeaverBlock(_, attrs) => { 637 946 self.in_non_writing_block = true; 947 + self.weaver_block_buffer.clear(); 948 + // Store attrs from Start tag, will merge with parsed text on End 949 + if !attrs.classes.is_empty() || !attrs.attrs.is_empty() { 950 + self.pending_block_attrs = Some(attrs.into_static()); 951 + } 638 952 Ok(()) 639 953 } 640 954 Tag::FootnoteDefinition(name) => { 641 - if self.end_newline { 642 - self.write("<div class=\"footnote-definition\" id=\"")?; 955 + // Check if this matches a pending footnote reference (sidenote case) 956 + let is_sidenote = self 957 + .pending_footnote 958 + .as_ref() 959 + .map(|(n, _)| n.as_ref() == name.as_ref()) 960 + .unwrap_or(false); 961 + 962 + if is_sidenote { 963 + // Emit sidenote structure at reference position 964 + let (_, number) = self.pending_footnote.take().unwrap(); 965 + let id = format!("sn-{}", number); 966 + 967 + // Emit: <label><input/><span class="sidenote"> 968 + self.write("<label for=\"")?; 969 + self.write(&id)?; 970 + self.write("\" class=\"sidenote-number\"></label>")?; 971 + self.write("<input type=\"checkbox\" id=\"")?; 972 + self.write(&id)?; 973 + self.write("\" class=\"margin-toggle\"/>")?; 974 + self.write("<span class=\"sidenote\">")?; 975 + 976 + // Write any buffered content AFTER the sidenote span closes 977 + // (we'll do this in end_tag) 978 + self.in_sidenote = true; 643 979 } else { 644 - self.write("\n<div class=\"footnote-definition\" id=\"")?; 980 + // Traditional footnote - close any deferred paragraph (which also flushes pending ref) 981 + self.close_deferred_paragraph()?; 982 + 983 + if self.end_newline { 984 + self.write("<div class=\"footnote-definition\" id=\"")?; 985 + } else { 986 + self.write("\n<div class=\"footnote-definition\" id=\"")?; 987 + } 988 + escape_html(&mut self.writer, &name)?; 989 + self.write("\"><sup class=\"footnote-definition-label\">")?; 990 + let len = self.numbers.len() + 1; 991 + let number = *self.numbers.entry(name.into_static()).or_insert(len); 992 + write!(&mut self.writer, "{}", number)?; 993 + self.write("</sup>")?; 645 994 } 646 - escape_html(&mut self.writer, &name)?; 647 - self.write("\"><sup class=\"footnote-definition-label\">")?; 648 - let len = self.numbers.len() + 1; 649 - let number = *self.numbers.entry(name.into_static()).or_insert(len); 650 - write!(&mut self.writer, "{}", number)?; 651 - self.write("</sup>") 995 + Ok(()) 652 996 } 653 997 Tag::MetadataBlock(_) => { 654 998 self.in_non_writing_block = true;
+28 -7
lexicons/notebook/searchNotebooks.json
··· 9 9 "type": "params", 10 10 "required": ["q"], 11 11 "properties": { 12 - "q": { "type": "string", "minLength": 1, "maxLength": 500 }, 12 + "q": { 13 + "type": "string", 14 + "minLength": 1, 15 + "maxLength": 500 16 + }, 13 17 "author": { 14 18 "type": "string", 15 19 "format": "at-identifier", ··· 17 21 }, 18 22 "tags": { 19 23 "type": "array", 20 - "items": { "type": "string" }, 24 + "items": { 25 + "type": "string" 26 + }, 21 27 "maxLength": 10, 22 28 "description": "Filter by tags (all must match)." 23 29 }, 24 30 "rating": { 25 31 "type": "array", 26 - "items": { "type": "ref", "ref": "sh.weaver.notebook.defs#contentRating" }, 32 + "items": { 33 + "type": "string", 34 + "knownValues": ["general", "teen", "mature", "explicit"] 35 + }, 27 36 "description": "Filter by content rating (any of these)." 28 37 }, 29 38 "sort": { ··· 31 40 "knownValues": ["relevance", "recent", "popular"], 32 41 "default": "relevance" 33 42 }, 34 - "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 }, 35 - "cursor": { "type": "string" } 43 + "limit": { 44 + "type": "integer", 45 + "minimum": 1, 46 + "maximum": 100, 47 + "default": 25 48 + }, 49 + "cursor": { 50 + "type": "string" 51 + } 36 52 } 37 53 }, 38 54 "output": { ··· 43 59 "properties": { 44 60 "notebooks": { 45 61 "type": "array", 46 - "items": { "type": "ref", "ref": "sh.weaver.notebook.defs#notebookView" } 62 + "items": { 63 + "type": "ref", 64 + "ref": "sh.weaver.notebook.defs#notebookView" 65 + } 47 66 }, 48 - "cursor": { "type": "string" } 67 + "cursor": { 68 + "type": "string" 69 + } 49 70 } 50 71 } 51 72 }
+18 -1
lexicons/notification/listNotifications.json
··· 10 10 "properties": { 11 11 "reasons": { 12 12 "type": "array", 13 - "items": { "type": "ref", "ref": "sh.weaver.notification.defs#notificationReason" }, 13 + "items": { 14 + "type": "string", 15 + "knownValues": [ 16 + "like", 17 + "bookmark", 18 + "follow", 19 + "followAccept", 20 + "subscribe", 21 + "subscribeAccept", 22 + "collaborationInvite", 23 + "collaborationAccept", 24 + "newEntry", 25 + "entryUpdate", 26 + "mention", 27 + "tag", 28 + "comment" 29 + ] 30 + }, 14 31 "description": "Filter by notification reasons." 15 32 }, 16 33 "seenAt": {
+1278
test-output/test-sidenotes.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>test-sidenotes</title> 7 + <style> 8 + /* CSS Reset */ 9 + *, *::before, *::after { 10 + box-sizing: border-box; 11 + margin: 0; 12 + padding: 0; 13 + } 14 + 15 + /* CSS Variables - Light Mode (default) */ 16 + :root { 17 + --color-base: #faf4ed; 18 + --color-surface: #fffaf3; 19 + --color-overlay: #f2e9e1; 20 + --color-text: #1f1d2e; 21 + --color-muted: #635e74; 22 + --color-subtle: #4a4560; 23 + --color-emphasis: #1e1a2d; 24 + --color-primary: #907aa9; 25 + --color-secondary: #56949f; 26 + --color-tertiary: #286983; 27 + --color-error: #b4637a; 28 + --color-warning: #ea9d34; 29 + --color-success: #286983; 30 + --color-border: #dfdad9; 31 + --color-link: #d7827e; 32 + --color-highlight: #cecacd; 33 + 34 + --font-body: 'Adobe Caslon Pro','Latin Modern Roman','Times New Roman','serif'; 35 + --font-heading: 'IBM Plex Sans','system-ui','sans-serif'; 36 + --font-mono: 'Ioskeley Mono','IBM Plex Mono','Berkeley Mono','Consolas','monospace'; 37 + 38 + --spacing-base: 16px; 39 + --spacing-line-height: 1.6; 40 + --spacing-scale: 1.25; 41 + } 42 + 43 + /* CSS Variables - Dark Mode */ 44 + @media (prefers-color-scheme: dark) { 45 + :root { 46 + --color-base: #191724; 47 + --color-surface: #1f1d2e; 48 + --color-overlay: #26233a; 49 + --color-text: #e0def4; 50 + --color-muted: #6e6a86; 51 + --color-subtle: #908caa; 52 + --color-emphasis: #e0def4; 53 + --color-primary: #c4a7e7; 54 + --color-secondary: #9ccfd8; 55 + --color-tertiary: #ebbcba; 56 + --color-error: #eb6f92; 57 + --color-warning: #f6c177; 58 + --color-success: #31748f; 59 + --color-border: #403d52; 60 + --color-link: #ebbcba; 61 + --color-highlight: #524f67; 62 + } 63 + } 64 + 65 + /* Base Styles */ 66 + html { 67 + font-size: var(--spacing-base); 68 + line-height: var(--spacing-line-height); 69 + } 70 + 71 + /* Scoped to notebook-content container */ 72 + .notebook-content { 73 + font-family: var(--font-body); 74 + color: var(--color-text); 75 + background-color: var(--color-base); 76 + margin: 0 auto; 77 + padding: 1rem 0rem; 78 + word-wrap: break-word; 79 + overflow-wrap: break-word; 80 + counter-reset: sidenote-counter; 81 + max-width: 95ch; 82 + } 83 + 84 + /* When sidenotes exist, body padding creates the gutter */ 85 + /* Left padding shrinks first as viewport narrows, right stays for sidenotes */ 86 + body:has(.sidenote) { 87 + padding-left: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem); 88 + padding-right: 15.5rem; 89 + } 90 + 91 + /* Typography */ 92 + h1, h2, h3, h4, h5, h6 { 93 + font-family: var(--font-heading); 94 + margin-top: calc(1rem * var(--spacing-scale)); 95 + margin-bottom: 0.5rem; 96 + line-height: 1.2; 97 + } 98 + 99 + h1 { 100 + font-size: 2rem; 101 + color: var(--color-secondary); 102 + } 103 + h2 { 104 + font-size: 1.5rem; 105 + color: var(--color-primary); 106 + } 107 + h3 { 108 + font-size: 1.25rem; 109 + color: var(--color-secondary); 110 + } 111 + h4 { 112 + font-size: 1.2rem; 113 + color: var(--color-tertiary); 114 + } 115 + h5 { 116 + font-size: 1.125rem; 117 + color: var(--color-secondary); 118 + } 119 + h6 { font-size: 1rem; } 120 + 121 + p { 122 + margin-bottom: 1rem; 123 + word-wrap: break-word; 124 + overflow-wrap: break-word; 125 + } 126 + 127 + a { 128 + color: var(--color-link); 129 + text-decoration: none; 130 + } 131 + 132 + .notebook-content a:hover { 133 + color: var(--color-emphasis); 134 + text-decoration: underline; 135 + } 136 + 137 + /* Wikilink validation (editor) */ 138 + .link-valid { 139 + color: var(--color-link); 140 + } 141 + 142 + .link-broken { 143 + color: var(--color-error); 144 + text-decoration: underline wavy; 145 + text-decoration-color: var(--color-error); 146 + opacity: 0.8; 147 + } 148 + 149 + /* Selection */ 150 + ::selection { 151 + background: var(--color-highlight); 152 + color: var(--color-text); 153 + } 154 + 155 + /* Lists */ 156 + ul, ol { 157 + margin-left: 1rem; 158 + margin-bottom: 1rem; 159 + } 160 + 161 + li { 162 + margin-bottom: 0.25rem; 163 + } 164 + 165 + /* Code */ 166 + code { 167 + font-family: var(--font-mono); 168 + background: var(--color-surface); 169 + padding: 0.125rem 0.25rem; 170 + border-radius: 4px; 171 + font-size: 0.9em; 172 + } 173 + 174 + pre { 175 + overflow-x: auto; 176 + margin-bottom: 1rem; 177 + border-radius: 5px; 178 + border: 1px solid var(--color-border); 179 + box-sizing: border-box; 180 + } 181 + 182 + /* Code blocks inside pre are handled by syntax theme */ 183 + pre code { 184 + 185 + display: block; 186 + width: fit-content; 187 + min-width: 100%; 188 + padding: 1rem; 189 + background: var(--color-surface); 190 + } 191 + 192 + /* Math */ 193 + .math { 194 + font-family: var(--font-mono); 195 + } 196 + 197 + .math-display { 198 + display: block; 199 + margin: 1rem 0; 200 + text-align: center; 201 + } 202 + 203 + /* Blockquotes */ 204 + blockquote { 205 + border-left: 2px solid var(--color-secondary); 206 + background: var(--color-surface); 207 + padding-left: 1rem; 208 + padding-right: 1rem; 209 + padding-top: 0.5rem; 210 + padding-bottom: 0.04rem; 211 + margin: 1rem 0; 212 + font-size: 0.95em; 213 + border-bottom-right-radius: 5px; 214 + border-top-right-radius: 5px; 215 + } 216 + } 217 + 218 + /* Tables */ 219 + table { 220 + border-collapse: collapse; 221 + width: 100%; 222 + margin-bottom: 1rem; 223 + display: block; 224 + overflow-x: auto; 225 + max-width: 100%; 226 + } 227 + 228 + th, td { 229 + border: 1px solid var(--color-border); 230 + padding: 0.5rem; 231 + text-align: left; 232 + } 233 + 234 + th { 235 + background: var(--color-surface); 236 + font-weight: 600; 237 + } 238 + 239 + tr:hover { 240 + background: var(--color-surface); 241 + } 242 + 243 + /* Footnotes */ 244 + .footnote-reference { 245 + font-size: 0.8em; 246 + color: var(--color-subtle); 247 + } 248 + 249 + .footnote-definition { 250 + order: 9999; 251 + margin: 0; 252 + padding: 0.5rem 0; 253 + font-size: 0.9em; 254 + } 255 + 256 + .footnote-definition:first-of-type { 257 + margin-top: 2rem; 258 + padding-top: 1rem; 259 + border-top: 2px solid var(--color-border); 260 + } 261 + 262 + .footnote-definition:first-of-type::before { 263 + content: "Footnotes"; 264 + display: block; 265 + font-weight: 600; 266 + font-size: 1.1em; 267 + color: var(--color-subtle); 268 + margin-bottom: 0.75rem; 269 + } 270 + 271 + .footnote-definition-label { 272 + font-weight: 600; 273 + margin-right: 0.5rem; 274 + color: var(--color-primary); 275 + } 276 + 277 + /* Aside blocks (via WeaverBlock prefix) */ 278 + aside, .aside { 279 + float: left; 280 + width: 40%; 281 + margin: 0 1.5rem 1rem 0; 282 + padding: 1rem; 283 + background: var(--color-surface); 284 + border-right: 3px solid var(--color-primary); 285 + font-size: 0.9em; 286 + clear: left; 287 + } 288 + 289 + aside > *:first-child, 290 + .aside > *:first-child { 291 + margin-top: 0; 292 + } 293 + 294 + aside > *:last-child, 295 + .aside > *:last-child { 296 + margin-bottom: 0; 297 + } 298 + 299 + /* Reset blockquote styling inside asides */ 300 + aside > blockquote, 301 + .aside > blockquote { 302 + border-left: none; 303 + background: transparent; 304 + padding: 0; 305 + margin: 0; 306 + font-size: inherit; 307 + } 308 + 309 + /* Indent utilities */ 310 + .indent-1 { margin-left: 1em; } 311 + .indent-2 { margin-left: 2em; } 312 + .indent-3 { margin-left: 3em; } 313 + 314 + /* Tufte-style Sidenotes */ 315 + /* Hide checkbox for sidenote toggle */ 316 + .margin-toggle { 317 + display: none; 318 + } 319 + 320 + /* Sidenote number marker (inline superscript) */ 321 + .sidenote-number { 322 + counter-increment: sidenote-counter; 323 + } 324 + 325 + .sidenote-number::after { 326 + content: counter(sidenote-counter); 327 + font-size: 0.7em; 328 + position: relative; 329 + top: -0.5em; 330 + color: var(--color-primary); 331 + padding-left: 0.1em; 332 + } 333 + 334 + /* Sidenote content (margin notes on wide screens) */ 335 + .sidenote { 336 + float: right; 337 + clear: right; 338 + margin-right: -15.5rem; 339 + width: 14rem; 340 + margin-top: 0.3rem; 341 + margin-bottom: 1rem; 342 + font-size: 0.85em; 343 + line-height: 1.4; 344 + color: var(--color-subtle); 345 + } 346 + 347 + .sidenote::before { 348 + content: counter(sidenote-counter) ". "; 349 + color: var(--color-primary); 350 + } 351 + 352 + /* Mobile sidenotes: toggle behavior */ 353 + @media (max-width: 900px) { 354 + /* Reset sidenote gutter on mobile */ 355 + body:has(.sidenote) { 356 + padding-right: 0; 357 + } 358 + 359 + aside, .aside { 360 + float: none; 361 + width: 100%; 362 + margin: 1rem 0; 363 + } 364 + 365 + .sidenote { 366 + display: none; 367 + } 368 + 369 + .margin-toggle:checked + .sidenote { 370 + display: block; 371 + float: none; 372 + width: 95%; 373 + margin: 0.5rem 2.5%; 374 + padding: 0.5rem; 375 + background: var(--color-surface); 376 + border-left: 2px solid var(--color-primary); 377 + } 378 + 379 + label.sidenote-number { 380 + cursor: pointer; 381 + } 382 + 383 + label.sidenote-number::after { 384 + text-decoration: underline; 385 + } 386 + } 387 + 388 + /* Images */ 389 + img { 390 + max-width: 100%; 391 + height: auto; 392 + display: block; 393 + margin: 1rem 0; 394 + border-radius: 4px; 395 + } 396 + 397 + /* Hygiene for iframes */ 398 + .html-embed-block { 399 + max-width: 100%; 400 + height: auto; 401 + display: block; 402 + margin: 1rem 0; 403 + } 404 + 405 + /* AT Protocol Embeds - Container */ 406 + /* Light mode: paper with shadow, dark mode: blueprint with borders */ 407 + .atproto-embed { 408 + display: block; 409 + position: relative; 410 + max-width: 550px; 411 + margin: 1rem 0; 412 + padding: 1rem; 413 + background: var(--color-surface); 414 + border-left: 2px solid var(--color-secondary); 415 + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent); 416 + } 417 + 418 + .atproto-embed:hover { 419 + border-left-color: var(--color-primary); 420 + } 421 + 422 + @media (prefers-color-scheme: dark) { 423 + .atproto-embed { 424 + box-shadow: none; 425 + border: 1px solid var(--color-border); 426 + border-left: 2px solid var(--color-secondary); 427 + } 428 + } 429 + 430 + .atproto-embed-placeholder { 431 + color: var(--color-muted); 432 + font-style: italic; 433 + } 434 + 435 + .embed-loading { 436 + display: block; 437 + padding: 0.5rem 0; 438 + color: var(--color-subtle); 439 + font-family: var(--font-mono); 440 + font-size: 0.85rem; 441 + } 442 + 443 + /* Embed Author Block */ 444 + .embed-author { 445 + display: flex; 446 + align-items: center; 447 + gap: 0.75rem; 448 + padding-bottom: 0.5rem; 449 + } 450 + 451 + .embed-avatar { 452 + width: 36px; 453 + height: 36px; 454 + max-width: 36px; 455 + max-height: 36px; 456 + aspect-ratio: 1; 457 + margin: 0; 458 + object-fit: cover; 459 + } 460 + 461 + .embed-author-info { 462 + display: flex; 463 + flex-direction: column; 464 + gap: 0; 465 + min-width: 0; 466 + } 467 + 468 + .embed-avatar-link { 469 + display: block; 470 + flex-shrink: 0; 471 + } 472 + 473 + .embed-author-name { 474 + font-weight: 600; 475 + color: var(--color-text); 476 + overflow: hidden; 477 + text-overflow: ellipsis; 478 + white-space: nowrap; 479 + text-decoration: none; 480 + line-height: 1.2; 481 + } 482 + 483 + a.embed-author-name:hover { 484 + color: var(--color-link); 485 + } 486 + 487 + .embed-author-handle { 488 + font-size: 0.85em; 489 + font-family: var(--font-mono); 490 + color: var(--color-subtle); 491 + text-decoration: none; 492 + overflow: hidden; 493 + text-overflow: ellipsis; 494 + white-space: nowrap; 495 + line-height: 1.2; 496 + } 497 + 498 + .embed-author-handle:hover { 499 + color: var(--color-link); 500 + } 501 + 502 + /* Card-wide clickable link (sits behind content) */ 503 + .embed-card-link { 504 + position: absolute; 505 + inset: 0; 506 + z-index: 0; 507 + } 508 + 509 + .embed-card-link:focus { 510 + outline: 2px solid var(--color-primary); 511 + outline-offset: 2px; 512 + } 513 + 514 + /* Interactive elements sit above the card link */ 515 + .embed-author, 516 + .embed-external, 517 + .embed-quote, 518 + .embed-images, 519 + .embed-meta { 520 + position: relative; 521 + z-index: 1; 522 + } 523 + 524 + /* Embed Content Block */ 525 + .embed-content { 526 + display: block; 527 + color: var(--color-text); 528 + line-height: 1.5; 529 + margin-bottom: 0.75rem; 530 + white-space: pre-wrap; 531 + } 532 + 533 + 534 + 535 + .embed-description { 536 + display: block; 537 + color: var(--color-text); 538 + font-size: 0.95em; 539 + line-height: 1.4; 540 + } 541 + 542 + /* Embed Metadata Block */ 543 + .embed-meta { 544 + display: flex; 545 + justify-content: space-between; 546 + align-items: center; 547 + font-size: 0.85em; 548 + color: var(--color-muted); 549 + margin-top: 0.75rem; 550 + } 551 + 552 + .embed-stats { 553 + display: flex; 554 + gap: 1rem; 555 + font-family: var(--font-mono); 556 + } 557 + 558 + .embed-stat { 559 + color: var(--color-subtle); 560 + font-size: 0.9em; 561 + } 562 + 563 + .embed-time { 564 + color: var(--color-subtle); 565 + text-decoration: none; 566 + font-family: var(--font-mono); 567 + font-size: 0.9em; 568 + } 569 + 570 + .embed-time:hover { 571 + color: var(--color-link); 572 + } 573 + 574 + .embed-type { 575 + font-size: 0.8em; 576 + color: var(--color-subtle); 577 + font-family: var(--font-mono); 578 + text-transform: uppercase; 579 + letter-spacing: 0.05em; 580 + } 581 + 582 + /* Embed URL link (shown with syntax in editor) */ 583 + .embed-url { 584 + color: var(--color-link); 585 + font-family: var(--font-mono); 586 + font-size: 0.9em; 587 + word-break: break-all; 588 + } 589 + 590 + /* External link cards */ 591 + .embed-external { 592 + display: flex; 593 + gap: 0.75rem; 594 + padding: 0.75rem; 595 + background: var(--color-surface); 596 + border: 1px dashed var(--color-border); 597 + text-decoration: none; 598 + color: inherit; 599 + margin-top: 0.5rem; 600 + } 601 + 602 + .embed-external:hover { 603 + border-left: 2px solid var(--color-primary); 604 + margin-left: -1px; 605 + } 606 + 607 + @media (prefers-color-scheme: dark) { 608 + .embed-external { 609 + border: 1px solid var(--color-border); 610 + } 611 + 612 + .embed-external:hover { 613 + border-left: 2px solid var(--color-primary); 614 + margin-left: -1px; 615 + } 616 + } 617 + 618 + .embed-external-thumb { 619 + width: 120px; 620 + height: 80px; 621 + object-fit: cover; 622 + flex-shrink: 0; 623 + } 624 + 625 + .embed-external-info { 626 + display: flex; 627 + flex-direction: column; 628 + gap: 0.25rem; 629 + min-width: 0; 630 + } 631 + 632 + .embed-external-title { 633 + font-weight: 600; 634 + color: var(--color-text); 635 + overflow: hidden; 636 + text-overflow: ellipsis; 637 + white-space: nowrap; 638 + } 639 + 640 + .embed-external-description { 641 + font-size: 0.9em; 642 + color: var(--color-muted); 643 + overflow: hidden; 644 + text-overflow: ellipsis; 645 + display: -webkit-box; 646 + -webkit-line-clamp: 2; 647 + -webkit-box-orient: vertical; 648 + } 649 + 650 + .embed-external-url { 651 + font-size: 0.8em; 652 + font-family: var(--font-mono); 653 + color: var(--color-subtle); 654 + } 655 + 656 + /* Image embeds */ 657 + .embed-images { 658 + display: grid; 659 + gap: 4px; 660 + margin-top: 0.5rem; 661 + overflow: hidden; 662 + } 663 + 664 + .embed-images-1 { 665 + grid-template-columns: 1fr; 666 + } 667 + 668 + .embed-images-2 { 669 + grid-template-columns: 1fr 1fr; 670 + } 671 + 672 + .embed-images-3 { 673 + grid-template-columns: 1fr 1fr; 674 + } 675 + 676 + .embed-images-4 { 677 + grid-template-columns: 1fr 1fr; 678 + } 679 + 680 + .embed-image-link { 681 + display: block; 682 + line-height: 0; 683 + } 684 + 685 + .embed-image { 686 + width: 100%; 687 + height: auto; 688 + max-height: 500px; 689 + object-fit: cover; 690 + object-position: center; 691 + margin: 0; 692 + } 693 + 694 + /* Quoted records */ 695 + .embed-quote { 696 + display: block; 697 + margin-top: 0.5rem; 698 + padding: 0.75rem; 699 + background: var(--color-overlay); 700 + border-left: 2px solid var(--color-tertiary); 701 + } 702 + 703 + @media (prefers-color-scheme: dark) { 704 + .embed-quote { 705 + border: 1px solid var(--color-border); 706 + border-left: 2px solid var(--color-tertiary); 707 + } 708 + } 709 + 710 + .embed-quote .embed-author { 711 + margin-bottom: 0.5rem; 712 + } 713 + 714 + .embed-quote .embed-avatar { 715 + width: 24px; 716 + height: 24px; 717 + min-width: 24px; 718 + min-height: 24px; 719 + max-width: 24px; 720 + max-height: 24px; 721 + } 722 + 723 + .embed-quote .embed-content { 724 + font-size: 0.95em; 725 + margin-bottom: 0; 726 + } 727 + 728 + /* Placeholder states */ 729 + .embed-video-placeholder, 730 + .embed-not-found, 731 + .embed-blocked, 732 + .embed-detached, 733 + .embed-unknown { 734 + display: block; 735 + padding: 1rem; 736 + background: var(--color-overlay); 737 + border-left: 2px solid var(--color-border); 738 + color: var(--color-muted); 739 + font-style: italic; 740 + margin-top: 0.5rem; 741 + font-family: var(--font-mono); 742 + font-size: 0.9em; 743 + } 744 + 745 + @media (prefers-color-scheme: dark) { 746 + .embed-video-placeholder, 747 + .embed-not-found, 748 + .embed-blocked, 749 + .embed-detached, 750 + .embed-unknown { 751 + border: 1px dashed var(--color-border); 752 + } 753 + } 754 + 755 + /* Record card embeds (feeds, lists, labelers, starter packs) */ 756 + .embed-record-card { 757 + display: block; 758 + margin-top: 0.5rem; 759 + padding: 0.75rem; 760 + background: var(--color-overlay); 761 + border-left: 2px solid var(--color-tertiary); 762 + } 763 + 764 + .embed-record-card > .embed-author-name { 765 + display: block; 766 + font-size: 1.1em; 767 + } 768 + 769 + .embed-subtitle { 770 + display: block; 771 + font-size: 0.85em; 772 + color: var(--color-muted); 773 + margin-bottom: 0.5rem; 774 + } 775 + 776 + .embed-record-card .embed-description { 777 + display: block; 778 + margin: 0.5rem 0; 779 + } 780 + 781 + .embed-record-card .embed-stats { 782 + display: block; 783 + margin-top: 0.25rem; 784 + } 785 + 786 + /* Generic record fields */ 787 + .embed-fields { 788 + display: block; 789 + margin-top: 0.5rem; 790 + font-family: var(--font-ui); 791 + font-size: 0.85rem; 792 + color: var(--color-muted); 793 + } 794 + 795 + .embed-field { 796 + display: block; 797 + margin-top: 0.25rem; 798 + } 799 + 800 + /* Nested fields get indentation */ 801 + .embed-fields .embed-fields { 802 + display: block; 803 + margin-top: 0.5rem; 804 + margin-left: 1rem; 805 + padding-left: 0.5rem; 806 + border-left: 1px solid var(--color-border); 807 + } 808 + 809 + /* Type label inside fields should be block with spacing */ 810 + .embed-fields > .embed-author-handle { 811 + display: block; 812 + margin-bottom: 0.25rem; 813 + } 814 + 815 + .embed-field-name { 816 + color: var(--color-subtle); 817 + } 818 + 819 + .embed-field-number { 820 + color: var(--color-tertiary); 821 + } 822 + 823 + .embed-field-date { 824 + color: var(--color-muted); 825 + } 826 + 827 + .embed-field-count { 828 + color: var(--color-muted); 829 + font-style: italic; 830 + } 831 + 832 + .embed-field-bool-true { 833 + color: var(--color-success); 834 + } 835 + 836 + .embed-field-bool-false { 837 + color: var(--color-muted); 838 + } 839 + 840 + .embed-field-link, 841 + .embed-field-aturi { 842 + color: var(--color-link); 843 + text-decoration: none; 844 + } 845 + 846 + .embed-field-link:hover, 847 + .embed-field-aturi:hover { 848 + text-decoration: underline; 849 + } 850 + 851 + .embed-field-did { 852 + font-family: var(--font-mono); 853 + font-size: 0.9em; 854 + } 855 + 856 + .embed-field-did .did-scheme, 857 + .embed-field-did .did-separator { 858 + color: var(--color-muted); 859 + } 860 + 861 + .embed-field-did .did-method { 862 + color: var(--color-tertiary); 863 + } 864 + 865 + .embed-field-did .did-identifier { 866 + color: var(--color-text); 867 + } 868 + 869 + .embed-field-nsid { 870 + color: var(--color-secondary); 871 + } 872 + 873 + .embed-field-handle { 874 + color: var(--color-link); 875 + } 876 + 877 + /* AT URI highlighting */ 878 + .aturi-scheme { 879 + color: var(--color-muted); 880 + } 881 + 882 + .aturi-slash { 883 + color: var(--color-muted); 884 + } 885 + 886 + .aturi-authority { 887 + color: var(--color-link); 888 + } 889 + 890 + .aturi-collection { 891 + color: var(--color-secondary); 892 + } 893 + 894 + .aturi-rkey { 895 + color: var(--color-tertiary); 896 + } 897 + 898 + /* Generic AT Protocol record embed */ 899 + .atproto-record > .embed-author-handle { 900 + display: block; 901 + margin-bottom: 0.25rem; 902 + } 903 + 904 + .atproto-record > .embed-author-name { 905 + display: block; 906 + margin-bottom: 0.5rem; 907 + } 908 + 909 + .atproto-record > .embed-content { 910 + margin-bottom: 0.5rem; 911 + } 912 + 913 + /* Notebook entry embed - full width, expandable */ 914 + .atproto-entry { 915 + max-width: none; 916 + width: 100%; 917 + margin: 1.5rem 0; 918 + padding: 0; 919 + background: var(--color-surface); 920 + border: 1px solid var(--color-border); 921 + border-left: 1px solid var(--color-border); 922 + box-shadow: none; 923 + overflow: hidden; 924 + } 925 + 926 + .atproto-entry:hover { 927 + border-left-color: var(--color-border); 928 + } 929 + 930 + @media (prefers-color-scheme: dark) { 931 + .atproto-entry { 932 + border: 1px solid var(--color-border); 933 + border-left: 1px solid var(--color-border); 934 + } 935 + } 936 + 937 + .embed-entry-header { 938 + display: flex; 939 + flex-wrap: wrap; 940 + align-items: baseline; 941 + gap: 0.5rem 1rem; 942 + padding: 0.75rem 1rem; 943 + background: var(--color-overlay); 944 + border-bottom: 1px solid var(--color-border); 945 + } 946 + 947 + .embed-entry-title { 948 + font-size: 1.1em; 949 + font-weight: 600; 950 + color: var(--color-text); 951 + } 952 + 953 + .embed-entry-author { 954 + font-size: 0.85em; 955 + color: var(--color-muted); 956 + } 957 + 958 + /* Hidden checkbox for expand/collapse */ 959 + .embed-entry-toggle { 960 + display: none; 961 + } 962 + 963 + /* Content wrapper - scrollable when collapsed */ 964 + .embed-entry-content { 965 + max-height: 30rem; 966 + overflow-y: auto; 967 + padding: 1rem; 968 + transition: max-height 0.3s ease; 969 + } 970 + 971 + /* When checkbox is checked, expand fully */ 972 + .embed-entry-toggle:checked ~ .embed-entry-content { 973 + max-height: none; 974 + } 975 + 976 + /* Expand/collapse button */ 977 + .embed-entry-expand { 978 + display: block; 979 + width: 100%; 980 + padding: 0.5rem; 981 + text-align: center; 982 + font-size: 0.85em; 983 + font-family: var(--font-ui); 984 + color: var(--color-muted); 985 + background: var(--color-overlay); 986 + border-top: 1px solid var(--color-border); 987 + cursor: pointer; 988 + user-select: none; 989 + } 990 + 991 + .embed-entry-expand:hover { 992 + color: var(--color-text); 993 + background: var(--color-surface); 994 + } 995 + 996 + /* Toggle button text */ 997 + .embed-entry-expand::before { 998 + content: "Expand ↓"; 999 + } 1000 + 1001 + .embed-entry-toggle:checked ~ .embed-entry-expand::before { 1002 + content: "Collapse ↑"; 1003 + } 1004 + 1005 + /* Hide expand button if content doesn't overflow (via JS class) */ 1006 + .atproto-entry.no-overflow .embed-entry-expand { 1007 + display: none; 1008 + } 1009 + 1010 + /* Horizontal Rule */ 1011 + hr { 1012 + border: none; 1013 + border-top: 2px solid var(--color-border); 1014 + margin: 2rem 0; 1015 + } 1016 + 1017 + /* Tablet and mobile responsiveness */ 1018 + @media (max-width: 900px) { 1019 + .notebook-content { 1020 + padding: 1.5rem 1rem; 1021 + max-width: 100%; 1022 + } 1023 + 1024 + h1 { font-size: 1.85rem; } 1025 + h2 { font-size: 1.4rem; } 1026 + h3 { font-size: 1.2rem; } 1027 + 1028 + blockquote { 1029 + margin-left: 0; 1030 + margin-right: 0; 1031 + } 1032 + } 1033 + 1034 + /* Small mobile phones */ 1035 + @media (max-width: 480px) { 1036 + .notebook-content { 1037 + padding: 1rem 0.75rem; 1038 + } 1039 + 1040 + h1 { font-size: 1.65rem; } 1041 + h2 { font-size: 1.3rem; } 1042 + h3 { font-size: 1.1rem; } 1043 + 1044 + blockquote { 1045 + padding-left: 0.75rem; 1046 + padding-right: 0.75rem; 1047 + } 1048 + } 1049 + </style> 1050 + <style> 1051 + /* Syntax highlighting - Light Mode (default) */ 1052 + /* 1053 + * theme "Rosé Pine Dawn" generated by syntect 1054 + */ 1055 + 1056 + .wvc-code { 1057 + color: #575279; 1058 + background-color: #faf4ed; 1059 + } 1060 + 1061 + .wvc-comment { 1062 + color: #797593; 1063 + font-style: italic; 1064 + } 1065 + .wvc-string, .wvc-punctuation.wvc-definition.wvc-string { 1066 + color: #ea9d34; 1067 + } 1068 + .wvc-constant.wvc-numeric { 1069 + color: #ea9d34; 1070 + } 1071 + .wvc-constant.wvc-language { 1072 + color: #ea9d34; 1073 + font-weight: bold; 1074 + } 1075 + .wvc-constant.wvc-character, .wvc-constant.wvc-other { 1076 + color: #ea9d34; 1077 + } 1078 + .wvc-variable { 1079 + color: #575279; 1080 + font-style: italic; 1081 + } 1082 + .wvc-keyword { 1083 + color: #286983; 1084 + } 1085 + .wvc-storage { 1086 + color: #56949f; 1087 + } 1088 + .wvc-storage.wvc-type { 1089 + color: #56949f; 1090 + } 1091 + .wvc-entity.wvc-name.wvc-class { 1092 + color: #286983; 1093 + font-weight: bold; 1094 + } 1095 + .wvc-entity.wvc-other.wvc-inherited-class { 1096 + color: #286983; 1097 + font-style: italic; 1098 + } 1099 + .wvc-entity.wvc-name.wvc-function { 1100 + color: #d7827e; 1101 + font-style: italic; 1102 + } 1103 + .wvc-variable.wvc-parameter { 1104 + color: #907aa9; 1105 + } 1106 + .wvc-entity.wvc-name.wvc-tag { 1107 + color: #286983; 1108 + font-weight: bold; 1109 + } 1110 + .wvc-entity.wvc-other.wvc-attribute-name { 1111 + color: #907aa9; 1112 + } 1113 + .wvc-support.wvc-function { 1114 + color: #d7827e; 1115 + font-weight: bold; 1116 + } 1117 + .wvc-support.wvc-constant { 1118 + color: #ea9d34; 1119 + font-weight: bold; 1120 + } 1121 + .wvc-support.wvc-type, .wvc-support.wvc-class { 1122 + color: #56949f; 1123 + font-weight: bold; 1124 + } 1125 + .wvc-support.wvc-other.wvc-variable { 1126 + color: #b4637a; 1127 + font-weight: bold; 1128 + } 1129 + .wvc-invalid { 1130 + color: #575279; 1131 + background-color: #b4637a; 1132 + } 1133 + .wvc-invalid.wvc-deprecated { 1134 + color: #575279; 1135 + background-color: #907aa9; 1136 + } 1137 + .wvc-punctuation, .wvc-keyword.wvc-operator { 1138 + color: #797593; 1139 + } 1140 + 1141 + 1142 + /* Syntax highlighting - Dark Mode */ 1143 + @media (prefers-color-scheme: dark) { 1144 + /* 1145 + * theme "Rosé Pine" generated by syntect 1146 + */ 1147 + 1148 + .wvc-code { 1149 + color: #e0def4; 1150 + background-color: #191724; 1151 + } 1152 + 1153 + .wvc-comment { 1154 + color: #908caa; 1155 + font-style: italic; 1156 + } 1157 + .wvc-string, .wvc-punctuation.wvc-definition.wvc-string { 1158 + color: #f6c177; 1159 + } 1160 + .wvc-constant.wvc-numeric { 1161 + color: #f6c177; 1162 + } 1163 + .wvc-constant.wvc-language { 1164 + color: #f6c177; 1165 + font-weight: bold; 1166 + } 1167 + .wvc-constant.wvc-character, .wvc-constant.wvc-other { 1168 + color: #f6c177; 1169 + } 1170 + .wvc-variable { 1171 + color: #e0def4; 1172 + font-style: italic; 1173 + } 1174 + .wvc-keyword { 1175 + color: #31748f; 1176 + } 1177 + .wvc-storage { 1178 + color: #9ccfd8; 1179 + } 1180 + .wvc-storage.wvc-type { 1181 + color: #9ccfd8; 1182 + } 1183 + .wvc-entity.wvc-name.wvc-class { 1184 + color: #31748f; 1185 + font-weight: bold; 1186 + } 1187 + .wvc-entity.wvc-other.wvc-inherited-class { 1188 + color: #31748f; 1189 + font-style: italic; 1190 + } 1191 + .wvc-entity.wvc-name.wvc-function { 1192 + color: #ebbcba; 1193 + font-style: italic; 1194 + } 1195 + .wvc-variable.wvc-parameter { 1196 + color: #c4a7e7; 1197 + } 1198 + .wvc-entity.wvc-name.wvc-tag { 1199 + color: #31748f; 1200 + font-weight: bold; 1201 + } 1202 + .wvc-entity.wvc-other.wvc-attribute-name { 1203 + color: #c4a7e7; 1204 + } 1205 + .wvc-support.wvc-function { 1206 + color: #ebbcba; 1207 + font-weight: bold; 1208 + } 1209 + .wvc-support.wvc-constant { 1210 + color: #f6c177; 1211 + font-weight: bold; 1212 + } 1213 + .wvc-support.wvc-type, .wvc-support.wvc-class { 1214 + color: #9ccfd8; 1215 + font-weight: bold; 1216 + } 1217 + .wvc-support.wvc-other.wvc-variable { 1218 + color: #eb6f92; 1219 + font-weight: bold; 1220 + } 1221 + .wvc-invalid { 1222 + color: #e0def4; 1223 + background-color: #eb6f92; 1224 + } 1225 + .wvc-invalid.wvc-deprecated { 1226 + color: #e0def4; 1227 + background-color: #c4a7e7; 1228 + } 1229 + .wvc-punctuation, .wvc-keyword.wvc-operator { 1230 + color: #908caa; 1231 + } 1232 + } 1233 + </style> 1234 + </head> 1235 + <body style="background: var(--color-base); min-height: 100vh;"> 1236 + <div class="notebook-content"> 1237 + <h1>Weaver: Long-form Writing on AT Protocol</h1> 1238 + <p><em>Or: "Get in kid, we're rebuilding the blogosphere!"</em></p> 1239 + <p>I grew up, like a lot of people on Bluesky, in the era of the internet where most of your online social interactions took place via text. I had a MySpace account, MSN messenger and Google Chat, I first got on Facebook back when they required a school email to sign up, I had a Tumblr, though not a LiveJournal.<label for="sn-1" class="sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Hi Rahaeli. Sorry I was the wrong kind of nerd.</span></p> 1240 + <blockquote> 1241 + <p><img src="weaver_photo_med.jpg" alt="weaver_photo_med.jpg" /><em>The namesake of what I'm building</em></p> 1242 + </blockquote> 1243 + <p>Social media in the conventional sense has been in a lot of ways a small part of the story of my time on the internet. The broader independent blogosphere of my teens and early adulthood shaped my worldview, and I was an avid reader and sometime participant there.</p> 1244 + <h2>The Blogosphere</h2> 1245 + <p>I am an atheist in large part because of a blog called Common Sense Atheism.<label for="sn-2" class="sidenote-number"></label><input type="checkbox" id="sn-2" class="margin-toggle"/><span class="sidenote">The author, Luke Muehlhauser, was criticising both Richard Dawkins <em>and</em> some Christian apologetics I was familiar with.</span> Luke's blog was part of a cluster of blogs out of which grew the rationalists, one of, for better or for worse, the most influential intellectual movements of the 21st century.I also read blogs like boingboing.net, was a big fan of Cory Doctorow. I figured out I am trans in part because of Thing of Things,<label for="sn-3" class="sidenote-number"></label><input type="checkbox" id="sn-3" class="margin-toggle"/><span class="sidenote">Specifically their piece on the <a href="https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/">cluster structure of genderspace</a>.</span> a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere.One thing these all have in common is length. Part of the reason I only really got onto Twitter in 2020 or so was because the concept of microblogging, of having to fit your thoughts into such a small package, baffled me for ages.<label for="sn-4" class="sidenote-number"></label><input type="checkbox" id="sn-4" class="margin-toggle"/><span class="sidenote">Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all.</span></p> 1246 + <aside> 1247 + <blockquote> 1248 + <p><strong>On Platform Decay</strong></p> 1249 + <p>Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read.</p> 1250 + </blockquote> 1251 + </aside> 1252 + <p>But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts.</p> 1253 + <p>Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.<label for="sn-5" class="sidenote-number"></label><input type="checkbox" id="sn-5" class="margin-toggle"/><span class="sidenote">I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis.</span>That's where the <code>at://</code> protocol and Weaver comes in.</p> 1254 + <h2>The Pitch</h2> 1255 + <p>Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.<label for="sn-6" class="sidenote-number"></label><input type="checkbox" id="sn-6" class="margin-toggle"/><span class="sidenote">The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing.</span> I was inspired by how weaver birds build their own homes, and by the notebooks, physical and virtual, that I create in the course of my work.The initial proof-of-concept is essentially a static site generator, able to turn a Markdown text file or a folder of Markdown files into a static "notebook" site. The intermediate goal is an elegant and intuitive writing platform with collaborative editing and straightforward, immediate publishing via a web-app.</p> 1256 + <aside> 1257 + <blockquote> 1258 + <p><strong>The Ultimate Goal</strong></p> 1259 + <p>Build a platform suitable for professional writers and journalists, an open alternative to platforms like Substack, with ways for readers to support writers, all on the <code>at://</code> protocol.</p> 1260 + </blockquote> 1261 + </aside> 1262 + <h2>How It Works</h2> 1263 + <p>Weaver works on a concept of notebooks with entries, which can be grouped into pages or chapters. They can have multiple attributed authors. You can tear out a metaphorical page and stick it in another notebook.</p> 1264 + <p>You own what you write.<label for="sn-7" class="sidenote-number"></label><input type="checkbox" id="sn-7" class="margin-toggle"/><span class="sidenote">Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about <em>your</em> ownership of <em>your</em> words.</span> And once collaborative editing is in, collaborative work will be resilient against deletion by one author. They can delete their notebook or even their account, but what you write will be safe.Entries are Markdown text—specifically, an extension on the Obsidian flavour of Markdown.<label for="sn-8" class="sidenote-number"></label><input type="checkbox" id="sn-8" class="margin-toggle"/><span class="sidenote">I forked the popular rust markdown processing library <code>pulldown-cmark</code> because it had limited extensibility along the axes I wanted—custom syntax extensions to support Obsidian's Markdown flavour and additional useful features, like some of the ones on show here!</span> They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images.</p> 1265 + <h2>Why Rust?</h2> 1266 + <p>As to why I'm writing it in Rust (and currently zero Typescript) as opposed to Go and Typescript? Well it comes down to familiarity. Rust isn't necessarily anyone's first choice in a vacuum for a web-native programming language, but it works quite well as one. I can share the vast majority of the protocol code, as well as the markdown rendering engine, between front and back end, with few if any compromises on performance, save a larger bundle size due to the nature of WebAssembly.</p> 1267 + <aside> 1268 + <blockquote> 1269 + <p><strong>On Interoperability</strong></p> 1270 + <p>The <code>at://</code> protocol, while it was developed in concert with a microblogging app, is actually pretty damn good for "macroblogging" too. Weaver's app server can display Whitewind posts. With effort, it can faithfully render Leaflet posts. It doesn't care what app your profile is on.</p> 1271 + </blockquote> 1272 + </aside> 1273 + <h2>Evolution</h2> 1274 + <p>Weaver is therefore very much an evolving thing. It will always have and support the proof-of-concept workflow as a first-class citizen. That's part of the benefit of building this on atproto.</p> 1275 + <p>If I screw this up, not too hard for someone else to pick up the torch and continue.<label for="sn-9" class="sidenote-number"></label><input type="checkbox" id="sn-9" class="margin-toggle"/><span class="sidenote">This is the traditional footnote, at the end, because sometimes you want your citations at the bottom of the page rather than in the margins.</span></p> 1276 + </div> 1277 + </body> 1278 + </html>
test-output/weaver_photo_med.jpg

This is a binary file and will not be displayed.

+71
test-sidenotes.md
··· 1 + # Weaver: Long-form Writing on AT Protocol 2 + 3 + *Or: "Get in kid, we're rebuilding the blogosphere!"* 4 + 5 + I grew up, like a lot of people on Bluesky, in the era of the internet where most of your online social interactions took place via text. I had a MySpace account, MSN messenger and Google Chat, I first got on Facebook back when they required a school email to sign up, I had a Tumblr, though not a LiveJournal.[^nostalgia] 6 + [^nostalgia]: Hi Rahaeli. Sorry I was the wrong kind of nerd. 7 + 8 + > ![[weaver_photo_med.jpg]]*The namesake of what I'm building* 9 + 10 + Social media in the conventional sense has been in a lot of ways a small part of the story of my time on the internet. The broader independent blogosphere of my teens and early adulthood shaped my worldview, and I was an avid reader and sometime participant there. 11 + 12 + ## The Blogosphere 13 + 14 + I am an atheist in large part because of a blog called Common Sense Atheism.[^csa] Luke's blog was part of a cluster of blogs out of which grew the rationalists, one of, for better or for worse, the most influential intellectual movements of the 21st century. 15 + [^csa]: The author, Luke Muehlhauser, was criticising both Richard Dawkins *and* some Christian apologetics I was familiar with. 16 + 17 + I also read blogs like boingboing.net, was a big fan of Cory Doctorow. I figured out I am trans in part because of Thing of Things,[^ozy] a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere. 18 + [^ozy]: Specifically their piece on the [cluster structure of genderspace](https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/). 19 + 20 + One thing these all have in common is length. Part of the reason I only really got onto Twitter in 2020 or so was because the concept of microblogging, of having to fit your thoughts into such a small package, baffled me for ages.[^irony] 21 + [^irony]: Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all. 22 + 23 + {.aside} 24 + > **On Platform Decay** 25 + > 26 + > Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read. 27 + 28 + But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts. 29 + 30 + Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.[^substack] 31 + [^substack]: I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis. 32 + 33 + That's where the `at://` protocol and Weaver comes in. 34 + 35 + ## The Pitch 36 + 37 + Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.[^namesake] I was inspired by how weaver birds build their own homes, and by the notebooks, physical and virtual, that I create in the course of my work. 38 + [^namesake]: The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing. 39 + 40 + The initial proof-of-concept is essentially a static site generator, able to turn a Markdown text file or a folder of Markdown files into a static "notebook" site. The intermediate goal is an elegant and intuitive writing platform with collaborative editing and straightforward, immediate publishing via a web-app. 41 + 42 + {.aside} 43 + > **The Ultimate Goal** 44 + > 45 + > Build a platform suitable for professional writers and journalists, an open alternative to platforms like Substack, with ways for readers to support writers, all on the `at://` protocol. 46 + 47 + ## How It Works 48 + 49 + Weaver works on a concept of notebooks with entries, which can be grouped into pages or chapters. They can have multiple attributed authors. You can tear out a metaphorical page and stick it in another notebook. 50 + 51 + You own what you write.[^ownership] And once collaborative editing is in, collaborative work will be resilient against deletion by one author. They can delete their notebook or even their account, but what you write will be safe. 52 + [^ownership]: Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about *your* ownership of *your* words. 53 + 54 + Entries are Markdown text—specifically, an extension on the Obsidian flavour of Markdown.[^markdown] They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images. 55 + [^markdown]: I forked the popular rust markdown processing library `pulldown-cmark` because it had limited extensibility along the axes I wanted—custom syntax extensions to support Obsidian's Markdown flavour and additional useful features, like some of the ones on show here! 56 + 57 + ## Why Rust? 58 + 59 + As to why I'm writing it in Rust (and currently zero Typescript) as opposed to Go and Typescript? Well it comes down to familiarity. Rust isn't necessarily anyone's first choice in a vacuum for a web-native programming language, but it works quite well as one. I can share the vast majority of the protocol code, as well as the markdown rendering engine, between front and back end, with few if any compromises on performance, save a larger bundle size due to the nature of WebAssembly. 60 + 61 + {.aside} 62 + > **On Interoperability** 63 + > 64 + > The `at://` protocol, while it was developed in concert with a microblogging app, is actually pretty damn good for "macroblogging" too. Weaver's app server can display Whitewind posts. With effort, it can faithfully render Leaflet posts. It doesn't care what app your profile is on. 65 + 66 + ## Evolution 67 + 68 + Weaver is therefore very much an evolving thing. It will always have and support the proof-of-concept workflow as a first-class citizen. That's part of the benefit of building this on atproto. 69 + 70 + If I screw this up, not too hard for someone else to pick up the torch and continue.[^open] 71 + [^open]: This is the traditional footnote, at the end, because sometimes you want your citations at the bottom of the page rather than in the margins.