new render stuff, fixed lexicon

Orual 593dbcb3 119371ff

+3663 -394
+47 -36
Cargo.lock
··· 476 477 [[package]] 478 name = "aws-lc-rs" 479 - version = "1.15.1" 480 source = "registry+https://github.com/rust-lang/crates.io-index" 481 - checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" 482 dependencies = [ 483 "aws-lc-sys", 484 "zeroize", ··· 486 487 [[package]] 488 name = "aws-lc-sys" 489 - version = "0.34.0" 490 source = "registry+https://github.com/rust-lang/crates.io-index" 491 - checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" 492 dependencies = [ 493 "cc", 494 "cmake", ··· 900 901 [[package]] 902 name = "bumpalo" 903 - version = "3.19.0" 904 source = "registry+https://github.com/rust-lang/crates.io-index" 905 - checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" 906 dependencies = [ 907 "allocator-api2", 908 ] ··· 1234 1235 [[package]] 1236 name = "cmake" 1237 - version = "0.1.56" 1238 source = "registry+https://github.com/rust-lang/crates.io-index" 1239 - checksum = "b042e5d8a74ae91bb0961acd039822472ec99f8ab0948cbf6d1369588f8be586" 1240 dependencies = [ 1241 "cc", 1242 ] ··· 2805 [[package]] 2806 name = "dioxus-primitives" 2807 version = "0.0.1" 2808 - source = "git+https://github.com/DioxusLabs/components#7e5862e574aeceb3a3a021d042c165a839f1860b" 2809 dependencies = [ 2810 "dioxus 0.7.2", 2811 "dioxus-sdk-time", ··· 3992 3993 [[package]] 3994 name = "generator" 3995 - version = "0.8.7" 3996 source = "registry+https://github.com/rust-lang/crates.io-index" 3997 - checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" 3998 dependencies = [ 3999 "cc", 4000 "cfg-if", 4001 "libc", 4002 "log", 4003 "rustversion", 4004 - "windows 0.61.3", 4005 ] 4006 4007 [[package]] ··· 5149 5150 [[package]] 5151 name = "insta" 5152 - version = "1.44.3" 5153 source = "registry+https://github.com/rust-lang/crates.io-index" 5154 - checksum = "b5c943d4415edd8153251b6f197de5eb1640e56d84e8d9159bea190421c73698" 5155 dependencies = [ 5156 "console", 5157 "once_cell", 5158 "serde", 5159 "similar", 5160 ] 5161 5162 [[package]] ··· 6067 6068 [[package]] 6069 name = "libredox" 6070 - version = "0.1.10" 6071 source = "registry+https://github.com/rust-lang/crates.io-index" 6072 - checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" 6073 dependencies = [ 6074 "bitflags 2.10.0", 6075 "libc", 6076 - "redox_syscall", 6077 ] 6078 6079 [[package]] ··· 6480 [[package]] 6481 name = "markdown-weaver" 6482 version = "0.13.0" 6483 - source = "git+https://github.com/rsform/markdown-weaver#52075e20a194375f1bd4a0c78201ce3b3a52c82d" 6484 dependencies = [ 6485 "bitflags 2.10.0", 6486 "getopts", ··· 6493 [[package]] 6494 name = "markdown-weaver-escape" 6495 version = "0.11.0" 6496 - source = "git+https://github.com/rsform/markdown-weaver#52075e20a194375f1bd4a0c78201ce3b3a52c82d" 6497 6498 [[package]] 6499 name = "markup5ever" ··· 7635 dependencies = [ 7636 "cfg-if", 7637 "libc", 7638 - "redox_syscall", 7639 "smallvec", 7640 "windows-link 0.2.1", 7641 ] ··· 8172 source = "registry+https://github.com/rust-lang/crates.io-index" 8173 checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" 8174 dependencies = [ 8175 - "toml_edit 0.23.9", 8176 ] 8177 8178 [[package]] ··· 8581 version = "0.5.18" 8582 source = "registry+https://github.com/rust-lang/crates.io-index" 8583 checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" 8584 dependencies = [ 8585 "bitflags 2.10.0", 8586 ] ··· 8926 8927 [[package]] 8928 name = "rustls-pki-types" 8929 - version = "1.13.1" 8930 source = "registry+https://github.com/rust-lang/crates.io-index" 8931 - checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" 8932 dependencies = [ 8933 "web-time", 8934 "zeroize", ··· 9989 9990 [[package]] 9991 name = "supports-hyperlinks" 9992 - version = "3.1.0" 9993 source = "registry+https://github.com/rust-lang/crates.io-index" 9994 - checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b" 9995 9996 [[package]] 9997 name = "supports-unicode" ··· 10734 10735 [[package]] 10736 name = "toml_datetime" 10737 - version = "0.7.3" 10738 source = "registry+https://github.com/rust-lang/crates.io-index" 10739 - checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" 10740 dependencies = [ 10741 "serde_core", 10742 ] ··· 10767 10768 [[package]] 10769 name = "toml_edit" 10770 - version = "0.23.9" 10771 source = "registry+https://github.com/rust-lang/crates.io-index" 10772 - checksum = "5d7cbc3b4b49633d57a0509303158ca50de80ae32c265093b24c414705807832" 10773 dependencies = [ 10774 "indexmap 2.12.1", 10775 - "toml_datetime 0.7.3", 10776 "toml_parser", 10777 "winnow 0.7.14", 10778 ] 10779 10780 [[package]] 10781 name = "toml_parser" 10782 - version = "1.0.4" 10783 source = "registry+https://github.com/rust-lang/crates.io-index" 10784 - checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" 10785 dependencies = [ 10786 "winnow 0.7.14", 10787 ] ··· 10845 10846 [[package]] 10847 name = "tracing" 10848 - version = "0.1.43" 10849 source = "registry+https://github.com/rust-lang/crates.io-index" 10850 - checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" 10851 dependencies = [ 10852 "log", 10853 "pin-project-lite", ··· 10868 10869 [[package]] 10870 name = "tracing-core" 10871 - version = "0.1.35" 10872 source = "registry+https://github.com/rust-lang/crates.io-index" 10873 - checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" 10874 dependencies = [ 10875 "once_cell", 10876 "valuable",
··· 476 477 [[package]] 478 name = "aws-lc-rs" 479 + version = "1.15.2" 480 source = "registry+https://github.com/rust-lang/crates.io-index" 481 + checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" 482 dependencies = [ 483 "aws-lc-sys", 484 "zeroize", ··· 486 487 [[package]] 488 name = "aws-lc-sys" 489 + version = "0.35.0" 490 source = "registry+https://github.com/rust-lang/crates.io-index" 491 + checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" 492 dependencies = [ 493 "cc", 494 "cmake", ··· 900 901 [[package]] 902 name = "bumpalo" 903 + version = "3.19.1" 904 source = "registry+https://github.com/rust-lang/crates.io-index" 905 + checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" 906 dependencies = [ 907 "allocator-api2", 908 ] ··· 1234 1235 [[package]] 1236 name = "cmake" 1237 + version = "0.1.57" 1238 source = "registry+https://github.com/rust-lang/crates.io-index" 1239 + checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" 1240 dependencies = [ 1241 "cc", 1242 ] ··· 2805 [[package]] 2806 name = "dioxus-primitives" 2807 version = "0.0.1" 2808 + source = "git+https://github.com/DioxusLabs/components#3564270718866d2e886f879973afc77d7c3a1689" 2809 dependencies = [ 2810 "dioxus 0.7.2", 2811 "dioxus-sdk-time", ··· 3992 3993 [[package]] 3994 name = "generator" 3995 + version = "0.8.8" 3996 source = "registry+https://github.com/rust-lang/crates.io-index" 3997 + checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" 3998 dependencies = [ 3999 "cc", 4000 "cfg-if", 4001 "libc", 4002 "log", 4003 "rustversion", 4004 + "windows-link 0.2.1", 4005 + "windows-result 0.4.1", 4006 ] 4007 4008 [[package]] ··· 5150 5151 [[package]] 5152 name = "insta" 5153 + version = "1.45.0" 5154 source = "registry+https://github.com/rust-lang/crates.io-index" 5155 + checksum = "b76866be74d68b1595eb8060cb9191dca9c021db2316558e52ddc5d55d41b66c" 5156 dependencies = [ 5157 "console", 5158 "once_cell", 5159 "serde", 5160 "similar", 5161 + "tempfile", 5162 ] 5163 5164 [[package]] ··· 6069 6070 [[package]] 6071 name = "libredox" 6072 + version = "0.1.11" 6073 source = "registry+https://github.com/rust-lang/crates.io-index" 6074 + checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" 6075 dependencies = [ 6076 "bitflags 2.10.0", 6077 "libc", 6078 + "redox_syscall 0.6.0", 6079 ] 6080 6081 [[package]] ··· 6482 [[package]] 6483 name = "markdown-weaver" 6484 version = "0.13.0" 6485 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6486 dependencies = [ 6487 "bitflags 2.10.0", 6488 "getopts", ··· 6495 [[package]] 6496 name = "markdown-weaver-escape" 6497 version = "0.11.0" 6498 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6499 6500 [[package]] 6501 name = "markup5ever" ··· 7637 dependencies = [ 7638 "cfg-if", 7639 "libc", 7640 + "redox_syscall 0.5.18", 7641 "smallvec", 7642 "windows-link 0.2.1", 7643 ] ··· 8174 source = "registry+https://github.com/rust-lang/crates.io-index" 8175 checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" 8176 dependencies = [ 8177 + "toml_edit 0.23.10+spec-1.0.0", 8178 ] 8179 8180 [[package]] ··· 8583 version = "0.5.18" 8584 source = "registry+https://github.com/rust-lang/crates.io-index" 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" 8595 dependencies = [ 8596 "bitflags 2.10.0", 8597 ] ··· 8937 8938 [[package]] 8939 name = "rustls-pki-types" 8940 + version = "1.13.2" 8941 source = "registry+https://github.com/rust-lang/crates.io-index" 8942 + checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" 8943 dependencies = [ 8944 "web-time", 8945 "zeroize", ··· 10000 10001 [[package]] 10002 name = "supports-hyperlinks" 10003 + version = "3.2.0" 10004 source = "registry+https://github.com/rust-lang/crates.io-index" 10005 + checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" 10006 10007 [[package]] 10008 name = "supports-unicode" ··· 10745 10746 [[package]] 10747 name = "toml_datetime" 10748 + version = "0.7.5+spec-1.1.0" 10749 source = "registry+https://github.com/rust-lang/crates.io-index" 10750 + checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" 10751 dependencies = [ 10752 "serde_core", 10753 ] ··· 10778 10779 [[package]] 10780 name = "toml_edit" 10781 + version = "0.23.10+spec-1.0.0" 10782 source = "registry+https://github.com/rust-lang/crates.io-index" 10783 + checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" 10784 dependencies = [ 10785 "indexmap 2.12.1", 10786 + "toml_datetime 0.7.5+spec-1.1.0", 10787 "toml_parser", 10788 "winnow 0.7.14", 10789 ] 10790 10791 [[package]] 10792 name = "toml_parser" 10793 + version = "1.0.6+spec-1.1.0" 10794 source = "registry+https://github.com/rust-lang/crates.io-index" 10795 + checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" 10796 dependencies = [ 10797 "winnow 0.7.14", 10798 ] ··· 10856 10857 [[package]] 10858 name = "tracing" 10859 + version = "0.1.44" 10860 source = "registry+https://github.com/rust-lang/crates.io-index" 10861 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" 10862 dependencies = [ 10863 "log", 10864 "pin-project-lite", ··· 10879 10880 [[package]] 10881 name = "tracing-core" 10882 + version = "0.1.36" 10883 source = "registry+https://github.com/rust-lang/crates.io-index" 10884 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" 10885 dependencies = [ 10886 "once_cell", 10887 "valuable",
+2 -2
Cargo.toml
··· 28 syntect = { version = "5.2.0", default-features = false } 29 n0-future = "=0.1.3" 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" } 33 # markdown-weaver = { path = "../markdown-weaver/markdown-weaver" } 34 # markdown-weaver-escape = { path = "../markdown-weaver/markdown-weaver-escape" } 35
··· 28 syntect = { version = "5.2.0", default-features = false } 29 n0-future = "=0.1.3" 30 tracing = { version = "0.1.41", default-features = false, features = ["std"] } 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 # markdown-weaver = { path = "../markdown-weaver/markdown-weaver" } 34 # markdown-weaver-escape = { path = "../markdown-weaver/markdown-weaver-escape" } 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%; 7 min-height: 100vh; 8 background: var(--color-base); 9 - max-width: calc(95ch + 400px + 4rem); /* content + gutters + gaps */ 10 margin: 0 auto; 11 - padding: 0 1rem 0 0; 12 box-sizing: border-box; 13 } 14 15 - /* Main content area */ 16 - .entry-content-main { 17 - grid-column: 2; 18 - background: var(--color-base); 19 } 20 21 - /* Navigation gutters */ 22 - .nav-gutter { 23 - position: sticky; 24 - top: auto; 25 - bottom: 2rem; 26 - height: fit-content; 27 - align-self: end; 28 } 29 30 - .nav-prev { 31 - grid-column: 1; 32 } 33 34 - .nav-next { 35 - grid-column: 3; 36 } 37 38 - /* Navigation buttons */ 39 - .nav-button { 40 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; 51 } 52 53 - .nav-button:hover { 54 - box-shadow: 0 2px 6px color-mix(in srgb, var(--color-text) 12%, transparent); 55 } 56 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 - } 63 64 - .nav-button:hover { 65 - box-shadow: none; 66 - border-color: var(--color-primary); 67 - } 68 } 69 70 - .nav-button-prev { 71 align-items: flex-start; 72 text-align: left; 73 } 74 75 - .nav-button-next { 76 align-items: flex-end; 77 text-align: right; 78 } 79 80 - .nav-arrow { 81 - font-size: 1.5rem; 82 font-weight: bold; 83 color: var(--color-primary); 84 - transition: color 0.2s ease; 85 } 86 87 - .nav-button:hover .nav-arrow { 88 - color: var(--color-emphasis); 89 } 90 91 - .nav-button:hover .nav-label { 92 - color: var(--color-secondary); 93 } 94 95 - .nav-title { 96 - font-size: 0.95rem; 97 - font-weight: 500; 98 - line-height: 1.4; 99 } 100 101 - /* Entry metadata header */ 102 .entry-metadata { 103 margin-bottom: calc(1rem * var(--spacing-scale, 1.5)); 104 padding-bottom: calc(0.5rem * var(--spacing-scale, 1.5)); ··· 235 background: var(--color-surface); 236 } 237 238 - /* Responsive layout - Tablet/small desktop */ 239 - @media (max-width: 1400px) { 240 - .entry-page-layout { 241 - grid-template-columns: 1fr; 242 - gap: 0; 243 - } 244 245 - .entry-content-main { 246 - grid-column: 1; 247 - padding: 2rem 1rem; 248 } 249 250 - .nav-gutter { 251 - position: relative; 252 - bottom: auto; 253 - grid-column: 1; 254 - padding: 0 1rem; 255 } 256 257 - .nav-prev { 258 - order: 2; 259 - margin-top: 2rem; 260 } 261 262 - .nav-next { 263 - order: 3; 264 - margin-top: 1rem; 265 } 266 267 - .entry-content-main { 268 - order: 1; 269 } 270 } 271 272 - /* Tablet and smaller */ 273 @media (max-width: 900px) { 274 - .entry-page-layout { 275 - max-width: 100%; 276 - grid-template-columns: minmax(0, 1fr); 277 } 278 } 279 280 - /* Small mobile phones */ 281 - @media (max-width: 480px) { 282 - .entry-content-main { 283 - padding: 1rem 0.75rem; 284 } 285 286 - .nav-gutter { 287 - padding: 0 0.75rem; 288 } 289 290 - .nav-button { 291 - padding: 0.75rem; 292 } 293 294 .entry-metadata {
··· 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; 9 min-height: 100vh; 10 background: var(--color-base); 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); 21 margin: 0 auto; 22 + width: 100%; 23 box-sizing: border-box; 24 } 25 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; 36 } 37 38 + .entry-header .nav-button:hover { 39 + color: var(--color-primary); 40 } 41 42 + .entry-header .nav-button-prev { 43 + flex-shrink: 1; 44 + min-width: 0; 45 } 46 47 + .entry-header .nav-button-next { 48 + flex-shrink: 1; 49 + min-width: 0; 50 } 51 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; 75 display: flex; 76 + justify-content: center; 77 + padding: 0 1rem; 78 } 79 80 + .entry-content-main { 81 + width: var(--content-width); 82 + max-width: 100%; 83 + position: relative; 84 } 85 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 + } 90 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; 102 } 103 104 + .entry-footer-nav .nav-button-prev { 105 align-items: flex-start; 106 text-align: left; 107 } 108 109 + .entry-footer-nav .nav-button-next { 110 align-items: flex-end; 111 text-align: right; 112 + margin-left: auto; 113 } 114 115 + .entry-footer-nav .nav-arrow { 116 + font-size: 1.25rem; 117 font-weight: bold; 118 color: var(--color-primary); 119 } 120 121 + .entry-footer-nav .nav-title { 122 + font-size: 0.95rem; 123 + font-weight: 500; 124 + line-height: 1.4; 125 } 126 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; 131 } 132 133 + .entry-header .entry-metadata .entry-header-row { 134 + flex-wrap: wrap; 135 } 136 137 + /* Standalone entry metadata (when not in header) */ 138 .entry-metadata { 139 margin-bottom: calc(1rem * var(--spacing-scale, 1.5)); 140 padding-bottom: calc(0.5rem * var(--spacing-scale, 1.5)); ··· 271 background: var(--color-surface); 272 } 273 274 + /* TODO: footnote ordering needs non-flex solution for aside reflow to work */ 275 276 + /* Responsive: tablet */ 277 + @media (max-width: 1200px) { 278 + .entry-header { 279 + flex-wrap: wrap; 280 } 281 282 + .entry-header .entry-metadata { 283 + order: -1; 284 + flex-basis: 100%; 285 + max-width: none; 286 } 287 288 + .entry-header .nav-button-prev { 289 + order: 0; 290 } 291 292 + .entry-header .nav-button-next { 293 + order: 1; 294 + margin-left: auto; 295 } 296 + } 297 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; 303 } 304 } 305 306 + /* Responsive: narrower - compress left margin more */ 307 @media (max-width: 900px) { 308 + .entry-header .nav-title { 309 + max-width: 8rem; 310 + } 311 + 312 + .entry-content-main:has(.sidenote) { 313 + margin-left: -5rem; 314 } 315 } 316 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; 323 } 324 325 + .entry-content-main:has(.sidenote) { 326 + margin-left: 0; 327 + margin-right: 0; 328 } 329 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; 341 } 342 343 .entry-metadata {
+4
crates/weaver-app/src/components/editor/actions.rs
··· 43 self.end.saturating_sub(self.start) 44 } 45 46 pub fn is_empty(&self) -> bool { 47 self.len() == 0 48 } ··· 65 /// These represent semantic operations on the document, decoupled from 66 /// how they're triggered (keyboard, mouse, touch, voice, etc.). 67 #[derive(Debug, Clone, PartialEq)] 68 pub enum EditorAction { 69 // === Text Insertion === 70 /// Insert text at the given range (replacing any selected content). ··· 178 /// SmolStr to efficiently handle both single characters and composed sequences 179 /// (from dead keys, IME, etc.). 180 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 181 pub enum Key { 182 /// A character key. The string corresponds to the character typed, 183 /// taking into account locale, modifiers, and keyboard mapping. ··· 460 pub super_: bool, // `super` is a keyword 461 } 462 463 impl Modifiers { 464 pub const NONE: Self = Self { 465 ctrl: false,
··· 43 self.end.saturating_sub(self.start) 44 } 45 46 + #[allow(dead_code)] 47 pub fn is_empty(&self) -> bool { 48 self.len() == 0 49 } ··· 66 /// These represent semantic operations on the document, decoupled from 67 /// how they're triggered (keyboard, mouse, touch, voice, etc.). 68 #[derive(Debug, Clone, PartialEq)] 69 + #[allow(dead_code)] 70 pub enum EditorAction { 71 // === Text Insertion === 72 /// Insert text at the given range (replacing any selected content). ··· 180 /// SmolStr to efficiently handle both single characters and composed sequences 181 /// (from dead keys, IME, etc.). 182 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 183 + #[allow(dead_code)] 184 pub enum Key { 185 /// A character key. The string corresponds to the character typed, 186 /// taking into account locale, modifiers, and keyboard mapping. ··· 463 pub super_: bool, // `super` is a keyword 464 } 465 466 + #[allow(dead_code)] 467 impl Modifiers { 468 pub const NONE: Self = Self { 469 ctrl: false,
+9 -2
crates/weaver-app/src/components/editor/beforeinput.rs
··· 57 /// 58 /// See: https://w3c.github.io/input-events/#interface-InputEvent-Attributes 59 #[derive(Debug, Clone, PartialEq, Eq)] 60 pub enum InputType { 61 // === Insertion === 62 /// Insert typed text. ··· 131 Unknown(String), 132 } 133 134 impl InputType { 135 /// Parse from the browser's inputType string. 136 pub fn from_str(s: &str) -> Self { ··· 220 221 /// Result of handling a beforeinput event. 222 #[derive(Debug, Clone)] 223 pub enum BeforeInputResult { 224 /// Event was handled, prevent default browser behavior. 225 Handled, ··· 235 } 236 237 /// Context for beforeinput handling. 238 pub struct BeforeInputContext<'a> { 239 /// The input type. 240 pub input_type: InputType, ··· 253 /// 254 /// This is the main entry point for beforeinput-based input handling. 255 /// Returns whether the event was handled and default should be prevented. 256 pub fn handle_beforeinput( 257 doc: &mut EditorDocument, 258 ctx: BeforeInputContext<'_>, ··· 557 let end_text = end_container.text_content().unwrap_or_default(); 558 559 // Check if containers are the editor element itself 560 - let start_is_editor = start_container.dyn_ref::<web_sys::Element>() 561 .map(|e| e == &editor_element) 562 .unwrap_or(false); 563 - let end_is_editor = end_container.dyn_ref::<web_sys::Element>() 564 .map(|e| e == &editor_element) 565 .unwrap_or(false); 566
··· 57 /// 58 /// See: https://w3c.github.io/input-events/#interface-InputEvent-Attributes 59 #[derive(Debug, Clone, PartialEq, Eq)] 60 + #[allow(dead_code)] 61 pub enum InputType { 62 // === Insertion === 63 /// Insert typed text. ··· 132 Unknown(String), 133 } 134 135 + #[allow(dead_code)] 136 impl InputType { 137 /// Parse from the browser's inputType string. 138 pub fn from_str(s: &str) -> Self { ··· 222 223 /// Result of handling a beforeinput event. 224 #[derive(Debug, Clone)] 225 + #[allow(dead_code)] 226 pub enum BeforeInputResult { 227 /// Event was handled, prevent default browser behavior. 228 Handled, ··· 238 } 239 240 /// Context for beforeinput handling. 241 + #[allow(dead_code)] 242 pub struct BeforeInputContext<'a> { 243 /// The input type. 244 pub input_type: InputType, ··· 257 /// 258 /// This is the main entry point for beforeinput-based input handling. 259 /// Returns whether the event was handled and default should be prevented. 260 + #[allow(dead_code)] 261 pub fn handle_beforeinput( 262 doc: &mut EditorDocument, 263 ctx: BeforeInputContext<'_>, ··· 562 let end_text = end_container.text_content().unwrap_or_default(); 563 564 // Check if containers are the editor element itself 565 + let start_is_editor = start_container 566 + .dyn_ref::<web_sys::Element>() 567 .map(|e| e == &editor_element) 568 .unwrap_or(false); 569 + let end_is_editor = end_container 570 + .dyn_ref::<web_sys::Element>() 571 .map(|e| e == &editor_element) 572 .unwrap_or(false); 573
+27 -15
crates/weaver-app/src/components/editor/component.rs
··· 1 //! The main MarkdownEditor component. 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 - 14 use super::actions::{ 15 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 16 handle_keydown_with_bindings, 17 }; 18 use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; 19 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 20 use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 21 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 - }; 25 use super::formatting; 26 use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 27 use super::offset_map::SnapDirection; 28 use super::paragraph::ParagraphRender; 29 use super::platform; 30 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 31 use super::render; 32 use super::storage; 33 use super::sync::{SyncStatus, load_and_merge_document}; 34 use super::toolbar::EditorToolbar; 35 use super::visibility::update_syntax_visibility; 36 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 37 use crate::auth::AuthState; 38 use crate::components::collab::CollaboratorAvatars; 39 use crate::components::editor::ReportButton; 40 use crate::components::editor::collab::CollabCoordinator; 41 use crate::fetch::Fetcher; 42 43 /// Result of loading document state. 44 enum LoadResult { ··· 47 /// Loading failed 48 Failed(String), 49 /// Still loading 50 Loading, 51 } 52 ··· 412 let fetcher = use_context::<Fetcher>(); 413 let auth_state = use_context::<Signal<AuthState>>(); 414 415 let mut document = use_hook(|| { 416 let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); 417 ··· 459 let doc_for_memo = document.clone(); 460 let doc_for_refs = document.clone(); 461 let entry_index_for_memo = entry_index.clone(); 462 let mut paragraphs = use_memo(move || { 463 let edit = doc_for_memo.last_edit(); 464 let cache = render_cache.peek(); ··· 627 628 let mut new_tag = use_signal(String::new); 629 630 let offset_map = use_memo(move || { 631 paragraphs() 632 .iter() ··· 639 .flat_map(|p| p.syntax_spans.iter().cloned()) 640 .collect::<Vec<_>>() 641 }); 642 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 643 644 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 710 }); 711 712 // Track last saved frontiers to detect changes (peek-only, no subscriptions) 713 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 714 715 // Store interval handle so it's dropped when component unmounts (prevents panic) ··· 1153 // Refresh callback: fetch and merge collaborator changes (incremental) 1154 let on_refresh = if is_published { 1155 let fetcher_for_refresh = fetcher.clone(); 1156 - let mut doc_for_refresh = document.clone(); 1157 let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); 1158 1159 Some(EventHandler::new(move |_| { ··· 1398 move |evt| { 1399 tracing::debug!("onclick fired - syncing cursor from DOM"); 1400 let paras = cached_paragraphs(); 1401 1402 // Check if click target is a math-clickable element 1403 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
··· 1 //! The main MarkdownEditor component. 2 3 + #[allow(unused_imports)] 4 use super::actions::{ 5 EditorAction, Key, KeyCombo, KeybindingConfig, KeydownResult, Range, execute_action, 6 handle_keydown_with_bindings, 7 }; 8 + #[allow(unused_imports)] 9 use super::beforeinput::{BeforeInputContext, BeforeInputResult, InputType, handle_beforeinput}; 10 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 11 use super::beforeinput::{get_data_from_event, get_target_range_from_event}; 12 use super::document::{CompositionState, EditorDocument, LoadedDocState}; 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}; 16 use super::formatting; 17 use super::input::{get_char_at, handle_copy, handle_cut, handle_paste}; 18 use super::offset_map::SnapDirection; 19 use super::paragraph::ParagraphRender; 20 use super::platform; 21 + #[allow(unused_imports)] 22 use super::publish::{LoadedEntry, PublishButton, load_entry_for_editing}; 23 use super::render; 24 use super::storage; 25 use super::sync::{SyncStatus, load_and_merge_document}; 26 use super::toolbar::EditorToolbar; 27 use super::visibility::update_syntax_visibility; 28 + #[allow(unused_imports)] 29 use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 30 use crate::auth::AuthState; 31 use crate::components::collab::CollaboratorAvatars; 32 use crate::components::editor::ReportButton; 33 use crate::components::editor::collab::CollabCoordinator; 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; 46 47 /// Result of loading document state. 48 enum LoadResult { ··· 51 /// Loading failed 52 Failed(String), 53 /// Still loading 54 + #[allow(dead_code)] 55 Loading, 56 } 57 ··· 417 let fetcher = use_context::<Fetcher>(); 418 let auth_state = use_context::<Signal<AuthState>>(); 419 420 + #[allow(unused_mut)] 421 let mut document = use_hook(|| { 422 let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); 423 ··· 465 let doc_for_memo = document.clone(); 466 let doc_for_refs = document.clone(); 467 let entry_index_for_memo = entry_index.clone(); 468 + #[allow(unused_mut)] 469 let mut paragraphs = use_memo(move || { 470 let edit = doc_for_memo.last_edit(); 471 let cache = render_cache.peek(); ··· 634 635 let mut new_tag = use_signal(String::new); 636 637 + #[allow(unused)] 638 let offset_map = use_memo(move || { 639 paragraphs() 640 .iter() ··· 647 .flat_map(|p| p.syntax_spans.iter().cloned()) 648 .collect::<Vec<_>>() 649 }); 650 + #[allow(unused_mut)] 651 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new()); 652 653 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] ··· 719 }); 720 721 // Track last saved frontiers to detect changes (peek-only, no subscriptions) 722 + #[allow(unused_mut, unused)] 723 let mut last_saved_frontiers: Signal<Option<loro::Frontiers>> = use_signal(|| None); 724 725 // Store interval handle so it's dropped when component unmounts (prevents panic) ··· 1163 // Refresh callback: fetch and merge collaborator changes (incremental) 1164 let on_refresh = if is_published { 1165 let fetcher_for_refresh = fetcher.clone(); 1166 + let doc_for_refresh = document.clone(); 1167 let entry_uri = document.entry_ref().map(|r| r.uri.clone().into_static()); 1168 1169 Some(EventHandler::new(move |_| { ··· 1408 move |evt| { 1409 tracing::debug!("onclick fired - syncing cursor from DOM"); 1410 let paras = cached_paragraphs(); 1411 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 1412 + let _ = evt; 1413 1414 // Check if click target is a math-clickable element 1415 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+18 -31
crates/weaver-app/src/components/editor/cursor.rs
··· 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 //! 4. Setting cursor with web_sys Selection API 9 10 - use super::offset_map::{OffsetMapping, SnapDirection, find_mapping_for_char, find_nearest_valid_position}; 11 12 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 13 use wasm_bindgen::JsCast; ··· 46 Some((m, false)) => (m, char_offset), // Valid position, use as-is 47 Some((m, true)) => { 48 // Position is on invisible content, snap to nearest valid 49 - if let Some(snapped) = find_nearest_valid_position(offset_map, char_offset, snap_direction) { 50 tracing::trace!( 51 target: "weaver::cursor", 52 original_offset = char_offset, ··· 190 Err("no text node found in container".into()) 191 } 192 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 /// Screen coordinates for a cursor position. 205 #[derive(Debug, Clone, Copy)] 206 pub struct CursorRect { ··· 232 let document = window.document()?; 233 234 // 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 - })?; 241 242 let range = document.create_range().ok()?; 243 ··· 288 y: cursor_rect.y - editor_rect.y(), 289 height: cursor_rect.height, 290 }) 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 } 301 302 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] ··· 378 // Set start 379 if let Some(child_index) = start_mapping.child_index { 380 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>() { 382 let offset_in_range = start - start_mapping.char_range.start; 383 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) { 385 let _ = range.set_start(&text_node, node_offset as u32); 386 } 387 } ··· 392 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 393 let offset_in_range = end - end_mapping.char_range.start; 394 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) { 396 let _ = range.set_end(&text_node, node_offset as u32); 397 } 398 }
··· 7 //! 3. Walking text nodes to find the UTF-16 offset within the element 8 //! 4. Setting cursor with web_sys Selection API 9 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}; 13 14 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 15 use wasm_bindgen::JsCast; ··· 48 Some((m, false)) => (m, char_offset), // Valid position, use as-is 49 Some((m, true)) => { 50 // Position is on invisible content, snap to nearest valid 51 + if let Some(snapped) = 52 + find_nearest_valid_position(offset_map, char_offset, snap_direction) 53 + { 54 tracing::trace!( 55 target: "weaver::cursor", 56 original_offset = char_offset, ··· 194 Err("no text node found in container".into()) 195 } 196 197 /// Screen coordinates for a cursor position. 198 #[derive(Debug, Clone, Copy)] 199 pub struct CursorRect { ··· 225 let document = window.document()?; 226 227 // Get container element 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 + })?; 232 233 let range = document.create_range().ok()?; 234 ··· 279 y: cursor_rect.y - editor_rect.y(), 280 height: cursor_rect.height, 281 }) 282 } 283 284 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] ··· 360 // Set start 361 if let Some(child_index) = start_mapping.child_index { 362 let _ = range.set_start(&start_container, child_index as u32); 363 + } else if let Ok(container_element) = start_container.clone().dyn_into::<web_sys::HtmlElement>() 364 + { 365 let offset_in_range = start - start_mapping.char_range.start; 366 let target_utf16_offset = start_mapping.char_offset_in_node + offset_in_range; 367 + if let Ok((text_node, node_offset)) = 368 + find_text_node_at_offset(&container_element, target_utf16_offset) 369 + { 370 let _ = range.set_start(&text_node, node_offset as u32); 371 } 372 } ··· 377 } else if let Ok(container_element) = end_container.dyn_into::<web_sys::HtmlElement>() { 378 let offset_in_range = end - end_mapping.char_range.start; 379 let target_utf16_offset = end_mapping.char_offset_in_node + offset_in_range; 380 + if let Ok((text_node, node_offset)) = 381 + find_text_node_at_offset(&container_element, target_utf16_offset) 382 + { 383 let _ = range.set_end(&text_node, node_offset as u32); 384 } 385 }
+49 -53
crates/weaver-app/src/components/editor/dom_sync.rs
··· 3 //! Handles syncing cursor/selection state between the browser DOM and our 4 //! internal document model, and updating paragraph DOM elements. 5 6 - use dioxus::prelude::*; 7 - 8 use super::cursor::restore_cursor_position; 9 use super::document::{EditorDocument, Selection}; 10 use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 11 use super::paragraph::ParagraphRender; 12 13 /// Sync internal cursor and selection state from browser DOM selection. 14 /// ··· 93 (Some(anchor), Some(focus)) => { 94 let old_offset = doc.cursor.read().offset; 95 // 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 }; 97 if jump > 100 { 98 tracing::warn!( 99 old_offset, ··· 179 } 180 } 181 // 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"); 183 break None; 184 } 185 ··· 432 let mut cursor_para_updated = false; 433 434 // 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(); 439 440 // Build pool of existing DOM elements by ID 441 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new(); ··· 452 453 // Track position for insertBefore - starts at first element child 454 // (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()); 457 458 // Single pass through new paragraphs 459 for new_para in new_paragraphs.iter() { ··· 499 // 500 // HOWEVER: we must verify browser actually updated the DOM. 501 // 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); 507 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 - }; 533 534 - syntax_unchanged && dom_matches_expected 535 - }; 536 537 if should_skip_cursor_update { 538 tracing::trace!( ··· 607 608 cursor_para_updated 609 } 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 - }
··· 3 //! Handles syncing cursor/selection state between the browser DOM and our 4 //! internal document model, and updating paragraph DOM elements. 5 6 + #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 7 use super::cursor::restore_cursor_position; 8 + #[allow(unused_imports)] 9 use super::document::{EditorDocument, Selection}; 10 + #[allow(unused_imports)] 11 use super::offset_map::{SnapDirection, find_nearest_valid_position, is_valid_cursor_position}; 12 use super::paragraph::ParagraphRender; 13 + #[allow(unused_imports)] 14 + use dioxus::prelude::*; 15 16 /// Sync internal cursor and selection state from browser DOM selection. 17 /// ··· 96 (Some(anchor), Some(focus)) => { 97 let old_offset = doc.cursor.read().offset; 98 // Warn if cursor is jumping a large distance - likely a bug 99 + let jump = if focus > old_offset { 100 + focus - old_offset 101 + } else { 102 + old_offset - focus 103 + }; 104 if jump > 100 { 105 tracing::warn!( 106 old_offset, ··· 186 } 187 } 188 // Couldn't find containing paragraph, fall through 189 + tracing::warn!( 190 + "dom_position_to_text_offset: walked up to editor but couldn't find containing paragraph" 191 + ); 192 break None; 193 } 194 ··· 441 let mut cursor_para_updated = false; 442 443 // Build lookup for old paragraphs by ID (for syntax span comparison) 444 + let old_para_map: HashMap<&str, &ParagraphRender> = 445 + old_paragraphs.iter().map(|p| (p.id.as_str(), p)).collect(); 446 447 // Build pool of existing DOM elements by ID 448 let mut old_elements: HashMap<String, web_sys::Element> = HashMap::new(); ··· 459 460 // Track position for insertBefore - starts at first element child 461 // (use first_element_child to skip any stray text nodes) 462 + let mut cursor_node: Option<web_sys::Node> = editor.first_element_child().map(|e| e.into()); 463 464 // Single pass through new paragraphs 465 for new_para in new_paragraphs.iter() { ··· 505 // 506 // HOWEVER: we must verify browser actually updated the DOM. 507 // PassThrough assumes browser handles edit, but sometimes it doesn't. 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); 514 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 + }; 540 541 + syntax_unchanged && dom_matches_expected 542 + }; 543 544 if should_skip_cursor_update { 545 tracing::trace!( ··· 614 615 cursor_para_updated 616 }
+1
crates/weaver-app/src/components/editor/formatting.rs
··· 1 //! Formatting actions and utilities for applying markdown formatting. 2 3 use super::document::EditorDocument; 4 use super::input::{ListContext, detect_list_context, find_line_end}; 5 use dioxus::prelude::*; 6
··· 1 //! Formatting actions and utilities for applying markdown formatting. 2 3 use super::document::EditorDocument; 4 + #[allow(unused_imports)] 5 use super::input::{ListContext, detect_list_context, find_line_end}; 6 use dioxus::prelude::*; 7
+6
crates/weaver-app/src/components/editor/input.rs
··· 10 11 /// Check if we need to intercept this key event. 12 /// Returns true for content-modifying operations, false for navigation. 13 pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool { 14 use dioxus::prelude::keyboard_types::Key; 15 ··· 42 } 43 44 /// Handle keyboard events and update document state. 45 pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) { 46 use dioxus::prelude::keyboard_types::Key; 47 ··· 329 /// Handle paste events and insert text at cursor. 330 pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 331 evt.prevent_default(); 332 333 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 334 { ··· 369 /// Handle cut events - extract text, write to clipboard, then delete. 370 pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 371 evt.prevent_default(); 372 373 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 374 {
··· 10 11 /// Check if we need to intercept this key event. 12 /// Returns true for content-modifying operations, false for navigation. 13 + #[allow(unused)] 14 pub fn should_intercept_key(evt: &Event<KeyboardData>) -> bool { 15 use dioxus::prelude::keyboard_types::Key; 16 ··· 43 } 44 45 /// Handle keyboard events and update document state. 46 + #[allow(unused)] 47 pub fn handle_keydown(evt: Event<KeyboardData>, doc: &mut EditorDocument) { 48 use dioxus::prelude::keyboard_types::Key; 49 ··· 331 /// Handle paste events and insert text at cursor. 332 pub fn handle_paste(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 333 evt.prevent_default(); 334 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 335 + let _ = doc; 336 337 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 338 { ··· 373 /// Handle cut events - extract text, write to clipboard, then delete. 374 pub fn handle_cut(evt: Event<ClipboardData>, doc: &mut EditorDocument) { 375 evt.prevent_default(); 376 + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] 377 + let _ = doc; 378 379 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))] 380 {
+1
crates/weaver-app/src/components/editor/log_buffer.rs
··· 98 } 99 100 /// Get all captured log entries as a single string. 101 pub fn get_logs() -> String { 102 LOG_BUFFER.with(|buf| { 103 let buf = buf.borrow();
··· 98 } 99 100 /// Get all captured log entries as a single string. 101 + #[allow(dead_code)] 102 pub fn get_logs() -> String { 103 LOG_BUFFER.with(|buf| { 104 let buf = buf.borrow();
+8 -1
crates/weaver-app/src/components/editor/offset_map.rs
··· 114 /// 115 /// Returns the mapping and whether the cursor should snap to the next 116 /// visible position (for invisible content). 117 pub fn find_mapping_for_char( 118 offset_map: &[OffsetMapping], 119 char_offset: usize, ··· 162 163 /// Result of finding a valid cursor position. 164 #[derive(Debug, Clone)] 165 pub struct SnappedPosition<'a> { 166 pub mapping: &'a OffsetMapping, 167 pub offset_in_mapping: usize, 168 pub snapped: Option<SnapDirection>, 169 } 170 171 impl SnappedPosition<'_> { 172 /// Get the absolute char offset for this position. 173 pub fn char_offset(&self) -> usize { ··· 181 /// If the position is already valid, returns it directly. Otherwise, 182 /// searches in the preferred direction first, falling back to the other 183 /// direction if needed. 184 pub fn find_nearest_valid_position( 185 offset_map: &[OffsetMapping], 186 char_offset: usize, ··· 219 } 220 221 /// Search for a valid position in a specific direction. 222 fn find_valid_in_direction( 223 offset_map: &[OffsetMapping], 224 char_offset: usize, ··· 275 } 276 277 /// Check if a char offset is at a valid (non-invisible) cursor position. 278 pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { 279 find_mapping_for_char(offset_map, char_offset) 280 .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) ··· 473 let mappings = make_test_mappings(); 474 475 // 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(); 477 assert_eq!(pos.char_offset(), 5); // end of "alt" mapping 478 assert_eq!(pos.snapped, Some(SnapDirection::Backward)); 479 }
··· 114 /// 115 /// Returns the mapping and whether the cursor should snap to the next 116 /// visible position (for invisible content). 117 + #[allow(dead_code)] 118 pub fn find_mapping_for_char( 119 offset_map: &[OffsetMapping], 120 char_offset: usize, ··· 163 164 /// Result of finding a valid cursor position. 165 #[derive(Debug, Clone)] 166 + #[allow(dead_code)] 167 pub struct SnappedPosition<'a> { 168 pub mapping: &'a OffsetMapping, 169 pub offset_in_mapping: usize, 170 pub snapped: Option<SnapDirection>, 171 } 172 173 + #[allow(dead_code)] 174 impl SnappedPosition<'_> { 175 /// Get the absolute char offset for this position. 176 pub fn char_offset(&self) -> usize { ··· 184 /// If the position is already valid, returns it directly. Otherwise, 185 /// searches in the preferred direction first, falling back to the other 186 /// direction if needed. 187 + #[allow(dead_code)] 188 pub fn find_nearest_valid_position( 189 offset_map: &[OffsetMapping], 190 char_offset: usize, ··· 223 } 224 225 /// Search for a valid position in a specific direction. 226 + #[allow(dead_code)] 227 fn find_valid_in_direction( 228 offset_map: &[OffsetMapping], 229 char_offset: usize, ··· 280 } 281 282 /// Check if a char offset is at a valid (non-invisible) cursor position. 283 + #[allow(dead_code)] 284 pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool { 285 find_mapping_for_char(offset_map, char_offset) 286 .map(|(m, should_snap)| !should_snap && m.utf16_len > 0) ··· 479 let mappings = make_test_mappings(); 480 481 // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5) 482 + let pos = 483 + find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap(); 484 assert_eq!(pos.char_offset(), 5); // end of "alt" mapping 485 assert_eq!(pos.snapped, Some(SnapDirection::Backward)); 486 }
+1
crates/weaver-app/src/components/editor/platform.rs
··· 6 7 /// Cached platform detection results. 8 #[derive(Debug, Clone)] 9 pub struct Platform { 10 pub ios: bool, 11 pub mac: bool,
··· 6 7 /// Cached platform detection results. 8 #[derive(Debug, Clone)] 9 + #[allow(dead_code)] 10 pub struct Platform { 11 pub ios: bool, 12 pub mac: bool,
+1
crates/weaver-app/src/components/editor/publish.rs
··· 8 use jacquard::types::collection::Collection; 9 use jacquard::types::ident::AtIdentifier; 10 use jacquard::types::recordkey::RecordKey; 11 use jacquard::types::string::{AtUri, Datetime, Nsid, Rkey}; 12 use jacquard::types::tid::Ticker; 13 use jacquard::{IntoStatic, from_data, prelude::*, to_data};
··· 8 use jacquard::types::collection::Collection; 9 use jacquard::types::ident::AtIdentifier; 10 use jacquard::types::recordkey::RecordKey; 11 + #[allow(unused_imports)] 12 use jacquard::types::string::{AtUri, Datetime, Nsid, Rkey}; 13 use jacquard::types::tid::Ticker; 14 use jacquard::{IntoStatic, from_data, prelude::*, to_data};
+3 -8
crates/weaver-app/src/components/editor/render.rs
··· 5 //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 6 7 use super::document::EditInfo; 8 use super::offset_map::{OffsetMapping, RenderResult}; 9 use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; 10 use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; 11 use loro::LoroText; 12 use markdown_weaver::Parser; 13 - use std::collections::HashMap; 14 use std::ops::Range; 15 use weaver_common::{EntryIndex, ResolvedContent}; 16 ··· 94 if gap_size > MIN_PARAGRAPH_BREAK_INCR { 95 let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; 96 let gap_end_char = para.char_range.start; 97 - let gap_start_byte = prev_end_byte + MIN_PARAGRAPH_BREAK_INCR; 98 let gap_end_byte = para.byte_range.start; 99 100 let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); ··· 724 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 725 let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 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; 728 let reused_count = reused_paragraphs.len(); 729 730 // Find which paragraph contains cursor (for stable ID assignment) ··· 740 parsed_count = parsed_paragraph_ranges.len(), 741 "ID assignment: cursor and edit info" 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 749 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 750 let para_source = text_slice_to_string(text, char_range.clone());
··· 5 //! Uses EditorWriter which tracks gaps in offset_iter to preserve formatting characters. 6 7 use super::document::EditInfo; 8 + #[allow(unused_imports)] 9 use super::offset_map::{OffsetMapping, RenderResult}; 10 use super::paragraph::{ParagraphRender, hash_source, make_paragraph_id, text_slice_to_string}; 11 + #[allow(unused_imports)] 12 use super::writer::{EditorImageResolver, EditorWriter, ImageResolver, SyntaxSpanInfo}; 13 use loro::LoroText; 14 use markdown_weaver::Parser; 15 use std::ops::Range; 16 use weaver_common::{EntryIndex, ResolvedContent}; 17 ··· 95 if gap_size > MIN_PARAGRAPH_BREAK_INCR { 96 let gap_start_char = prev_end_char + MIN_PARAGRAPH_BREAK_INCR; 97 let gap_end_char = para.char_range.start; 98 let gap_end_byte = para.byte_range.start; 99 100 let gap_node_id = format!("gap-{}-{}", gap_start_char, gap_end_char); ··· 724 let mut new_cached = Vec::with_capacity(paragraph_ranges.len()); 725 let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new(); 726 // next_para_id must account for all IDs allocated by the writer 727 + let next_para_id = parsed_para_id_start + parsed_para_count; 728 let reused_count = reused_paragraphs.len(); 729 730 // Find which paragraph contains cursor (for stable ID assignment) ··· 740 parsed_count = parsed_paragraph_ranges.len(), 741 "ID assignment: cursor and edit info" 742 ); 743 744 for (idx, (byte_range, char_range)) in paragraph_ranges.iter().enumerate() { 745 let para_source = text_slice_to_string(text, char_range.clone());
+3
crates/weaver-app/src/components/editor/report.rs
··· 5 6 use dioxus::prelude::*; 7 8 use super::log_buffer; 9 use super::storage::load_from_storage; 10 11 /// Captured report data. ··· 112 let email = props.email.clone(); 113 let submit_report = move |_| { 114 let data = report_data(); 115 let mailto_url = data.to_mailto(&email, &comment()); 116 117 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
··· 5 6 use dioxus::prelude::*; 7 8 + #[allow(unused_imports)] 9 use super::log_buffer; 10 + #[allow(unused_imports)] 11 use super::storage::load_from_storage; 12 13 /// Captured report data. ··· 114 let email = props.email.clone(); 115 let submit_report = move |_| { 116 let data = report_data(); 117 + #[allow(unused_variables)] 118 let mailto_url = data.to_mailto(&email, &comment()); 119 120 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
+4
crates/weaver-app/src/components/editor/storage.rs
··· 19 use dioxus::prelude::*; 20 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 use gloo_storage::{LocalStorage, Storage}; 22 use jacquard::IntoStatic; 23 use jacquard::smol_str::{SmolStr, ToSmolStr}; 24 use jacquard::types::string::{AtUri, Cid}; 25 use loro::cursor::Cursor; 26 use serde::{Deserialize, Serialize}; ··· 78 } 79 80 /// Build the full storage key from a draft key. 81 fn storage_key(key: &str) -> String { 82 format!("{}{}", DRAFT_KEY_PREFIX, key) 83 }
··· 19 use dioxus::prelude::*; 20 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 21 use gloo_storage::{LocalStorage, Storage}; 22 + #[allow(unused_imports)] 23 use jacquard::IntoStatic; 24 + #[allow(unused_imports)] 25 use jacquard::smol_str::{SmolStr, ToSmolStr}; 26 + #[allow(unused_imports)] 27 use jacquard::types::string::{AtUri, Cid}; 28 use loro::cursor::Cursor; 29 use serde::{Deserialize, Serialize}; ··· 81 } 82 83 /// Build the full storage key from a draft key. 84 + #[allow(dead_code)] 85 fn storage_key(key: &str) -> String { 86 format!("{}{}", DRAFT_KEY_PREFIX, key) 87 }
+2
crates/weaver-app/src/components/editor/sync.rs
··· 25 use jacquard::prelude::*; 26 use jacquard::smol_str::format_smolstr; 27 use jacquard::types::blob::MimeType; 28 use jacquard::types::collection::Collection; 29 use jacquard::types::ident::AtIdentifier; 30 use jacquard::types::recordkey::RecordKey; ··· 43 use weaver_api::sh_weaver::edit::root::Root; 44 use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; 45 use weaver_common::constellation::{GetBacklinksQuery, RecordId}; 46 use weaver_common::{WeaverError, WeaverExt}; 47 48 const ROOT_NSID: &str = "sh.weaver.edit.root";
··· 25 use jacquard::prelude::*; 26 use jacquard::smol_str::format_smolstr; 27 use jacquard::types::blob::MimeType; 28 + #[allow(unused_imports)] 29 use jacquard::types::collection::Collection; 30 use jacquard::types::ident::AtIdentifier; 31 use jacquard::types::recordkey::RecordKey; ··· 44 use weaver_api::sh_weaver::edit::root::Root; 45 use weaver_api::sh_weaver::edit::{DocRef, DocRefValue, DraftRef, EntryRef}; 46 use weaver_common::constellation::{GetBacklinksQuery, RecordId}; 47 + #[allow(unused_imports)] 48 use weaver_common::{WeaverError, WeaverExt}; 49 50 const ROOT_NSID: &str = "sh.weaver.edit.root";
+1
crates/weaver-app/src/components/editor/visibility.rs
··· 124 } 125 126 /// Check if cursor is in the same paragraph as a syntax span. 127 fn cursor_in_same_paragraph( 128 cursor_offset: usize, 129 syntax_range: &Range<usize>,
··· 124 } 125 126 /// Check if cursor is in the same paragraph as a syntax span. 127 + #[allow(dead_code)] 128 fn cursor_in_same_paragraph( 129 cursor_offset: usize, 130 syntax_range: &Range<usize>,
+351 -32
crates/weaver-app/src/components/editor/writer.rs
··· 5 //! 6 //! Uses Parser::into_offset_iter() to track gaps between events, which 7 //! represent consumed formatting characters. 8 - 9 use super::offset_map::{OffsetMapping, RenderResult}; 10 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 11 use loro::LoroText; 12 use markdown_weaver::{ 13 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 14 }; 15 use markdown_weaver_escape::{ 16 StrWrite, escape_href, escape_html, escape_html_body_text, ··· 30 segments: Vec<String>, 31 } 32 33 impl SegmentedWriter { 34 pub fn new() -> Self { 35 Self { ··· 375 } 376 } 377 378 /// HTML writer that preserves markdown formatting characters. 379 /// 380 /// This writer processes offset-iter events to detect gaps (consumed formatting) ··· 416 417 // Offset mapping tracking - current paragraph 418 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 421 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) 423 next_node_id: usize, 424 current_node_id: Option<String>, // node ID for current text container 425 current_node_char_offset: usize, // UTF-16 offset within current node ··· 450 syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>, 451 refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>, 452 453 _phantom: std::marker::PhantomData<&'a ()>, 454 } 455 ··· 519 offset_maps_by_para: Vec::new(), 520 syntax_spans_by_para: Vec::new(), 521 refs_by_para: Vec::new(), 522 _phantom: std::marker::PhantomData, 523 } 524 } ··· 578 offset_maps_by_para: self.offset_maps_by_para, 579 syntax_spans_by_para: self.syntax_spans_by_para, 580 refs_by_para: self.refs_by_para, 581 _phantom: std::marker::PhantomData, 582 } 583 } ··· 608 } 609 610 /// Get the next paragraph ID that would be assigned (for tracking allocations). 611 pub fn next_paragraph_id(&self) -> Option<usize> { 612 self.auto_increment_prefix 613 } ··· 1106 } 1107 1108 // Consume raw text events until end tag, for alt attributes 1109 fn raw_text(&mut self) -> Result<(), fmt::Error> { 1110 use Event::*; 1111 let mut nest = 0; ··· 1170 } 1171 } 1172 1173 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> { 1174 use Event::*; 1175 ··· 1546 escape_html(&mut self.writer, spaces)?; 1547 self.write("</span>")?; 1548 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 // Count this span as a child 1558 self.current_node_child_count += 1; 1559 ··· 1570 1571 // After <br>, emit plain zero-width space for cursor positioning 1572 self.write(" ")?; 1573 - //self.write("\u{200B}")?; 1574 1575 // Count the zero-width space text node as a child 1576 self.current_node_child_count += 1; ··· 1636 self.write("<div class=\"toggle-block\"><hr /></div>\n")?; 1637 } 1638 FootnoteReference(name) => { 1639 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 let number = *self.numbers.entry(name.to_string()).or_insert(len); 1644 - write!(&mut self.writer, "{}", number)?; 1645 - self.write("</a></sup>")?; 1646 } 1647 TaskListMarker(checked) => { 1648 // Emit the [ ] or [x] syntax ··· 1680 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 1681 } 1682 } 1683 - WeaverBlock(_) => {} 1684 } 1685 Ok(()) 1686 } ··· 1830 1831 Ok(()) 1832 } 1833 - Tag::Paragraph => { 1834 // Record paragraph start for boundary tracking 1835 // BUT skip if inside a list - list owns the paragraph boundary 1836 if self.list_depth == 0 { ··· 1892 classes, 1893 attrs, 1894 } => { 1895 // Record paragraph start for boundary tracking 1896 // Treat headings as paragraph-level blocks 1897 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); ··· 1980 self.in_non_writing_block = true; // Suppress content output 1981 Ok(()) 1982 } else { 1983 self.table_alignments = alignments; 1984 self.write("<table>") 1985 } ··· 2018 } 2019 } 2020 Tag::BlockQuote(kind) => { 2021 let class_str = match kind { 2022 None => "", 2023 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", ··· 2037 Ok(()) 2038 } 2039 Tag::CodeBlock(info) => { 2040 // Track code block as paragraph-level block 2041 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2042 ··· 2110 } 2111 } 2112 Tag::List(Some(1)) => { 2113 // Track list as paragraph-level block 2114 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2115 self.list_depth += 1; ··· 2120 } 2121 } 2122 Tag::List(Some(start)) => { 2123 // Track list as paragraph-level block 2124 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2125 self.list_depth += 1; ··· 2132 self.write("\">\n") 2133 } 2134 Tag::List(None) => { 2135 // Track list as paragraph-level block 2136 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2137 self.list_depth += 1; ··· 2239 Ok(()) 2240 } 2241 Tag::DefinitionList => { 2242 if self.end_newline { 2243 self.write("<dl>\n") 2244 } else { ··· 2507 id, 2508 attrs, 2509 } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 2510 - Tag::WeaverBlock(_, _) => { 2511 self.in_non_writing_block = true; 2512 Ok(()) 2513 } 2514 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=\"")?; 2519 } 2520 - escape_html(&mut self.writer, &name)?; 2521 - self.write("\"><sup class=\"footnote-definition-label\">")?; 2522 let len = self.numbers.len() + 1; 2523 let number = *self.numbers.entry(name.to_string()).or_insert(len); 2524 - write!(&mut self.writer, "{}", number)?; 2525 - self.write("</sup>") 2526 } 2527 Tag::MetadataBlock(_) => { 2528 self.in_non_writing_block = true; ··· 2566 } 2567 Ok(()) 2568 } 2569 - TagEnd::Paragraph => { 2570 // Capture paragraph boundary info BEFORE writing closing HTML 2571 // Skip if inside a list - list owns the paragraph boundary 2572 let para_boundary = if self.list_depth == 0 { ··· 2585 // Write closing HTML to current segment 2586 self.end_node(); 2587 self.write("</p>\n")?; 2588 2589 // Now finalize paragraph (starts new segment) 2590 if let Some((byte_range, char_range)) = para_boundary { ··· 2609 self.write("</")?; 2610 write!(&mut self.writer, "{}", level)?; 2611 self.write(">\n")?; 2612 2613 // Now finalize paragraph (starts new segment) 2614 if let Some((byte_range, char_range)) = para_boundary { ··· 2701 } 2702 } 2703 self.write("</blockquote>\n")?; 2704 2705 // Now finalize paragraph if we had one 2706 if let Some((byte_range, char_range)) = para_boundary { ··· 2857 }); 2858 2859 self.write("</ol>\n")?; 2860 2861 // Finalize paragraph after closing HTML 2862 if let Some((byte_range, char_range)) = para_boundary { ··· 2878 }); 2879 2880 self.write("</ul>\n")?; 2881 2882 // Finalize paragraph after closing HTML 2883 if let Some((byte_range, char_range)) = para_boundary { ··· 2889 self.end_node(); 2890 self.write("</li>\n") 2891 } 2892 - TagEnd::DefinitionList => self.write("</dl>\n"), 2893 TagEnd::DefinitionListTitle => { 2894 self.end_node(); 2895 self.write("</dt>\n") ··· 2956 TagEnd::Embed => Ok(()), 2957 TagEnd::WeaverBlock(_) => { 2958 self.in_non_writing_block = false; 2959 Ok(()) 2960 } 2961 - TagEnd::FootnoteDefinition => self.write("</div>\n"), 2962 TagEnd::MetadataBlock(_) => { 2963 self.in_non_writing_block = false; 2964 Ok(())
··· 5 //! 6 //! Uses Parser::into_offset_iter() to track gaps between events, which 7 //! represent consumed formatting characters. 8 + #[allow(unused_imports)] 9 use super::offset_map::{OffsetMapping, RenderResult}; 10 use jacquard::types::{ident::AtIdentifier, string::Rkey}; 11 use loro::LoroText; 12 use markdown_weaver::{ 13 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 14 + WeaverAttributes, 15 }; 16 use markdown_weaver_escape::{ 17 StrWrite, escape_href, escape_html, escape_html_body_text, ··· 31 segments: Vec<String>, 32 } 33 34 + #[allow(dead_code)] 35 impl SegmentedWriter { 36 pub fn new() -> Self { 37 Self { ··· 377 } 378 } 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 + 387 /// HTML writer that preserves markdown formatting characters. 388 /// 389 /// This writer processes offset-iter events to detect gaps (consumed formatting) ··· 425 426 // Offset mapping tracking - current paragraph 427 offset_maps: Vec<OffsetMapping>, 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 430 static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index 431 + current_paragraph_index: usize, // which paragraph we're currently building (0-indexed) 432 next_node_id: usize, 433 current_node_id: Option<String>, // node ID for current text container 434 current_node_char_offset: usize, // UTF-16 offset within current node ··· 459 syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>, 460 refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>, 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 + 478 _phantom: std::marker::PhantomData<&'a ()>, 479 } 480 ··· 544 offset_maps_by_para: Vec::new(), 545 syntax_spans_by_para: Vec::new(), 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, 553 _phantom: std::marker::PhantomData, 554 } 555 } ··· 609 offset_maps_by_para: self.offset_maps_by_para, 610 syntax_spans_by_para: self.syntax_spans_by_para, 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, 618 _phantom: std::marker::PhantomData, 619 } 620 } ··· 645 } 646 647 /// Get the next paragraph ID that would be assigned (for tracking allocations). 648 + #[allow(dead_code)] 649 pub fn next_paragraph_id(&self) -> Option<usize> { 650 self.auto_increment_prefix 651 } ··· 1144 } 1145 1146 // Consume raw text events until end tag, for alt attributes 1147 + #[allow(dead_code)] 1148 fn raw_text(&mut self) -> Result<(), fmt::Error> { 1149 use Event::*; 1150 let mut nest = 0; ··· 1209 } 1210 } 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 + 1310 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> { 1311 use Event::*; 1312 ··· 1683 escape_html(&mut self.writer, spaces)?; 1684 self.write("</span>")?; 1685 1686 // Count this span as a child 1687 self.current_node_child_count += 1; 1688 ··· 1699 1700 // After <br>, emit plain zero-width space for cursor positioning 1701 self.write(" ")?; 1702 1703 // Count the zero-width space text node as a child 1704 self.current_node_child_count += 1; ··· 1764 self.write("<div class=\"toggle-block\"><hr /></div>\n")?; 1765 } 1766 FootnoteReference(name) => { 1767 + // Get/create footnote number 1768 let len = self.numbers.len() + 1; 1769 let number = *self.numbers.entry(name.to_string()).or_insert(len); 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; 1813 } 1814 TaskListMarker(checked) => { 1815 // Emit the [ ] or [x] syntax ··· 1847 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 1848 } 1849 } 1850 + WeaverBlock(text) => { 1851 + // Buffer WeaverBlock content for parsing on End 1852 + self.weaver_block_buffer.push_str(&text); 1853 + } 1854 } 1855 Ok(()) 1856 } ··· 2000 2001 Ok(()) 2002 } 2003 + Tag::Paragraph(_) => { 2004 + // Handle wrapper before block 2005 + self.emit_wrapper_start()?; 2006 + 2007 // Record paragraph start for boundary tracking 2008 // BUT skip if inside a list - list owns the paragraph boundary 2009 if self.list_depth == 0 { ··· 2065 classes, 2066 attrs, 2067 } => { 2068 + // Emit wrapper if pending (but don't close on heading end - wraps following block too) 2069 + self.emit_wrapper_start()?; 2070 + 2071 // Record paragraph start for boundary tracking 2072 // Treat headings as paragraph-level blocks 2073 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); ··· 2156 self.in_non_writing_block = true; // Suppress content output 2157 Ok(()) 2158 } else { 2159 + self.emit_wrapper_start()?; 2160 self.table_alignments = alignments; 2161 self.write("<table>") 2162 } ··· 2195 } 2196 } 2197 Tag::BlockQuote(kind) => { 2198 + self.emit_wrapper_start()?; 2199 + 2200 let class_str = match kind { 2201 None => "", 2202 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", ··· 2216 Ok(()) 2217 } 2218 Tag::CodeBlock(info) => { 2219 + self.emit_wrapper_start()?; 2220 + 2221 // Track code block as paragraph-level block 2222 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2223 ··· 2291 } 2292 } 2293 Tag::List(Some(1)) => { 2294 + self.emit_wrapper_start()?; 2295 // Track list as paragraph-level block 2296 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2297 self.list_depth += 1; ··· 2302 } 2303 } 2304 Tag::List(Some(start)) => { 2305 + self.emit_wrapper_start()?; 2306 // Track list as paragraph-level block 2307 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2308 self.list_depth += 1; ··· 2315 self.write("\">\n") 2316 } 2317 Tag::List(None) => { 2318 + self.emit_wrapper_start()?; 2319 // Track list as paragraph-level block 2320 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2321 self.list_depth += 1; ··· 2423 Ok(()) 2424 } 2425 Tag::DefinitionList => { 2426 + self.emit_wrapper_start()?; 2427 if self.end_newline { 2428 self.write("<dl>\n") 2429 } else { ··· 2692 id, 2693 attrs, 2694 } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 2695 + Tag::WeaverBlock(_, attrs) => { 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 + } 2703 Ok(()) 2704 } 2705 Tag::FootnoteDefinition(name) => { 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")?; 2716 } 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 2753 let len = self.numbers.len() + 1; 2754 let number = *self.numbers.entry(name.to_string()).or_insert(len); 2755 + write!( 2756 + &mut self.writer, 2757 + "<sup class=\"footnote-definition-label\">{}</sup>", 2758 + number 2759 + )?; 2760 + 2761 + Ok(()) 2762 } 2763 Tag::MetadataBlock(_) => { 2764 self.in_non_writing_block = true; ··· 2802 } 2803 Ok(()) 2804 } 2805 + TagEnd::Paragraph(_) => { 2806 // Capture paragraph boundary info BEFORE writing closing HTML 2807 // Skip if inside a list - list owns the paragraph boundary 2808 let para_boundary = if self.list_depth == 0 { ··· 2821 // Write closing HTML to current segment 2822 self.end_node(); 2823 self.write("</p>\n")?; 2824 + self.close_wrapper()?; 2825 2826 // Now finalize paragraph (starts new segment) 2827 if let Some((byte_range, char_range)) = para_boundary { ··· 2846 self.write("</")?; 2847 write!(&mut self.writer, "{}", level)?; 2848 self.write(">\n")?; 2849 + // Note: Don't close wrapper here - headings typically go with following block 2850 2851 // Now finalize paragraph (starts new segment) 2852 if let Some((byte_range, char_range)) = para_boundary { ··· 2939 } 2940 } 2941 self.write("</blockquote>\n")?; 2942 + self.close_wrapper()?; 2943 2944 // Now finalize paragraph if we had one 2945 if let Some((byte_range, char_range)) = para_boundary { ··· 3096 }); 3097 3098 self.write("</ol>\n")?; 3099 + self.close_wrapper()?; 3100 3101 // Finalize paragraph after closing HTML 3102 if let Some((byte_range, char_range)) = para_boundary { ··· 3118 }); 3119 3120 self.write("</ul>\n")?; 3121 + self.close_wrapper()?; 3122 3123 // Finalize paragraph after closing HTML 3124 if let Some((byte_range, char_range)) = para_boundary { ··· 3130 self.end_node(); 3131 self.write("</li>\n") 3132 } 3133 + TagEnd::DefinitionList => { 3134 + self.write("</dl>\n")?; 3135 + self.close_wrapper() 3136 + } 3137 TagEnd::DefinitionListTitle => { 3138 self.end_node(); 3139 self.write("</dt>\n") ··· 3200 TagEnd::Embed => Ok(()), 3201 TagEnd::WeaverBlock(_) => { 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 + 3250 Ok(()) 3251 } 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 + } 3281 TagEnd::MetadataBlock(_) => { 3282 self.in_non_writing_block = false; 3283 Ok(())
+49 -20
crates/weaver-app/src/components/entry.rs
··· 212 213 tracing::info!("Entry: {book_title} - {title}"); 214 215 rsx! { 216 EntryOgMeta { 217 title: title.to_string(), ··· 223 } 224 document::Link { rel: "stylesheet", href: ENTRY_CSS } 225 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", 230 NavButton { 231 direction: "prev", 232 entry: prev.entry.clone(), ··· 234 book_title: book_title() 235 } 236 } 237 - } 238 239 - // Main content area 240 - div { class: "entry-content-main notebook-content", 241 - // Metadata header 242 { 243 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content); 244 rsx! { ··· 254 } 255 } 256 257 - // Rendered markdown 258 - EntryMarkdown { 259 - content: entry_record, 260 - ident 261 } 262 } 263 264 - // Right gutter with next button 265 - if let Some(ref next) = book_entry_view().next { 266 - div { class: "nav-gutter nav-next", 267 NavButton { 268 direction: "next", 269 entry: next.entry.clone(), ··· 648 } 649 } 650 651 - /// Navigation button for prev/next entries 652 #[component] 653 pub fn NavButton( 654 direction: &'static str, ··· 662 .map(|t| t.as_ref()) 663 .unwrap_or("Untitled"); 664 665 - // Get path from view for URL, fallback to title 666 let entry_path = entry 667 .path 668 .as_ref() 669 .map(|p| p.as_ref().to_string()) 670 .unwrap_or_else(|| entry_title.to_string()); 671 672 - let arrow = if direction == "prev" { "←" } else { "→" }; 673 674 rsx! { 675 Link { ··· 679 title: entry_path.into() 680 }, 681 class: "nav-button nav-button-{direction}", 682 - div { class: "nav-arrow", "{arrow}" } 683 - div { class: "nav-title", "{entry_title}" } 684 } 685 } 686 }
··· 212 213 tracing::info!("Entry: {book_title} - {title}"); 214 215 + let prev_entry = book_entry_view().prev.clone(); 216 + let next_entry = book_entry_view().next.clone(); 217 + 218 rsx! { 219 EntryOgMeta { 220 title: title.to_string(), ··· 226 } 227 document::Link { rel: "stylesheet", href: ENTRY_CSS } 228 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 { 233 NavButton { 234 direction: "prev", 235 entry: prev.entry.clone(), ··· 237 book_title: book_title() 238 } 239 } 240 241 { 242 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content); 243 rsx! { ··· 253 } 254 } 255 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 + } 273 } 274 } 275 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 { 288 NavButton { 289 direction: "next", 290 entry: next.entry.clone(), ··· 669 } 670 } 671 672 + /// Navigation link for prev/next entries (minimal: arrow + title) 673 #[component] 674 pub fn NavButton( 675 direction: &'static str, ··· 683 .map(|t| t.as_ref()) 684 .unwrap_or("Untitled"); 685 686 let entry_path = entry 687 .path 688 .as_ref() 689 .map(|p| p.as_ref().to_string()) 690 .unwrap_or_else(|| entry_title.to_string()); 691 692 + let (arrow, title_first) = if direction == "prev" { 693 + ("←", false) 694 + } else { 695 + ("→", true) 696 + }; 697 698 rsx! { 699 Link { ··· 703 title: entry_path.into() 704 }, 705 class: "nav-button nav-button-{direction}", 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 + } 713 } 714 } 715 }
+3
crates/weaver-renderer/src/atproto.rs
··· 22 pub use preprocess::AtProtoPreprocessContext; 23 pub use types::{BlobInfo, BlobName}; 24 pub use writer::{ClientWriter, EmbedContentProvider};
··· 22 pub use preprocess::AtProtoPreprocessContext; 23 pub use types::{BlobInfo, BlobName}; 24 pub use writer::{ClientWriter, EmbedContentProvider}; 25 + 26 + #[cfg(test)] 27 + mod tests;
+5 -5
crates/weaver-renderer/src/atproto/markdown_writer.rs
··· 48 49 fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 50 match tag { 51 - Tag::Paragraph => Ok(()), 52 Tag::Heading { level, .. } => { 53 write!(self.writer, "{} ", "#".repeat(level as usize)) 54 } ··· 109 110 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 111 match tag { 112 - TagEnd::Paragraph => write!(self.writer, "\n\n"), 113 TagEnd::Heading(_) => write!(self.writer, "\n\n"), 114 TagEnd::BlockQuote(_) => write!(self.writer, "\n\n"), 115 TagEnd::CodeBlock => write!(self.writer, "```\n\n"), ··· 158 #[cfg(test)] 159 mod tests { 160 use super::*; 161 - use markdown_weaver::{Event, Tag, CowStr}; 162 use markdown_weaver_escape::FmtWriter; 163 164 #[test] ··· 166 let mut output = String::new(); 167 let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 168 169 - writer.write_event(Event::Start(Tag::Paragraph)).unwrap(); 170 writer.write_event(Event::Text(CowStr::Borrowed("Hello"))).unwrap(); 171 - writer.write_event(Event::End(markdown_weaver::TagEnd::Paragraph)).unwrap(); 172 173 assert_eq!(output, "Hello\n\n"); 174 }
··· 48 49 fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 50 match tag { 51 + Tag::Paragraph(_) => Ok(()), 52 Tag::Heading { level, .. } => { 53 write!(self.writer, "{} ", "#".repeat(level as usize)) 54 } ··· 109 110 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 111 match tag { 112 + TagEnd::Paragraph(_) => write!(self.writer, "\n\n"), 113 TagEnd::Heading(_) => write!(self.writer, "\n\n"), 114 TagEnd::BlockQuote(_) => write!(self.writer, "\n\n"), 115 TagEnd::CodeBlock => write!(self.writer, "```\n\n"), ··· 158 #[cfg(test)] 159 mod tests { 160 use super::*; 161 + use markdown_weaver::{Event, Tag, CowStr, ParagraphContext}; 162 use markdown_weaver_escape::FmtWriter; 163 164 #[test] ··· 166 let mut output = String::new(); 167 let mut writer = MarkdownWriter::new(FmtWriter(&mut output)); 168 169 + writer.write_event(Event::Start(Tag::Paragraph(ParagraphContext::Complete))).unwrap(); 170 writer.write_event(Event::Text(CowStr::Borrowed("Hello"))).unwrap(); 171 + writer.write_event(Event::End(markdown_weaver::TagEnd::Paragraph(ParagraphContext::Complete))).unwrap(); 172 173 assert_eq!(output, "Hello\n\n"); 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 6 use jacquard::types::string::AtUri; 7 use markdown_weaver::{ 8 - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 9 }; 10 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 11 use std::collections::HashMap; 12 use weaver_common::ResolvedContent; 13 14 /// Synchronous callback for injecting embed content 15 /// ··· 69 embed_provider: Option<E>, 70 71 code_buffer: Option<(Option<String>, String)>, // (lang, content) 72 _phantom: std::marker::PhantomData<&'a ()>, 73 } 74 ··· 95 numbers: self.numbers, 96 embed_provider: Some(provider), 97 code_buffer: self.code_buffer, 98 _phantom: std::marker::PhantomData, 99 } 100 } ··· 115 numbers: HashMap::new(), 116 embed_provider: None, 117 code_buffer: None, 118 _phantom: std::marker::PhantomData, 119 } 120 } 121 122 #[inline] 123 fn write_newline(&mut self) -> Result<(), W::Error> { 124 self.end_newline = true; ··· 139 while let Some(event) = self.events.next() { 140 self.process_event(event)?; 141 } 142 Ok(self.writer) 143 } 144 145 /// Consume events until End tag without writing anything. ··· 216 // If buffering code, append to buffer instead of writing 217 if let Some((_, ref mut buffer)) = self.code_buffer { 218 buffer.push_str(&text); 219 } else if !self.in_non_writing_block { 220 escape_html_body_text(&mut self.writer, &text)?; 221 self.end_newline = text.ends_with('\n'); ··· 254 self.write(&html)?; 255 self.write("</span>")?; 256 } 257 - SoftBreak => self.write_newline()?, 258 - HardBreak => self.write("<br />\n")?, 259 Rule => { 260 if self.end_newline { 261 self.write("<hr />\n")?; ··· 264 } 265 } 266 FootnoteReference(name) => { 267 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 let number = *self.numbers.entry(name.to_string()).or_insert(len); 272 - write!(&mut self.writer, "{}", number)?; 273 - self.write("</a></sup>")?; 274 } 275 TaskListMarker(checked) => { 276 if checked { ··· 279 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 280 } 281 } 282 - WeaverBlock(_) => {} 283 } 284 Ok(()) 285 } ··· 287 fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 288 match tag { 289 Tag::HtmlBlock => self.write(r#"<span class="html-embed html-embed-block">"#), 290 - Tag::Paragraph => { 291 - if self.end_newline { 292 - self.write("<p>") 293 } else { 294 - self.write("\n<p>") 295 } 296 } 297 Tag::Heading { ··· 300 classes, 301 attrs, 302 } => { 303 if !self.end_newline { 304 self.write("\n")?; 305 } ··· 334 self.write(">") 335 } 336 Tag::Table(alignments) => { 337 self.table_alignments = alignments; 338 self.write("<table>") 339 } ··· 359 } 360 } 361 Tag::BlockQuote(kind) => { 362 let class_str = match kind { 363 None => "", 364 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", ··· 375 Ok(()) 376 } 377 Tag::CodeBlock(info) => { 378 if !self.end_newline { 379 self.write_newline()?; 380 } ··· 398 } 399 } 400 Tag::List(Some(1)) => { 401 if self.end_newline { 402 self.write("<ol>\n") 403 } else { ··· 405 } 406 } 407 Tag::List(Some(start)) => { 408 if self.end_newline { 409 self.write("<ol start=\"")?; 410 } else { ··· 414 self.write("\">\n") 415 } 416 Tag::List(None) => { 417 if self.end_newline { 418 self.write("<ul>\n") 419 } else { ··· 428 } 429 } 430 Tag::DefinitionList => { 431 if self.end_newline { 432 self.write("<dl>\n") 433 } else { ··· 556 id, 557 attrs, 558 } => self.write_embed(embed_type, dest_url, title, id, attrs), 559 - Tag::WeaverBlock(_, _) => { 560 self.in_non_writing_block = true; 561 Ok(()) 562 } 563 Tag::FootnoteDefinition(name) => { 564 - if self.end_newline { 565 - self.write("<div class=\"footnote-definition\" id=\"")?; 566 } else { 567 - self.write("\n<div class=\"footnote-definition\" id=\"")?; 568 } 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>") 575 } 576 Tag::MetadataBlock(_) => { 577 self.in_non_writing_block = true; ··· 584 use markdown_weaver::TagEnd; 585 match tag { 586 TagEnd::HtmlBlock => self.write("</span>\n"), 587 - TagEnd::Paragraph => self.write("</p>\n"), 588 TagEnd::Heading(level) => { 589 self.write("</")?; 590 write!(&mut self.writer, "{}", level)?; 591 self.write(">\n") 592 } 593 TagEnd::Table => self.write("</tbody></table>\n"), ··· 605 self.table_cell_index += 1; 606 Ok(()) 607 } 608 - TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 609 TagEnd::CodeBlock => { 610 use std::sync::LazyLock; 611 use syntect::parsing::SyntaxSet; ··· 644 } 645 Ok(()) 646 } 647 - TagEnd::List(true) => self.write("</ol>\n"), 648 - TagEnd::List(false) => self.write("</ul>\n"), 649 TagEnd::Item => self.write("</li>\n"), 650 - TagEnd::DefinitionList => self.write("</dl>\n"), 651 TagEnd::DefinitionListTitle => self.write("</dt>\n"), 652 TagEnd::DefinitionListDefinition => self.write("</dd>\n"), 653 TagEnd::Emphasis => self.write("</em>"), ··· 660 TagEnd::Embed => Ok(()), 661 TagEnd::WeaverBlock(_) => { 662 self.in_non_writing_block = false; 663 Ok(()) 664 } 665 - TagEnd::FootnoteDefinition => self.write("</div>\n"), 666 TagEnd::MetadataBlock(_) => { 667 self.in_non_writing_block = false; 668 Ok(())
··· 5 6 use jacquard::types::string::AtUri; 7 use markdown_weaver::{ 8 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, 9 + ParagraphContext, Tag, WeaverAttributes, 10 }; 11 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 12 use std::collections::HashMap; 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 + } 21 22 /// Synchronous callback for injecting embed content 23 /// ··· 77 embed_provider: Option<E>, 78 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 + 96 _phantom: std::marker::PhantomData<&'a ()>, 97 } 98 ··· 119 numbers: self.numbers, 120 embed_provider: Some(provider), 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, 129 _phantom: std::marker::PhantomData, 130 } 131 } ··· 146 numbers: HashMap::new(), 147 embed_provider: None, 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, 156 _phantom: std::marker::PhantomData, 157 } 158 } 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 + 285 #[inline] 286 fn write_newline(&mut self) -> Result<(), W::Error> { 287 self.end_newline = true; ··· 302 while let Some(event) = self.events.next() { 303 self.process_event(event)?; 304 } 305 + self.finalize()?; 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(()) 320 } 321 322 /// Consume events until End tag without writing anything. ··· 393 // If buffering code, append to buffer instead of writing 394 if let Some((_, ref mut buffer)) = self.code_buffer { 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); 399 } else if !self.in_non_writing_block { 400 escape_html_body_text(&mut self.writer, &text)?; 401 self.end_newline = text.ends_with('\n'); ··· 434 self.write(&html)?; 435 self.write("</span>")?; 436 } 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 + } 451 Rule => { 452 if self.end_newline { 453 self.write("<hr />\n")?; ··· 456 } 457 } 458 FootnoteReference(name) => { 459 + // Flush any existing pending footnote as traditional 460 + self.flush_pending_footnote()?; 461 + // Get/create footnote number 462 let len = self.numbers.len() + 1; 463 let number = *self.numbers.entry(name.to_string()).or_insert(len); 464 + // Buffer this reference to see if definition follows immediately 465 + self.pending_footnote = Some((name.to_string(), number)); 466 } 467 TaskListMarker(checked) => { 468 if checked { ··· 471 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 472 } 473 } 474 + WeaverBlock(text) => { 475 + // Buffer WeaverBlock content for parsing on End 476 + self.weaver_block_buffer.push_str(&text); 477 + } 478 } 479 Ok(()) 480 } ··· 482 fn start_tag(&mut self, tag: Tag<'_>) -> Result<(), W::Error> { 483 match tag { 484 Tag::HtmlBlock => self.write(r#"<span class="html-embed html-embed-block">"#), 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(()) 495 } else { 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 + } 503 } 504 } 505 Tag::Heading { ··· 508 classes, 509 attrs, 510 } => { 511 + self.close_deferred_paragraph()?; 512 + self.emit_wrapper_start()?; 513 if !self.end_newline { 514 self.write("\n")?; 515 } ··· 544 self.write(">") 545 } 546 Tag::Table(alignments) => { 547 + self.close_deferred_paragraph()?; 548 + self.emit_wrapper_start()?; 549 self.table_alignments = alignments; 550 self.write("<table>") 551 } ··· 571 } 572 } 573 Tag::BlockQuote(kind) => { 574 + self.close_deferred_paragraph()?; 575 + self.emit_wrapper_start()?; 576 let class_str = match kind { 577 None => "", 578 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", ··· 589 Ok(()) 590 } 591 Tag::CodeBlock(info) => { 592 + self.close_deferred_paragraph()?; 593 + self.emit_wrapper_start()?; 594 if !self.end_newline { 595 self.write_newline()?; 596 } ··· 614 } 615 } 616 Tag::List(Some(1)) => { 617 + self.close_deferred_paragraph()?; 618 + self.emit_wrapper_start()?; 619 if self.end_newline { 620 self.write("<ol>\n") 621 } else { ··· 623 } 624 } 625 Tag::List(Some(start)) => { 626 + self.close_deferred_paragraph()?; 627 + self.emit_wrapper_start()?; 628 if self.end_newline { 629 self.write("<ol start=\"")?; 630 } else { ··· 634 self.write("\">\n") 635 } 636 Tag::List(None) => { 637 + self.close_deferred_paragraph()?; 638 + self.emit_wrapper_start()?; 639 if self.end_newline { 640 self.write("<ul>\n") 641 } else { ··· 650 } 651 } 652 Tag::DefinitionList => { 653 + self.close_deferred_paragraph()?; 654 + self.emit_wrapper_start()?; 655 if self.end_newline { 656 self.write("<dl>\n") 657 } else { ··· 780 id, 781 attrs, 782 } => self.write_embed(embed_type, dest_url, title, id, attrs), 783 + Tag::WeaverBlock(_, attrs) => { 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 + } 790 Ok(()) 791 } 792 Tag::FootnoteDefinition(name) => { 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; 815 } else { 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>")?; 830 } 831 + Ok(()) 832 } 833 Tag::MetadataBlock(_) => { 834 self.in_non_writing_block = true; ··· 841 use markdown_weaver::TagEnd; 842 match tag { 843 TagEnd::HtmlBlock => self.write("</span>\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 + } 864 TagEnd::Heading(level) => { 865 self.write("</")?; 866 write!(&mut self.writer, "{}", level)?; 867 + // Don't close wrapper - headings typically go with following block 868 self.write(">\n") 869 } 870 TagEnd::Table => self.write("</tbody></table>\n"), ··· 882 self.table_cell_index += 1; 883 Ok(()) 884 } 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 + } 892 TagEnd::CodeBlock => { 893 use std::sync::LazyLock; 894 use syntect::parsing::SyntaxSet; ··· 927 } 928 Ok(()) 929 } 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 + } 938 TagEnd::Item => self.write("</li>\n"), 939 + TagEnd::DefinitionList => { 940 + self.write("</dl>\n")?; 941 + self.close_wrapper() 942 + } 943 TagEnd::DefinitionListTitle => self.write("</dt>\n"), 944 TagEnd::DefinitionListDefinition => self.write("</dd>\n"), 945 TagEnd::Emphasis => self.write("</em>"), ··· 952 TagEnd::Embed => Ok(()), 953 TagEnd::WeaverBlock(_) => { 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 + } 967 Ok(()) 968 } 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 + } 984 TagEnd::MetadataBlock(_) => { 985 self.in_non_writing_block = false; 986 Ok(())
+20 -24
crates/weaver-renderer/src/base_html.rs
··· 96 escape_html_body_text(&mut self.writer, &text)?; 97 self.write("</code>")?; 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 - } 109 } 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 - } 121 } 122 - } 123 Html(html) | InlineHtml(html) => { 124 self.write(&html)?; 125 } ··· 161 fn start_tag(&mut self, tag: Tag<'a>) -> Result<(), W::Error> { 162 match tag { 163 Tag::HtmlBlock => Ok(()), 164 - Tag::Paragraph => { 165 if self.end_newline { 166 self.write("<p>") 167 } else { ··· 461 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 462 match tag { 463 TagEnd::HtmlBlock => {} 464 - TagEnd::Paragraph => { 465 self.write("</p>\n")?; 466 } 467 TagEnd::Heading(level) => {
··· 96 escape_html_body_text(&mut self.writer, &text)?; 97 self.write("</code>")?; 98 } 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>")?; 104 } 105 + crate::math::MathResult::Error { html, .. } => { 106 + self.write(&html)?; 107 } 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 + }, 119 Html(html) | InlineHtml(html) => { 120 self.write(&html)?; 121 } ··· 157 fn start_tag(&mut self, tag: Tag<'a>) -> Result<(), W::Error> { 158 match tag { 159 Tag::HtmlBlock => Ok(()), 160 + Tag::Paragraph(_) => { 161 if self.end_newline { 162 self.write("<p>") 163 } else { ··· 457 fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> { 458 match tag { 459 TagEnd::HtmlBlock => {} 460 + TagEnd::Paragraph(_) => { 461 self.write("</p>\n")?; 462 } 463 TagEnd::Heading(level) => {
+138 -3
crates/weaver-renderer/src/css.rs
··· 125 padding: 1rem 0rem; 126 word-wrap: break-word; 127 overflow-wrap: break-word; 128 }} 129 130 /* Typography */ ··· 286 }} 287 288 .footnote-definition {{ 289 margin-top: 2rem; 290 - padding-top: 0.5rem; 291 - border-top: 1px solid var(--color-border); 292 - font-size: 0.9em; 293 }} 294 295 .footnote-definition-label {{ 296 font-weight: 600; 297 margin-right: 0.5rem; 298 color: var(--color-primary); 299 }} 300 301 /* Images */
··· 125 padding: 1rem 0rem; 126 word-wrap: break-word; 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; 137 }} 138 139 /* Typography */ ··· 295 }} 296 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 {{ 305 margin-top: 2rem; 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; 317 }} 318 319 .footnote-definition-label {{ 320 font-weight: 600; 321 margin-right: 0.5rem; 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 + }} 434 }} 435 436 /* Images */
+1 -1
crates/weaver-renderer/src/lib.rs
··· 365 pub fn get_context<'a>(event: &Event<'a>, prev: Option<&Self>) -> Self { 366 match event { 367 Event::Start(tag) => match tag { 368 - Tag::Paragraph => Self::Text, 369 Tag::Heading { .. } => Self::Heading, 370 Tag::BlockQuote(_block_quote_kind) => Self::Text, 371 Tag::CodeBlock(_code_block_kind) => Self::CodeBlock,
··· 365 pub fn get_context<'a>(event: &Event<'a>, prev: Option<&Self>) -> Self { 366 match event { 367 Event::Start(tag) => match tag { 368 + Tag::Paragraph(_) => Self::Text, 369 Tag::Heading { .. } => Self::Heading, 370 Tag::BlockQuote(_block_quote_kind) => Self::Text, 371 Tag::CodeBlock(_code_block_kind) => Self::CodeBlock,
+4 -1
crates/weaver-renderer/src/static_site/document.rs
··· 154 } 155 156 writer.write_all(b"</head>\n").await.into_diagnostic()?; 157 - writer.write_all(b"<body>\n").await.into_diagnostic()?; 158 writer 159 .write_all(b"<div class=\"notebook-content\">\n") 160 .await
··· 154 } 155 156 writer.write_all(b"</head>\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()?; 161 writer 162 .write_all(b"<div class=\"notebook-content\">\n") 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 assert!(output.contains("🎉")); 211 assert!(output.contains("café")); 212 }
··· 210 assert!(output.contains("🎉")); 211 assert!(output.contains("café")); 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 use crate::{NotebookProcessor, base_html::TableState, static_site::context::StaticSiteContext}; 2 use dashmap::DashMap; 3 use markdown_weaver::{ 4 - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 5 }; 6 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 7 use n0_future::StreamExt; 8 use weaver_common::jacquard::{client::AgentSession, prelude::*}; 9 10 pub struct StaticPageWriter<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> 11 { ··· 23 numbers: DashMap<CowStr<'input>, usize>, 24 25 code_buffer: Option<(Option<String>, String)>, // (lang, content) 26 } 27 28 impl<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> ··· 39 table_cell_index: 0, 40 numbers: DashMap::new(), 41 code_buffer: None, 42 } 43 } 44 45 /// Writes a new line. 46 #[inline] 47 fn write_newline(&mut self) -> Result<(), W::Error> { ··· 60 Ok(()) 61 } 62 63 fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> { 64 use markdown_weaver::TagEnd; 65 match tag { 66 TagEnd::HtmlBlock => {} 67 - TagEnd::Paragraph => { 68 - self.write("</p>\n")?; 69 } 70 TagEnd::Heading(level) => { 71 self.write("</")?; 72 write!(&mut self.writer, "{}", level)?; 73 self.write(">\n")?; 74 } 75 TagEnd::Table => { 76 self.write("</tbody></table>\n")?; 77 } 78 TagEnd::TableHead => { 79 self.write("</tr></thead><tbody>\n")?; ··· 94 self.table_cell_index += 1; 95 } 96 TagEnd::BlockQuote(_) => { 97 self.write("</blockquote>\n")?; 98 } 99 TagEnd::CodeBlock => { 100 if let Some((lang, buffer)) = self.code_buffer.take() { ··· 127 } else { 128 self.write("</code></pre>\n")?; 129 } 130 } 131 TagEnd::List(true) => { 132 self.write("</ol>\n")?; 133 } 134 TagEnd::List(false) => { 135 self.write("</ul>\n")?; 136 } 137 TagEnd::Item => { 138 self.write("</li>\n")?; 139 } 140 TagEnd::DefinitionList => { 141 self.write("</dl>\n")?; 142 } 143 TagEnd::DefinitionListTitle => { 144 self.write("</dt>\n")?; ··· 168 TagEnd::Embed => (), // shouldn't happen, handled in start 169 TagEnd::WeaverBlock(_) => { 170 self.in_non_writing_block = false; 171 } 172 TagEnd::FootnoteDefinition => { 173 - self.write("</div>\n")?; 174 } 175 TagEnd::MetadataBlock(_) => { 176 self.in_non_writing_block = false; ··· 191 while let Some(event) = self.context.next().await { 192 self.process_event(event).await? 193 } 194 Ok(()) 195 } 196 ··· 198 use markdown_weaver::Event::*; 199 match event { 200 Start(tag) => { 201 self.start_tag(tag).await?; 202 } 203 End(tag) => { ··· 207 // If buffering code, append to buffer instead of writing 208 if let Some((_, ref mut buffer)) = self.code_buffer { 209 buffer.push_str(&text); 210 } else if !self.in_non_writing_block { 211 escape_html_body_text(&mut self.writer, &text)?; 212 self.end_newline = text.ends_with('\n'); ··· 231 self.write(&html)?; 232 } 233 SoftBreak => { 234 - self.write_newline()?; 235 } 236 HardBreak => { 237 - self.write("<br />\n")?; 238 } 239 Rule => { 240 if self.end_newline { ··· 244 } 245 } 246 FootnoteReference(name) => { 247 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>")?; 254 } 255 TaskListMarker(true) => { 256 self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; ··· 258 TaskListMarker(false) => { 259 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 260 } 261 - WeaverBlock(_text) => {} 262 } 263 Ok(()) 264 } ··· 315 async fn start_tag(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 316 match tag { 317 Tag::HtmlBlock => Ok(()), 318 - Tag::Paragraph => { 319 - if self.end_newline { 320 - self.write("<p>") 321 } else { 322 - self.write("\n<p>") 323 } 324 } 325 Tag::Heading { ··· 328 classes, 329 attrs, 330 } => { 331 if self.end_newline { 332 self.write("<")?; 333 } else { ··· 363 self.write(">") 364 } 365 Tag::Table(alignments) => { 366 self.table_alignments = alignments; 367 self.write("<table>") 368 } ··· 392 } 393 } 394 Tag::BlockQuote(kind) => { 395 let class_str = match kind { 396 None => "", 397 Some(kind) => match kind { ··· 409 } 410 } 411 Tag::CodeBlock(info) => { 412 if !self.end_newline { 413 self.write_newline()?; 414 } ··· 432 } 433 } 434 Tag::List(Some(1)) => { 435 if self.end_newline { 436 self.write("<ol>\n") 437 } else { ··· 439 } 440 } 441 Tag::List(Some(start)) => { 442 if self.end_newline { 443 self.write("<ol start=\"")?; 444 } else { ··· 448 self.write("\">\n") 449 } 450 Tag::List(None) => { 451 if self.end_newline { 452 self.write("<ul>\n") 453 } else { ··· 462 } 463 } 464 Tag::DefinitionList => { 465 if self.end_newline { 466 self.write("<dl>\n") 467 } else { ··· 632 } 633 Ok(()) 634 } 635 - Tag::WeaverBlock(_, _attrs) => { 636 - println!("Weaver block"); 637 self.in_non_writing_block = true; 638 Ok(()) 639 } 640 Tag::FootnoteDefinition(name) => { 641 - if self.end_newline { 642 - self.write("<div class=\"footnote-definition\" id=\"")?; 643 } else { 644 - self.write("\n<div class=\"footnote-definition\" id=\"")?; 645 } 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>") 652 } 653 Tag::MetadataBlock(_) => { 654 self.in_non_writing_block = true;
··· 1 use crate::{NotebookProcessor, base_html::TableState, static_site::context::StaticSiteContext}; 2 use dashmap::DashMap; 3 use markdown_weaver::{ 4 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, 5 + ParagraphContext, Tag, WeaverAttributes, 6 }; 7 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 8 use n0_future::StreamExt; 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 + } 17 18 pub struct StaticPageWriter<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> 19 { ··· 31 numbers: DashMap<CowStr<'input>, usize>, 32 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, 51 } 52 53 impl<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> ··· 64 table_cell_index: 0, 65 numbers: DashMap::new(), 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, 75 } 76 } 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 + 112 /// Writes a new line. 113 #[inline] 114 fn write_newline(&mut self) -> Result<(), W::Error> { ··· 127 Ok(()) 128 } 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 + 236 fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> { 237 use markdown_weaver::TagEnd; 238 match tag { 239 TagEnd::HtmlBlock => {} 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 + } 259 } 260 TagEnd::Heading(level) => { 261 self.write("</")?; 262 write!(&mut self.writer, "{}", level)?; 263 + self.block_depth -= 1; 264 + // Don't close wrapper - headings typically go with following block 265 self.write(">\n")?; 266 } 267 TagEnd::Table => { 268 self.write("</tbody></table>\n")?; 269 + self.block_depth -= 1; 270 + self.close_wrapper()?; 271 } 272 TagEnd::TableHead => { 273 self.write("</tr></thead><tbody>\n")?; ··· 288 self.table_cell_index += 1; 289 } 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()?; 294 self.write("</blockquote>\n")?; 295 + self.block_depth -= 1; 296 + self.close_wrapper()?; 297 } 298 TagEnd::CodeBlock => { 299 if let Some((lang, buffer)) = self.code_buffer.take() { ··· 326 } else { 327 self.write("</code></pre>\n")?; 328 } 329 + self.block_depth -= 1; 330 + self.close_wrapper()?; 331 } 332 TagEnd::List(true) => { 333 self.write("</ol>\n")?; 334 + self.block_depth -= 1; 335 + self.close_wrapper()?; 336 } 337 TagEnd::List(false) => { 338 self.write("</ul>\n")?; 339 + self.block_depth -= 1; 340 + self.close_wrapper()?; 341 } 342 TagEnd::Item => { 343 self.write("</li>\n")?; 344 } 345 TagEnd::DefinitionList => { 346 self.write("</dl>\n")?; 347 + self.block_depth -= 1; 348 + self.close_wrapper()?; 349 } 350 TagEnd::DefinitionListTitle => { 351 self.write("</dt>\n")?; ··· 375 TagEnd::Embed => (), // shouldn't happen, handled in start 376 TagEnd::WeaverBlock(_) => { 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 + } 399 } 400 TagEnd::FootnoteDefinition => { 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 + } 413 } 414 TagEnd::MetadataBlock(_) => { 415 self.in_non_writing_block = false; ··· 430 while let Some(event) = self.context.next().await { 431 self.process_event(event).await? 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 + } 447 Ok(()) 448 } 449 ··· 451 use markdown_weaver::Event::*; 452 match event { 453 Start(tag) => { 454 + println!("Start tag: {:?}", tag); 455 self.start_tag(tag).await?; 456 } 457 End(tag) => { ··· 461 // If buffering code, append to buffer instead of writing 462 if let Some((_, ref mut buffer)) = self.code_buffer { 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); 467 } else if !self.in_non_writing_block { 468 escape_html_body_text(&mut self.writer, &text)?; 469 self.end_newline = text.ends_with('\n'); ··· 488 self.write(&html)?; 489 } 490 SoftBreak => { 491 + if self.pending_footnote.is_some() { 492 + self.pending_footnote_content.push('\n'); 493 + } else { 494 + self.write_newline()?; 495 + } 496 } 497 HardBreak => { 498 + if self.pending_footnote.is_some() { 499 + self.pending_footnote_content.push_str("<br />\n"); 500 + } else { 501 + self.write("<br />\n")?; 502 + } 503 } 504 Rule => { 505 if self.end_newline { ··· 509 } 510 } 511 FootnoteReference(name) => { 512 + // Flush any existing pending footnote as traditional 513 + self.flush_pending_footnote()?; 514 + // Get/create footnote number 515 let len = self.numbers.len() + 1; 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)); 522 } 523 TaskListMarker(true) => { 524 self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; ··· 526 TaskListMarker(false) => { 527 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 528 } 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 + } 534 } 535 Ok(()) 536 } ··· 587 async fn start_tag(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 588 match tag { 589 Tag::HtmlBlock => Ok(()), 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(()) 600 } else { 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 + } 609 } 610 } 611 Tag::Heading { ··· 614 classes, 615 attrs, 616 } => { 617 + self.close_deferred_paragraph()?; 618 + self.emit_wrapper_start()?; 619 + self.block_depth += 1; 620 if self.end_newline { 621 self.write("<")?; 622 } else { ··· 652 self.write(">") 653 } 654 Tag::Table(alignments) => { 655 + self.close_deferred_paragraph()?; 656 + self.emit_wrapper_start()?; 657 + self.block_depth += 1; 658 self.table_alignments = alignments; 659 self.write("<table>") 660 } ··· 684 } 685 } 686 Tag::BlockQuote(kind) => { 687 + self.close_deferred_paragraph()?; 688 + self.emit_wrapper_start()?; 689 + self.block_depth += 1; 690 let class_str = match kind { 691 None => "", 692 Some(kind) => match kind { ··· 704 } 705 } 706 Tag::CodeBlock(info) => { 707 + self.close_deferred_paragraph()?; 708 + self.emit_wrapper_start()?; 709 + self.block_depth += 1; 710 if !self.end_newline { 711 self.write_newline()?; 712 } ··· 730 } 731 } 732 Tag::List(Some(1)) => { 733 + self.close_deferred_paragraph()?; 734 + self.emit_wrapper_start()?; 735 + self.block_depth += 1; 736 if self.end_newline { 737 self.write("<ol>\n") 738 } else { ··· 740 } 741 } 742 Tag::List(Some(start)) => { 743 + self.close_deferred_paragraph()?; 744 + self.emit_wrapper_start()?; 745 + self.block_depth += 1; 746 if self.end_newline { 747 self.write("<ol start=\"")?; 748 } else { ··· 752 self.write("\">\n") 753 } 754 Tag::List(None) => { 755 + self.close_deferred_paragraph()?; 756 + self.emit_wrapper_start()?; 757 + self.block_depth += 1; 758 if self.end_newline { 759 self.write("<ul>\n") 760 } else { ··· 769 } 770 } 771 Tag::DefinitionList => { 772 + self.close_deferred_paragraph()?; 773 + self.emit_wrapper_start()?; 774 + self.block_depth += 1; 775 if self.end_newline { 776 self.write("<dl>\n") 777 } else { ··· 942 } 943 Ok(()) 944 } 945 + Tag::WeaverBlock(_, attrs) => { 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 + } 952 Ok(()) 953 } 954 Tag::FootnoteDefinition(name) => { 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; 979 } else { 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>")?; 994 } 995 + Ok(()) 996 } 997 Tag::MetadataBlock(_) => { 998 self.in_non_writing_block = true;
+28 -7
lexicons/notebook/searchNotebooks.json
··· 9 "type": "params", 10 "required": ["q"], 11 "properties": { 12 - "q": { "type": "string", "minLength": 1, "maxLength": 500 }, 13 "author": { 14 "type": "string", 15 "format": "at-identifier", ··· 17 }, 18 "tags": { 19 "type": "array", 20 - "items": { "type": "string" }, 21 "maxLength": 10, 22 "description": "Filter by tags (all must match)." 23 }, 24 "rating": { 25 "type": "array", 26 - "items": { "type": "ref", "ref": "sh.weaver.notebook.defs#contentRating" }, 27 "description": "Filter by content rating (any of these)." 28 }, 29 "sort": { ··· 31 "knownValues": ["relevance", "recent", "popular"], 32 "default": "relevance" 33 }, 34 - "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 25 }, 35 - "cursor": { "type": "string" } 36 } 37 }, 38 "output": { ··· 43 "properties": { 44 "notebooks": { 45 "type": "array", 46 - "items": { "type": "ref", "ref": "sh.weaver.notebook.defs#notebookView" } 47 }, 48 - "cursor": { "type": "string" } 49 } 50 } 51 }
··· 9 "type": "params", 10 "required": ["q"], 11 "properties": { 12 + "q": { 13 + "type": "string", 14 + "minLength": 1, 15 + "maxLength": 500 16 + }, 17 "author": { 18 "type": "string", 19 "format": "at-identifier", ··· 21 }, 22 "tags": { 23 "type": "array", 24 + "items": { 25 + "type": "string" 26 + }, 27 "maxLength": 10, 28 "description": "Filter by tags (all must match)." 29 }, 30 "rating": { 31 "type": "array", 32 + "items": { 33 + "type": "string", 34 + "knownValues": ["general", "teen", "mature", "explicit"] 35 + }, 36 "description": "Filter by content rating (any of these)." 37 }, 38 "sort": { ··· 40 "knownValues": ["relevance", "recent", "popular"], 41 "default": "relevance" 42 }, 43 + "limit": { 44 + "type": "integer", 45 + "minimum": 1, 46 + "maximum": 100, 47 + "default": 25 48 + }, 49 + "cursor": { 50 + "type": "string" 51 + } 52 } 53 }, 54 "output": { ··· 59 "properties": { 60 "notebooks": { 61 "type": "array", 62 + "items": { 63 + "type": "ref", 64 + "ref": "sh.weaver.notebook.defs#notebookView" 65 + } 66 }, 67 + "cursor": { 68 + "type": "string" 69 + } 70 } 71 } 72 }
+18 -1
lexicons/notification/listNotifications.json
··· 10 "properties": { 11 "reasons": { 12 "type": "array", 13 - "items": { "type": "ref", "ref": "sh.weaver.notification.defs#notificationReason" }, 14 "description": "Filter by notification reasons." 15 }, 16 "seenAt": {
··· 10 "properties": { 11 "reasons": { 12 "type": "array", 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 + }, 31 "description": "Filter by notification reasons." 32 }, 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.