tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
broke up mega-writer file in editor
Orual
1 month ago
fb1645bc
d22836ce
+3573
-2226
13 changed files
expand all
collapse all
unified
split
Cargo.lock
crates
weaver-app
assets
styling
entry.css
src
components
editor
component.rs
writer
embed.rs
segmented.rs
syntax.rs
tags.rs
writer.rs
entry.rs
data.rs
weaver-index
src
endpoints
notebook.rs
weaver-renderer
src
css.rs
test-sidenotes.html
+33
-37
Cargo.lock
···
980
981
[[package]]
982
name = "cc"
983
-
version = "1.2.49"
984
source = "registry+https://github.com/rust-lang/crates.io-index"
985
-
checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
986
dependencies = [
987
"find-msvc-tools",
988
"jobserver",
···
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",
···
5568
5569
[[package]]
5570
name = "jacquard"
5571
-
version = "0.9.4"
5572
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5573
dependencies = [
5574
"bytes",
5575
"getrandom 0.2.16",
···
5585
"regex",
5586
"regex-lite",
5587
"reqwest",
5588
-
"ring",
5589
"serde",
5590
"serde_html_form",
5591
"serde_json",
···
5600
5601
[[package]]
5602
name = "jacquard-api"
5603
-
version = "0.9.2"
5604
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5605
dependencies = [
5606
"bon",
5607
"bytes",
···
5619
5620
[[package]]
5621
name = "jacquard-axum"
5622
-
version = "0.9.2"
5623
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5624
dependencies = [
5625
"axum",
5626
"bytes",
···
5641
5642
[[package]]
5643
name = "jacquard-common"
5644
-
version = "0.9.2"
5645
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5646
dependencies = [
5647
"base64 0.22.1",
5648
"bon",
···
5669
"regex",
5670
"regex-lite",
5671
"reqwest",
5672
-
"ring",
5673
"serde",
5674
"serde_bytes",
5675
"serde_html_form",
···
5689
5690
[[package]]
5691
name = "jacquard-derive"
5692
-
version = "0.9.4"
5693
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5694
dependencies = [
5695
"heck 0.5.0",
5696
"jacquard-lexicon",
···
5701
5702
[[package]]
5703
name = "jacquard-identity"
5704
-
version = "0.9.2"
5705
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5706
dependencies = [
5707
"bon",
5708
"bytes",
···
5712
"jacquard-common",
5713
"jacquard-lexicon",
5714
"miette 7.6.0",
5715
-
"mini-moka 0.10.99",
5716
"n0-future 0.1.3",
5717
"percent-encoding",
5718
"reqwest",
5719
-
"ring",
5720
"serde",
5721
"serde_html_form",
5722
"serde_json",
···
5730
5731
[[package]]
5732
name = "jacquard-lexicon"
5733
-
version = "0.9.2"
5734
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5735
dependencies = [
5736
"cid",
5737
"dashmap 6.1.0",
···
5756
5757
[[package]]
5758
name = "jacquard-oauth"
5759
-
version = "0.9.2"
5760
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5761
dependencies = [
5762
"base64 0.22.1",
5763
"bytes",
···
5772
"miette 7.6.0",
5773
"p256",
5774
"rand 0.8.5",
5775
-
"ring",
5776
"rouille",
5777
"serde",
5778
"serde_html_form",
···
5789
5790
[[package]]
5791
name = "jacquard-repo"
5792
-
version = "0.9.4"
5793
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
5794
dependencies = [
5795
"bytes",
5796
"cid",
···
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"
···
6747
6748
[[package]]
6749
name = "mini-moka"
6750
-
version = "0.10.99"
6751
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2"
6752
dependencies = [
6753
"crossbeam-channel",
6754
"crossbeam-utils",
···
6760
]
6761
6762
[[package]]
6763
-
name = "mini-moka"
6764
-
version = "0.11.0"
6765
-
source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d"
6766
dependencies = [
6767
"crossbeam-channel",
6768
"crossbeam-utils",
···
8030
8031
[[package]]
8032
name = "portable-atomic"
8033
-
version = "1.11.1"
8034
source = "registry+https://github.com/rust-lang/crates.io-index"
8035
-
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
8036
8037
[[package]]
8038
name = "portmapper"
···
11679
"markdown-weaver",
11680
"markdown-weaver-escape",
11681
"mime-sniffer",
11682
-
"mini-moka 0.11.0",
11683
"n0-future 0.1.3",
11684
"postcard",
11685
"regex",
···
11801
"k256",
11802
"loro",
11803
"miette 7.6.0",
11804
-
"mini-moka 0.11.0",
11805
"n0-future 0.1.3",
11806
"rand 0.8.5",
11807
"regex",
···
980
981
[[package]]
982
name = "cc"
983
+
version = "1.2.50"
984
source = "registry+https://github.com/rust-lang/crates.io-index"
985
+
checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c"
986
dependencies = [
987
"find-msvc-tools",
988
"jobserver",
···
2805
[[package]]
2806
name = "dioxus-primitives"
2807
version = "0.0.1"
2808
+
source = "git+https://github.com/DioxusLabs/components#545aa7f55205b488f6403f92133fffb6c66838de"
2809
dependencies = [
2810
"dioxus 0.7.2",
2811
"dioxus-sdk-time",
···
5568
5569
[[package]]
5570
name = "jacquard"
5571
+
version = "0.9.5"
5572
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5573
dependencies = [
5574
"bytes",
5575
"getrandom 0.2.16",
···
5585
"regex",
5586
"regex-lite",
5587
"reqwest",
0
5588
"serde",
5589
"serde_html_form",
5590
"serde_json",
···
5599
5600
[[package]]
5601
name = "jacquard-api"
5602
+
version = "0.9.5"
5603
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5604
dependencies = [
5605
"bon",
5606
"bytes",
···
5618
5619
[[package]]
5620
name = "jacquard-axum"
5621
+
version = "0.9.6"
5622
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5623
dependencies = [
5624
"axum",
5625
"bytes",
···
5640
5641
[[package]]
5642
name = "jacquard-common"
5643
+
version = "0.9.5"
5644
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5645
dependencies = [
5646
"base64 0.22.1",
5647
"bon",
···
5668
"regex",
5669
"regex-lite",
5670
"reqwest",
0
5671
"serde",
5672
"serde_bytes",
5673
"serde_html_form",
···
5687
5688
[[package]]
5689
name = "jacquard-derive"
5690
+
version = "0.9.5"
5691
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5692
dependencies = [
5693
"heck 0.5.0",
5694
"jacquard-lexicon",
···
5699
5700
[[package]]
5701
name = "jacquard-identity"
5702
+
version = "0.9.5"
5703
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5704
dependencies = [
5705
"bon",
5706
"bytes",
···
5710
"jacquard-common",
5711
"jacquard-lexicon",
5712
"miette 7.6.0",
5713
+
"mini-moka-wasm",
5714
"n0-future 0.1.3",
5715
"percent-encoding",
5716
"reqwest",
0
5717
"serde",
5718
"serde_html_form",
5719
"serde_json",
···
5727
5728
[[package]]
5729
name = "jacquard-lexicon"
5730
+
version = "0.9.5"
5731
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5732
dependencies = [
5733
"cid",
5734
"dashmap 6.1.0",
···
5753
5754
[[package]]
5755
name = "jacquard-oauth"
5756
+
version = "0.9.6"
5757
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5758
dependencies = [
5759
"base64 0.22.1",
5760
"bytes",
···
5769
"miette 7.6.0",
5770
"p256",
5771
"rand 0.8.5",
0
5772
"rouille",
5773
"serde",
5774
"serde_html_form",
···
5785
5786
[[package]]
5787
name = "jacquard-repo"
5788
+
version = "0.9.5"
5789
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
5790
dependencies = [
5791
"bytes",
5792
"cid",
···
6478
[[package]]
6479
name = "markdown-weaver"
6480
version = "0.13.0"
6481
+
source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca"
6482
dependencies = [
6483
"bitflags 2.10.0",
6484
"getopts",
···
6491
[[package]]
6492
name = "markdown-weaver-escape"
6493
version = "0.11.0"
6494
+
source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca"
6495
6496
[[package]]
6497
name = "markup5ever"
···
6743
6744
[[package]]
6745
name = "mini-moka"
6746
+
version = "0.11.0"
6747
+
source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d"
6748
dependencies = [
6749
"crossbeam-channel",
6750
"crossbeam-utils",
···
6756
]
6757
6758
[[package]]
6759
+
name = "mini-moka-wasm"
6760
+
version = "0.10.99"
6761
+
source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117"
6762
dependencies = [
6763
"crossbeam-channel",
6764
"crossbeam-utils",
···
8026
8027
[[package]]
8028
name = "portable-atomic"
8029
+
version = "1.12.0"
8030
source = "registry+https://github.com/rust-lang/crates.io-index"
8031
+
checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd"
8032
8033
[[package]]
8034
name = "portmapper"
···
11675
"markdown-weaver",
11676
"markdown-weaver-escape",
11677
"mime-sniffer",
11678
+
"mini-moka",
11679
"n0-future 0.1.3",
11680
"postcard",
11681
"regex",
···
11797
"k256",
11798
"loro",
11799
"miette 7.6.0",
11800
+
"mini-moka",
11801
"n0-future 0.1.3",
11802
"rand 0.8.5",
11803
"regex",
+15
-31
crates/weaver-app/assets/styling/entry.css
···
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
···
39
color: var(--color-primary);
40
}
41
42
-
.entry-header .nav-button-prev {
43
-
flex-shrink: 1;
0
44
min-width: 0;
45
}
46
47
-
.entry-header .nav-button-next {
48
-
flex-shrink: 1;
0
49
min-width: 0;
0
50
}
51
52
.entry-header .nav-arrow {
···
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;
···
80
.entry-content-main {
81
width: var(--content-width);
82
max-width: 100%;
0
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 {
···
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 {
···
1
/* Entry page layout - centered content with sidenote margin */
2
.entry-page {
3
+
--content-width: 95ch;
4
--sidenote-width: 14rem;
5
--sidenote-gap: 1.5rem;
6
···
39
color: var(--color-primary);
40
}
41
42
+
.entry-header .nav-button-prev,
43
+
.entry-header .nav-placeholder:first-child {
44
+
flex: 1;
45
min-width: 0;
46
}
47
48
+
.entry-header .nav-button-next,
49
+
.entry-header .nav-placeholder:last-child {
50
+
flex: 1;
51
min-width: 0;
52
+
justify-content: flex-end;
53
}
54
55
.entry-header .nav-arrow {
···
63
text-overflow: ellipsis;
64
}
65
66
+
/* Metadata anchored to content width */
67
.entry-header .entry-metadata {
68
+
width: min(var(--content-width), 100%);
69
+
flex-shrink: 1;
70
margin: 0;
71
padding: 0;
72
border: none;
···
83
.entry-content-main {
84
width: var(--content-width);
85
max-width: 100%;
86
+
border-top: 1px solid var(--color-border);
87
position: relative;
88
}
89
90
+
/* Sidenote layout handled by css.rs body padding */
0
0
0
91
92
/* Footer navigation */
93
.entry-footer-nav {
···
296
}
297
}
298
299
+
/* Responsive: narrower */
0
0
0
0
0
0
0
0
300
@media (max-width: 900px) {
301
.entry-header .nav-title {
302
max-width: 8rem;
303
}
0
0
0
0
304
}
305
306
+
/* Responsive: mobile */
307
@media (max-width: 768px) {
308
.entry-header {
309
flex-direction: column;
310
align-items: stretch;
311
gap: 0.5rem;
0
0
0
0
0
312
}
313
314
.entry-footer-nav {
+3
-1
crates/weaver-app/src/components/editor/component.rs
···
26
use super::toolbar::EditorToolbar;
27
use super::visibility::update_syntax_visibility;
28
#[allow(unused_imports)]
29
-
use super::writer::{EditorImageResolver, SyntaxSpanInfo};
0
0
30
use crate::auth::AuthState;
31
use crate::components::collab::CollaboratorAvatars;
32
use crate::components::editor::ReportButton;
···
26
use super::toolbar::EditorToolbar;
27
use super::visibility::update_syntax_visibility;
28
#[allow(unused_imports)]
29
+
use super::writer::EditorImageResolver;
30
+
#[allow(unused_imports)]
31
+
use super::writer::SyntaxSpanInfo;
32
use crate::auth::AuthState;
33
use crate::components::collab::CollaboratorAvatars;
34
use crate::components::editor::ReportButton;
+22
-2129
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.
0
0
0
0
0
0
0
0
0
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,
18
-
escape_html_body_text_with_char_count,
19
-
};
20
use std::collections::HashMap;
21
use std::fmt;
22
use std::ops::Range;
23
-
use weaver_common::{EntryIndex, ResolvedContent};
24
-
25
-
/// Writer that segments output by paragraph boundaries.
26
-
///
27
-
/// Each paragraph's HTML is written to a separate String in the segments Vec.
28
-
/// Call `new_segment()` at paragraph boundaries to start a new segment.
29
-
#[derive(Debug, Clone, Default)]
30
-
pub struct SegmentedWriter {
31
-
segments: Vec<String>,
32
-
}
33
-
34
-
#[allow(dead_code)]
35
-
impl SegmentedWriter {
36
-
pub fn new() -> Self {
37
-
Self {
38
-
segments: vec![String::new()],
39
-
}
40
-
}
41
-
42
-
/// Start a new segment for the next paragraph.
43
-
pub fn new_segment(&mut self) {
44
-
self.segments.push(String::new());
45
-
}
46
-
47
-
/// Get the completed segments.
48
-
pub fn into_segments(self) -> Vec<String> {
49
-
self.segments
50
-
}
51
-
52
-
/// Get current segment count.
53
-
pub fn segment_count(&self) -> usize {
54
-
self.segments.len()
55
-
}
56
-
}
57
-
58
-
impl StrWrite for SegmentedWriter {
59
-
type Error = fmt::Error;
60
-
61
-
#[inline]
62
-
fn write_str(&mut self, s: &str) -> Result<(), Self::Error> {
63
-
if let Some(segment) = self.segments.last_mut() {
64
-
segment.push_str(s);
65
-
}
66
-
Ok(())
67
-
}
68
-
69
-
#[inline]
70
-
fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> {
71
-
if let Some(segment) = self.segments.last_mut() {
72
-
fmt::Write::write_fmt(segment, args)?;
73
-
}
74
-
Ok(())
75
-
}
76
-
}
77
-
78
-
impl fmt::Write for SegmentedWriter {
79
-
fn write_str(&mut self, s: &str) -> fmt::Result {
80
-
<Self as StrWrite>::write_str(self, s)
81
-
}
82
-
83
-
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
84
-
<Self as StrWrite>::write_fmt(self, args)
85
-
}
86
-
}
87
88
/// Result of rendering with the EditorWriter.
89
#[derive(Debug, Clone)]
···
106
pub collected_refs_by_paragraph: Vec<Vec<weaver_common::ExtractedRef>>,
107
}
108
109
-
/// Classification of markdown syntax characters
110
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111
-
pub enum SyntaxType {
112
-
/// Inline formatting: **, *, ~~, `, $, [, ], (, )
113
-
Inline,
114
-
/// Block formatting: #, >, -, *, 1., ```, ---
115
-
Block,
116
-
}
117
-
118
-
/// Information about a syntax span for conditional visibility
119
-
#[derive(Debug, Clone, PartialEq, Eq)]
120
-
pub struct SyntaxSpanInfo {
121
-
/// Unique identifier for this syntax span (e.g., "s0", "s1")
122
-
pub syn_id: String,
123
-
/// Source char range this syntax covers (just this marker)
124
-
pub char_range: Range<usize>,
125
-
/// Whether this is inline or block-level syntax
126
-
pub syntax_type: SyntaxType,
127
-
/// For paired inline syntax (**, *, etc), the full formatted region
128
-
/// from opening marker through content to closing marker.
129
-
/// When cursor is anywhere in this range, the syntax is visible.
130
-
pub formatted_range: Option<Range<usize>>,
131
-
}
132
-
133
-
impl SyntaxSpanInfo {
134
-
/// Adjust all position fields by a character delta.
135
-
///
136
-
/// This adjusts both `char_range` and `formatted_range` (if present) together,
137
-
/// ensuring they stay in sync. Use this instead of manually adjusting fields
138
-
/// to avoid forgetting one.
139
-
pub fn adjust_positions(&mut self, char_delta: isize) {
140
-
self.char_range.start = (self.char_range.start as isize + char_delta) as usize;
141
-
self.char_range.end = (self.char_range.end as isize + char_delta) as usize;
142
-
if let Some(ref mut fr) = self.formatted_range {
143
-
fr.start = (fr.start as isize + char_delta) as usize;
144
-
fr.end = (fr.end as isize + char_delta) as usize;
145
-
}
146
-
}
147
-
}
148
-
149
-
/// Classify syntax text as inline or block level
150
-
fn classify_syntax(text: &str) -> SyntaxType {
151
-
let trimmed = text.trim_start();
152
-
153
-
// Check for block-level markers
154
-
if trimmed.starts_with('#')
155
-
|| trimmed.starts_with('>')
156
-
|| trimmed.starts_with("```")
157
-
|| trimmed.starts_with("---")
158
-
|| (trimmed.starts_with('-')
159
-
&& trimmed
160
-
.chars()
161
-
.nth(1)
162
-
.map(|c| c.is_whitespace())
163
-
.unwrap_or(false))
164
-
|| (trimmed.starts_with('*')
165
-
&& trimmed
166
-
.chars()
167
-
.nth(1)
168
-
.map(|c| c.is_whitespace())
169
-
.unwrap_or(false))
170
-
|| trimmed
171
-
.chars()
172
-
.next()
173
-
.map(|c| c.is_ascii_digit())
174
-
.unwrap_or(false)
175
-
&& trimmed.contains('.')
176
-
{
177
-
SyntaxType::Block
178
-
} else {
179
-
SyntaxType::Inline
180
-
}
181
-
}
182
-
183
-
/// Synchronous callback for injecting embed content
184
-
///
185
-
/// Takes the embed tag and returns optional HTML content to inject.
186
-
pub trait EmbedContentProvider {
187
-
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>;
188
-
}
189
-
190
-
impl EmbedContentProvider for () {
191
-
fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> {
192
-
None
193
-
}
194
-
}
195
-
196
-
impl EmbedContentProvider for &ResolvedContent {
197
-
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
198
-
if let Tag::Embed { dest_url, .. } = tag {
199
-
let url = dest_url.as_ref();
200
-
if url.starts_with("at://") {
201
-
if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) {
202
-
return ResolvedContent::get_embed_content(self, &at_uri)
203
-
.map(|s| s.to_string());
204
-
}
205
-
}
206
-
}
207
-
None
208
-
}
209
-
}
210
-
211
-
/// Resolves image URLs to CDN URLs based on stored images.
212
-
///
213
-
/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png").
214
-
/// This trait maps those names to the actual CDN URL using the blob CID and owner DID.
215
-
pub trait ImageResolver {
216
-
/// Resolve an image URL from markdown to a CDN URL.
217
-
///
218
-
/// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL.
219
-
fn resolve_image_url(&self, url: &str) -> Option<String>;
220
-
}
221
-
222
-
impl ImageResolver for () {
223
-
fn resolve_image_url(&self, _url: &str) -> Option<String> {
224
-
None
225
-
}
226
-
}
227
-
228
-
/// Concrete image resolver that maps image names to URLs.
229
-
///
230
-
/// Resolved image path type
231
-
#[derive(Clone, Debug)]
232
-
enum ResolvedImage {
233
-
/// Data URL for immediate preview (still uploading)
234
-
Pending(String),
235
-
/// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}`
236
-
Draft {
237
-
blob_rkey: Rkey<'static>,
238
-
ident: AtIdentifier<'static>,
239
-
},
240
-
/// Published image: `/image/{ident}/{entry_rkey}/{name}`
241
-
Published {
242
-
entry_rkey: Rkey<'static>,
243
-
ident: AtIdentifier<'static>,
244
-
},
245
-
}
246
-
247
-
/// Resolves image paths in the editor.
248
-
///
249
-
/// Supports three states for images:
250
-
/// - Pending: uses data URL for immediate preview while upload is in progress
251
-
/// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}`
252
-
/// - Published: uses path format `/image/{did}/{entry_rkey}/{name}`
253
-
///
254
-
/// Image URLs in markdown use the format `/image/{name}`.
255
-
#[derive(Clone, Default)]
256
-
pub struct EditorImageResolver {
257
-
/// All resolved images: name -> resolved path info
258
-
images: std::collections::HashMap<String, ResolvedImage>,
259
-
}
260
-
261
-
impl EditorImageResolver {
262
-
pub fn new() -> Self {
263
-
Self::default()
264
-
}
265
-
266
-
/// Add a pending image with a data URL for immediate preview.
267
-
pub fn add_pending(&mut self, name: String, data_url: String) {
268
-
self.images.insert(name, ResolvedImage::Pending(data_url));
269
-
}
270
-
271
-
/// Promote a pending image to uploaded (draft) status.
272
-
pub fn promote_to_uploaded(
273
-
&mut self,
274
-
name: &str,
275
-
blob_rkey: Rkey<'static>,
276
-
ident: AtIdentifier<'static>,
277
-
) {
278
-
self.images
279
-
.insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident });
280
-
}
281
-
282
-
/// Add an already-uploaded draft image.
283
-
pub fn add_uploaded(
284
-
&mut self,
285
-
name: String,
286
-
blob_rkey: Rkey<'static>,
287
-
ident: AtIdentifier<'static>,
288
-
) {
289
-
self.images
290
-
.insert(name, ResolvedImage::Draft { blob_rkey, ident });
291
-
}
292
-
293
-
/// Add a published image.
294
-
pub fn add_published(
295
-
&mut self,
296
-
name: String,
297
-
entry_rkey: Rkey<'static>,
298
-
ident: AtIdentifier<'static>,
299
-
) {
300
-
self.images
301
-
.insert(name, ResolvedImage::Published { entry_rkey, ident });
302
-
}
303
-
304
-
/// Check if an image is pending upload.
305
-
pub fn is_pending(&self, name: &str) -> bool {
306
-
matches!(self.images.get(name), Some(ResolvedImage::Pending(_)))
307
-
}
308
-
309
-
/// Build a resolver from editor images and user identifier.
310
-
///
311
-
/// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included.
312
-
/// For published mode (entry_rkey=Some), all images are included.
313
-
pub fn from_images<'a>(
314
-
images: impl IntoIterator<Item = &'a super::document::EditorImage>,
315
-
ident: AtIdentifier<'static>,
316
-
entry_rkey: Option<Rkey<'static>>,
317
-
) -> Self {
318
-
use jacquard::IntoStatic;
319
-
320
-
let mut resolver = Self::new();
321
-
for editor_image in images {
322
-
// Get the name from the Image (use alt text as fallback if name is empty)
323
-
let name = editor_image
324
-
.image
325
-
.name
326
-
.as_ref()
327
-
.map(|n| n.to_string())
328
-
.unwrap_or_else(|| editor_image.image.alt.to_string());
329
-
330
-
if name.is_empty() {
331
-
continue;
332
-
}
333
-
334
-
match &entry_rkey {
335
-
// Published mode: use entry rkey for all images
336
-
Some(rkey) => {
337
-
resolver.add_published(name, rkey.clone(), ident.clone());
338
-
}
339
-
// Draft mode: use published_blob_uri rkey
340
-
None => {
341
-
let blob_rkey = match &editor_image.published_blob_uri {
342
-
Some(uri) => match uri.rkey() {
343
-
Some(rkey) => rkey.0.clone().into_static(),
344
-
None => continue,
345
-
},
346
-
None => continue,
347
-
};
348
-
resolver.add_uploaded(name, blob_rkey, ident.clone());
349
-
}
350
-
}
351
-
}
352
-
resolver
353
-
}
354
-
}
355
-
356
-
impl ImageResolver for EditorImageResolver {
357
-
fn resolve_image_url(&self, url: &str) -> Option<String> {
358
-
// Extract image name from /image/{name} format
359
-
let name = url.strip_prefix("/image/").unwrap_or(url);
360
-
361
-
let resolved = self.images.get(name)?;
362
-
match resolved {
363
-
ResolvedImage::Pending(data_url) => Some(data_url.clone()),
364
-
ResolvedImage::Draft { blob_rkey, ident } => {
365
-
Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name))
366
-
}
367
-
ResolvedImage::Published { entry_rkey, ident } => {
368
-
Some(format!("/image/{}/{}/{}", ident, entry_rkey, name))
369
-
}
370
-
}
371
-
}
372
-
}
373
-
374
-
impl ImageResolver for &EditorImageResolver {
375
-
fn resolve_image_url(&self, url: &str) -> Option<String> {
376
-
(*self).resolve_image_url(url)
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,
0
0
0
0
0
0
385
}
386
387
/// HTML writer that preserves markdown formatting characters.
···
476
current_footnote_def: Option<(String, usize, usize)>,
477
478
_phantom: std::marker::PhantomData<&'a ()>,
479
-
}
480
-
481
-
#[derive(Debug, Clone, Copy)]
482
-
enum TableState {
483
-
Head,
484
-
Body,
485
}
486
487
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
···
756
}
757
}
758
759
-
/// Emit syntax span for a given range and record offset mapping
760
-
fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> {
761
-
if range.start < range.end {
762
-
let syntax = &self.source[range.clone()];
763
-
if !syntax.is_empty() {
764
-
let char_start = self.last_char_offset;
765
-
let syntax_char_len = syntax.chars().count();
766
-
let char_end = char_start + syntax_char_len;
767
-
768
-
tracing::trace!(
769
-
target: "weaver::writer",
770
-
byte_range = ?range,
771
-
char_range = ?(char_start..char_end),
772
-
syntax = %syntax.escape_debug(),
773
-
"emit_syntax"
774
-
);
775
-
776
-
// Whitespace-only content (trailing spaces, newlines) should be emitted
777
-
// as plain text, not wrapped in a hideable syntax span
778
-
let is_whitespace_only = syntax.trim().is_empty();
779
-
780
-
if is_whitespace_only {
781
-
// Emit as plain text with tracking span (not hideable)
782
-
let created_node = if self.current_node_id.is_none() {
783
-
let node_id = self.gen_node_id();
784
-
write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
785
-
self.begin_node(node_id);
786
-
true
787
-
} else {
788
-
false
789
-
};
790
-
791
-
escape_html(&mut self.writer, syntax)?;
792
-
793
-
// Record offset mapping BEFORE end_node (which clears current_node_id)
794
-
self.record_mapping(range.clone(), char_start..char_end);
795
-
self.last_char_offset = char_end;
796
-
self.last_byte_offset = range.end;
797
-
798
-
if created_node {
799
-
self.write("</span>")?;
800
-
self.end_node();
801
-
}
802
-
} else {
803
-
// Real syntax - wrap in hideable span
804
-
let syntax_type = classify_syntax(syntax);
805
-
let class = match syntax_type {
806
-
SyntaxType::Inline => "md-syntax-inline",
807
-
SyntaxType::Block => "md-syntax-block",
808
-
};
809
-
810
-
// Generate unique ID for this syntax span
811
-
let syn_id = self.gen_syn_id();
812
-
813
-
// If we're outside any node, create a wrapper span for tracking
814
-
let created_node = if self.current_node_id.is_none() {
815
-
let node_id = self.gen_node_id();
816
-
write!(
817
-
&mut self.writer,
818
-
"<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
819
-
node_id, class, syn_id, char_start, char_end
820
-
)?;
821
-
self.begin_node(node_id);
822
-
true
823
-
} else {
824
-
write!(
825
-
&mut self.writer,
826
-
"<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
827
-
class, syn_id, char_start, char_end
828
-
)?;
829
-
false
830
-
};
831
-
832
-
escape_html(&mut self.writer, syntax)?;
833
-
self.write("</span>")?;
834
-
835
-
// Record syntax span info for visibility toggling
836
-
self.syntax_spans.push(SyntaxSpanInfo {
837
-
syn_id,
838
-
char_range: char_start..char_end,
839
-
syntax_type,
840
-
formatted_range: None,
841
-
});
842
-
843
-
// Record offset mapping for this syntax
844
-
self.record_mapping(range.clone(), char_start..char_end);
845
-
self.last_char_offset = char_end;
846
-
self.last_byte_offset = range.end;
847
-
848
-
// Close wrapper if we created one
849
-
if created_node {
850
-
self.write("</span>")?;
851
-
self.end_node();
852
-
}
853
-
}
854
-
}
855
-
}
856
-
Ok(())
857
-
}
858
-
859
-
/// Emit syntax span inside current node with full offset tracking.
860
-
///
861
-
/// Use this for syntax markers that appear inside block elements (headings, lists,
862
-
/// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates
863
-
/// wrapper nodes, this assumes we're already inside a tracked node.
864
-
///
865
-
/// - Writes `<span class="md-syntax-{class}">{syntax}</span>`
866
-
/// - Records offset mapping (for cursor positioning)
867
-
/// - Updates both `last_char_offset` and `last_byte_offset`
868
-
fn emit_inner_syntax(
869
-
&mut self,
870
-
syntax: &str,
871
-
byte_start: usize,
872
-
syntax_type: SyntaxType,
873
-
) -> Result<(), fmt::Error> {
874
-
if syntax.is_empty() {
875
-
return Ok(());
876
-
}
877
-
878
-
let char_start = self.last_char_offset;
879
-
let syntax_char_len = syntax.chars().count();
880
-
let char_end = char_start + syntax_char_len;
881
-
let byte_end = byte_start + syntax.len();
882
-
883
-
let class_str = match syntax_type {
884
-
SyntaxType::Inline => "md-syntax-inline",
885
-
SyntaxType::Block => "md-syntax-block",
886
-
};
887
-
888
-
// Generate unique ID for this syntax span
889
-
let syn_id = self.gen_syn_id();
890
-
891
-
write!(
892
-
&mut self.writer,
893
-
"<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
894
-
class_str, syn_id, char_start, char_end
895
-
)?;
896
-
escape_html(&mut self.writer, syntax)?;
897
-
self.write("</span>")?;
898
-
899
-
// Record syntax span info for visibility toggling
900
-
self.syntax_spans.push(SyntaxSpanInfo {
901
-
syn_id,
902
-
char_range: char_start..char_end,
903
-
syntax_type,
904
-
formatted_range: None,
905
-
});
906
-
907
-
// Record offset mapping for cursor positioning
908
-
self.record_mapping(byte_start..byte_end, char_start..char_end);
909
-
910
-
self.last_char_offset = char_end;
911
-
self.last_byte_offset = byte_end;
912
-
913
-
Ok(())
914
-
}
915
-
916
-
/// Emit any gap between last position and next offset
917
-
fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> {
918
-
// Skip gap emission if we're inside a table being rendered as markdown
919
-
if self.table_start_offset.is_some() && self.render_tables_as_markdown {
920
-
return Ok(());
921
-
}
922
-
923
-
// Skip gap emission if we're buffering code block content
924
-
// The code block handler manages its own syntax emission
925
-
if self.code_buffer.is_some() {
926
-
return Ok(());
927
-
}
928
-
929
-
if next_offset > self.last_byte_offset {
930
-
self.emit_syntax(self.last_byte_offset..next_offset)?;
931
-
}
932
-
Ok(())
933
-
}
934
-
935
/// Generate a unique node ID.
936
/// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`.
937
/// Otherwise produces `n{counter}` for backwards compatibility.
···
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())));
0
0
0
1230
}
1231
} else {
1232
// No colon - treat as class, strip leading dot if present
···
1852
self.weaver_block_buffer.push_str(&text);
1853
}
1854
}
1855
-
Ok(())
1856
-
}
1857
-
1858
-
fn start_tag(&mut self, tag: Tag<'_>, range: Range<usize>) -> Result<(), fmt::Error> {
1859
-
// Check if this is a block-level tag that should have syntax inside
1860
-
let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_));
1861
-
1862
-
// For inline tags, emit syntax before tag
1863
-
if !is_block_tag && range.start < range.end {
1864
-
let raw_text = &self.source[range.clone()];
1865
-
let opening_syntax = match &tag {
1866
-
Tag::Strong => {
1867
-
if raw_text.starts_with("**") {
1868
-
Some("**")
1869
-
} else if raw_text.starts_with("__") {
1870
-
Some("__")
1871
-
} else {
1872
-
None
1873
-
}
1874
-
}
1875
-
Tag::Emphasis => {
1876
-
if raw_text.starts_with("*") {
1877
-
Some("*")
1878
-
} else if raw_text.starts_with("_") {
1879
-
Some("_")
1880
-
} else {
1881
-
None
1882
-
}
1883
-
}
1884
-
Tag::Strikethrough => {
1885
-
if raw_text.starts_with("~~") {
1886
-
Some("~~")
1887
-
} else {
1888
-
None
1889
-
}
1890
-
}
1891
-
Tag::Link { link_type, .. } => {
1892
-
if matches!(link_type, LinkType::WikiLink { .. }) {
1893
-
if raw_text.starts_with("[[") {
1894
-
Some("[[")
1895
-
} else {
1896
-
None
1897
-
}
1898
-
} else if raw_text.starts_with('[') {
1899
-
Some("[")
1900
-
} else {
1901
-
None
1902
-
}
1903
-
}
1904
-
// Note: Tag::Image and Tag::Embed handle their own syntax spans
1905
-
// in their respective handlers, so don't emit here
1906
-
_ => None,
1907
-
};
1908
-
1909
-
if let Some(syntax) = opening_syntax {
1910
-
let syntax_type = classify_syntax(syntax);
1911
-
let class = match syntax_type {
1912
-
SyntaxType::Inline => "md-syntax-inline",
1913
-
SyntaxType::Block => "md-syntax-block",
1914
-
};
1915
-
1916
-
let char_start = self.last_char_offset;
1917
-
let syntax_char_len = syntax.chars().count();
1918
-
let char_end = char_start + syntax_char_len;
1919
-
let syntax_byte_len = syntax.len();
1920
-
1921
-
// Generate unique ID for this syntax span
1922
-
let syn_id = self.gen_syn_id();
1923
-
1924
-
write!(
1925
-
&mut self.writer,
1926
-
"<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
1927
-
class, syn_id, char_start, char_end
1928
-
)?;
1929
-
escape_html(&mut self.writer, syntax)?;
1930
-
self.write("</span>")?;
1931
-
1932
-
// Record syntax span info for visibility toggling
1933
-
self.syntax_spans.push(SyntaxSpanInfo {
1934
-
syn_id: syn_id.clone(),
1935
-
char_range: char_start..char_end,
1936
-
syntax_type,
1937
-
formatted_range: None, // Will be updated when closing tag is emitted
1938
-
});
1939
-
1940
-
// Record offset mapping for cursor positioning
1941
-
// This is critical - without it, current_node_char_offset is wrong
1942
-
// and all subsequent cursor positions are shifted
1943
-
let byte_start = range.start;
1944
-
let byte_end = range.start + syntax_byte_len;
1945
-
self.record_mapping(byte_start..byte_end, char_start..char_end);
1946
-
1947
-
// For paired inline syntax, track opening span for formatted_range
1948
-
if matches!(
1949
-
tag,
1950
-
Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. }
1951
-
) {
1952
-
self.pending_inline_formats.push((syn_id, char_start));
1953
-
}
1954
-
1955
-
// Update tracking - we've consumed this opening syntax
1956
-
self.last_char_offset = char_end;
1957
-
self.last_byte_offset = range.start + syntax_byte_len;
1958
-
}
1959
-
}
1960
-
1961
-
// Emit the opening tag
1962
-
match tag {
1963
-
// HTML blocks get their own paragraph to try and corral them better
1964
-
Tag::HtmlBlock => {
1965
-
// Record paragraph start for boundary tracking
1966
-
// BUT skip if inside a list - list owns the paragraph boundary
1967
-
if self.list_depth == 0 {
1968
-
self.current_paragraph_start =
1969
-
Some((self.last_byte_offset, self.last_char_offset));
1970
-
}
1971
-
let node_id = self.gen_node_id();
1972
-
1973
-
if self.end_newline {
1974
-
write!(
1975
-
&mut self.writer,
1976
-
r#"<p id="{}" class="html-embed html-embed-block">"#,
1977
-
node_id
1978
-
)?;
1979
-
} else {
1980
-
write!(
1981
-
&mut self.writer,
1982
-
r#"\n<p id="{}" class="html-embed html-embed-block">"#,
1983
-
node_id
1984
-
)?;
1985
-
}
1986
-
self.begin_node(node_id.clone());
1987
-
1988
-
// Map the start position of the paragraph (before any content)
1989
-
// This allows cursor to be placed at the very beginning
1990
-
let para_start_char = self.last_char_offset;
1991
-
let mapping = OffsetMapping {
1992
-
byte_range: range.start..range.start,
1993
-
char_range: para_start_char..para_start_char,
1994
-
node_id,
1995
-
char_offset_in_node: 0,
1996
-
child_index: Some(0), // position before first child
1997
-
utf16_len: 0,
1998
-
};
1999
-
self.offset_maps.push(mapping);
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 {
2010
-
self.current_paragraph_start =
2011
-
Some((self.last_byte_offset, self.last_char_offset));
2012
-
}
2013
-
2014
-
let node_id = self.gen_node_id();
2015
-
if self.end_newline {
2016
-
write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
2017
-
} else {
2018
-
write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?;
2019
-
}
2020
-
self.begin_node(node_id.clone());
2021
-
2022
-
// Map the start position of the paragraph (before any content)
2023
-
// This allows cursor to be placed at the very beginning
2024
-
let para_start_char = self.last_char_offset;
2025
-
let mapping = OffsetMapping {
2026
-
byte_range: range.start..range.start,
2027
-
char_range: para_start_char..para_start_char,
2028
-
node_id,
2029
-
char_offset_in_node: 0,
2030
-
child_index: Some(0), // position before first child
2031
-
utf16_len: 0,
2032
-
};
2033
-
self.offset_maps.push(mapping);
2034
-
2035
-
// Emit > syntax if we're inside a blockquote
2036
-
if let Some(bq_range) = self.pending_blockquote_range.take() {
2037
-
if bq_range.start < bq_range.end {
2038
-
let raw_text = &self.source[bq_range.clone()];
2039
-
if let Some(gt_pos) = raw_text.find('>') {
2040
-
// Extract > [!NOTE] or just >
2041
-
let after_gt = &raw_text[gt_pos + 1..];
2042
-
let syntax_end = if after_gt.trim_start().starts_with("[!") {
2043
-
// Find the closing ]
2044
-
if let Some(close_bracket) = after_gt.find(']') {
2045
-
gt_pos + 1 + close_bracket + 1
2046
-
} else {
2047
-
gt_pos + 1
2048
-
}
2049
-
} else {
2050
-
// Just > and maybe a space
2051
-
(gt_pos + 1).min(raw_text.len())
2052
-
};
2053
-
2054
-
let syntax = &raw_text[gt_pos..syntax_end];
2055
-
let syntax_byte_start = bq_range.start + gt_pos;
2056
-
self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
2057
-
}
2058
-
}
2059
-
}
2060
-
Ok(())
2061
-
}
2062
-
Tag::Heading {
2063
-
level,
2064
-
id,
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));
2074
-
2075
-
if !self.end_newline {
2076
-
self.write("\n")?;
2077
-
}
2078
-
2079
-
// Generate node ID for offset tracking
2080
-
let node_id = self.gen_node_id();
2081
-
2082
-
self.write("<")?;
2083
-
write!(&mut self.writer, "{}", level)?;
2084
-
2085
-
// Add our tracking ID as data attribute (preserve user's id if present)
2086
-
self.write(" data-node-id=\"")?;
2087
-
self.write(&node_id)?;
2088
-
self.write("\"")?;
2089
-
2090
-
if let Some(id) = id {
2091
-
self.write(" id=\"")?;
2092
-
escape_html(&mut self.writer, &id)?;
2093
-
self.write("\"")?;
2094
-
}
2095
-
if !classes.is_empty() {
2096
-
self.write(" class=\"")?;
2097
-
for (i, class) in classes.iter().enumerate() {
2098
-
if i > 0 {
2099
-
self.write(" ")?;
2100
-
}
2101
-
escape_html(&mut self.writer, class)?;
2102
-
}
2103
-
self.write("\"")?;
2104
-
}
2105
-
for (attr, value) in attrs {
2106
-
self.write(" ")?;
2107
-
escape_html(&mut self.writer, &attr)?;
2108
-
if let Some(val) = value {
2109
-
self.write("=\"")?;
2110
-
escape_html(&mut self.writer, &val)?;
2111
-
self.write("\"")?;
2112
-
} else {
2113
-
self.write("=\"\"")?;
2114
-
}
2115
-
}
2116
-
self.write(">")?;
2117
-
2118
-
// Begin node tracking for offset mapping
2119
-
self.begin_node(node_id.clone());
2120
-
2121
-
// Map the start position of the heading (before any content)
2122
-
// This allows cursor to be placed at the very beginning
2123
-
let heading_start_char = self.last_char_offset;
2124
-
let mapping = OffsetMapping {
2125
-
byte_range: range.start..range.start,
2126
-
char_range: heading_start_char..heading_start_char,
2127
-
node_id: node_id.clone(),
2128
-
char_offset_in_node: 0,
2129
-
child_index: Some(0), // position before first child
2130
-
utf16_len: 0,
2131
-
};
2132
-
self.offset_maps.push(mapping);
2133
-
2134
-
// Emit # syntax inside the heading tag
2135
-
if range.start < range.end {
2136
-
let raw_text = &self.source[range.clone()];
2137
-
let count = level as usize;
2138
-
let pattern = "#".repeat(count);
2139
-
2140
-
// Find where the # actually starts (might have leading whitespace)
2141
-
if let Some(hash_pos) = raw_text.find(&pattern) {
2142
-
// Extract "# " or "## " etc
2143
-
let syntax_end = (hash_pos + count + 1).min(raw_text.len());
2144
-
let syntax = &raw_text[hash_pos..syntax_end];
2145
-
let syntax_byte_start = range.start + hash_pos;
2146
-
2147
-
self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
2148
-
}
2149
-
}
2150
-
Ok(())
2151
-
}
2152
-
Tag::Table(alignments) => {
2153
-
if self.render_tables_as_markdown {
2154
-
// Store start offset and skip HTML rendering
2155
-
self.table_start_offset = Some(range.start);
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
-
}
2163
-
}
2164
-
Tag::TableHead => {
2165
-
if self.render_tables_as_markdown {
2166
-
Ok(()) // Skip HTML rendering
2167
-
} else {
2168
-
self.table_state = TableState::Head;
2169
-
self.table_cell_index = 0;
2170
-
self.write("<thead><tr>")
2171
-
}
2172
-
}
2173
-
Tag::TableRow => {
2174
-
if self.render_tables_as_markdown {
2175
-
Ok(()) // Skip HTML rendering
2176
-
} else {
2177
-
self.table_cell_index = 0;
2178
-
self.write("<tr>")
2179
-
}
2180
-
}
2181
-
Tag::TableCell => {
2182
-
if self.render_tables_as_markdown {
2183
-
Ok(()) // Skip HTML rendering
2184
-
} else {
2185
-
match self.table_state {
2186
-
TableState::Head => self.write("<th")?,
2187
-
TableState::Body => self.write("<td")?,
2188
-
}
2189
-
match self.table_alignments.get(self.table_cell_index) {
2190
-
Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
2191
-
Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
2192
-
Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
2193
-
_ => self.write(">"),
2194
-
}
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\"",
2203
-
Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"",
2204
-
Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"",
2205
-
Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"",
2206
-
Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"",
2207
-
};
2208
-
if self.end_newline {
2209
-
write!(&mut self.writer, "<blockquote{}>\n", class_str)?;
2210
-
} else {
2211
-
write!(&mut self.writer, "\n<blockquote{}>\n", class_str)?;
2212
-
}
2213
-
2214
-
// Store range for emitting > inside the next paragraph
2215
-
self.pending_blockquote_range = Some(range);
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
-
2224
-
if !self.end_newline {
2225
-
self.write_newline()?;
2226
-
}
2227
-
2228
-
// Generate node ID for code block
2229
-
let node_id = self.gen_node_id();
2230
-
2231
-
match info {
2232
-
CodeBlockKind::Fenced(info) => {
2233
-
// Emit opening ```language and track both char and byte offsets
2234
-
if range.start < range.end {
2235
-
let raw_text = &self.source[range.clone()];
2236
-
if let Some(fence_pos) = raw_text.find("```") {
2237
-
let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len());
2238
-
let syntax = &raw_text[fence_pos..fence_end];
2239
-
let syntax_char_len = syntax.chars().count() + 1; // +1 for newline
2240
-
let syntax_byte_len = syntax.len() + 1; // +1 for newline
2241
-
2242
-
let syn_id = self.gen_syn_id();
2243
-
let char_start = self.last_char_offset;
2244
-
let char_end = char_start + syntax_char_len;
2245
-
2246
-
write!(
2247
-
&mut self.writer,
2248
-
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
2249
-
syn_id, char_start, char_end
2250
-
)?;
2251
-
escape_html(&mut self.writer, syntax)?;
2252
-
self.write("</span>\n")?;
2253
-
2254
-
// Track opening span index for formatted_range update later
2255
-
self.code_block_opening_span_idx = Some(self.syntax_spans.len());
2256
-
self.code_block_char_start = Some(char_start);
2257
-
2258
-
self.syntax_spans.push(SyntaxSpanInfo {
2259
-
syn_id,
2260
-
char_range: char_start..char_end,
2261
-
syntax_type: SyntaxType::Block,
2262
-
formatted_range: None, // Will be set in TagEnd::CodeBlock
2263
-
});
2264
-
2265
-
self.last_char_offset += syntax_char_len;
2266
-
self.last_byte_offset = range.start + fence_pos + syntax_byte_len;
2267
-
}
2268
-
}
2269
-
2270
-
let lang = info.split(' ').next().unwrap();
2271
-
let lang_opt = if lang.is_empty() {
2272
-
None
2273
-
} else {
2274
-
Some(lang.to_string())
2275
-
};
2276
-
// Start buffering
2277
-
self.code_buffer = Some((lang_opt, String::new()));
2278
-
2279
-
// Begin node tracking for offset mapping
2280
-
self.begin_node(node_id);
2281
-
Ok(())
2282
-
}
2283
-
CodeBlockKind::Indented => {
2284
-
// Ignore indented code blocks (as per executive decision)
2285
-
self.code_buffer = Some((None, String::new()));
2286
-
2287
-
// Begin node tracking for offset mapping
2288
-
self.begin_node(node_id);
2289
-
Ok(())
2290
-
}
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;
2298
-
if self.end_newline {
2299
-
self.write("<ol>\n")
2300
-
} else {
2301
-
self.write("\n<ol>\n")
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;
2309
-
if self.end_newline {
2310
-
self.write("<ol start=\"")?;
2311
-
} else {
2312
-
self.write("\n<ol start=\"")?;
2313
-
}
2314
-
write!(&mut self.writer, "{}", start)?;
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;
2322
-
if self.end_newline {
2323
-
self.write("<ul>\n")
2324
-
} else {
2325
-
self.write("\n<ul>\n")
2326
-
}
2327
-
}
2328
-
Tag::Item => {
2329
-
// Generate node ID for list item
2330
-
let node_id = self.gen_node_id();
2331
-
2332
-
if self.end_newline {
2333
-
write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?;
2334
-
} else {
2335
-
write!(&mut self.writer, "\n<li data-node-id=\"{}\">", node_id)?;
2336
-
}
2337
-
2338
-
// Begin node tracking
2339
-
self.begin_node(node_id);
2340
-
2341
-
// Emit list marker syntax inside the <li> tag and track both offsets
2342
-
if range.start < range.end {
2343
-
let raw_text = &self.source[range.clone()];
2344
-
2345
-
// Try to find the list marker (-, *, or digit.)
2346
-
let trimmed = raw_text.trim_start();
2347
-
let leading_ws_bytes = raw_text.len() - trimmed.len();
2348
-
let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count();
2349
-
2350
-
if let Some(marker) = trimmed.chars().next() {
2351
-
if marker == '-' || marker == '*' {
2352
-
// Unordered list: extract "- " or "* "
2353
-
let marker_end = trimmed
2354
-
.find(|c: char| c != '-' && c != '*')
2355
-
.map(|pos| pos + 1)
2356
-
.unwrap_or(1);
2357
-
let syntax = &trimmed[..marker_end.min(trimmed.len())];
2358
-
let char_start = self.last_char_offset;
2359
-
let syntax_char_len = leading_ws_chars + syntax.chars().count();
2360
-
let syntax_byte_len = leading_ws_bytes + syntax.len();
2361
-
let char_end = char_start + syntax_char_len;
2362
-
2363
-
let syn_id = self.gen_syn_id();
2364
-
write!(
2365
-
&mut self.writer,
2366
-
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
2367
-
syn_id, char_start, char_end
2368
-
)?;
2369
-
escape_html(&mut self.writer, syntax)?;
2370
-
self.write("</span>")?;
2371
-
2372
-
self.syntax_spans.push(SyntaxSpanInfo {
2373
-
syn_id,
2374
-
char_range: char_start..char_end,
2375
-
syntax_type: SyntaxType::Block,
2376
-
formatted_range: None,
2377
-
});
2378
-
2379
-
// Record offset mapping for cursor positioning
2380
-
self.record_mapping(
2381
-
range.start..range.start + syntax_byte_len,
2382
-
char_start..char_end,
2383
-
);
2384
-
self.last_char_offset = char_end;
2385
-
self.last_byte_offset = range.start + syntax_byte_len;
2386
-
} else if marker.is_ascii_digit() {
2387
-
// Ordered list: extract "1. " or similar (including trailing space)
2388
-
if let Some(dot_pos) = trimmed.find('.') {
2389
-
let syntax_end = (dot_pos + 2).min(trimmed.len());
2390
-
let syntax = &trimmed[..syntax_end];
2391
-
let char_start = self.last_char_offset;
2392
-
let syntax_char_len = leading_ws_chars + syntax.chars().count();
2393
-
let syntax_byte_len = leading_ws_bytes + syntax.len();
2394
-
let char_end = char_start + syntax_char_len;
2395
-
2396
-
let syn_id = self.gen_syn_id();
2397
-
write!(
2398
-
&mut self.writer,
2399
-
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
2400
-
syn_id, char_start, char_end
2401
-
)?;
2402
-
escape_html(&mut self.writer, syntax)?;
2403
-
self.write("</span>")?;
2404
-
2405
-
self.syntax_spans.push(SyntaxSpanInfo {
2406
-
syn_id,
2407
-
char_range: char_start..char_end,
2408
-
syntax_type: SyntaxType::Block,
2409
-
formatted_range: None,
2410
-
});
2411
-
2412
-
// Record offset mapping for cursor positioning
2413
-
self.record_mapping(
2414
-
range.start..range.start + syntax_byte_len,
2415
-
char_start..char_end,
2416
-
);
2417
-
self.last_char_offset = char_end;
2418
-
self.last_byte_offset = range.start + syntax_byte_len;
2419
-
}
2420
-
}
2421
-
}
2422
-
}
2423
-
Ok(())
2424
-
}
2425
-
Tag::DefinitionList => {
2426
-
self.emit_wrapper_start()?;
2427
-
if self.end_newline {
2428
-
self.write("<dl>\n")
2429
-
} else {
2430
-
self.write("\n<dl>\n")
2431
-
}
2432
-
}
2433
-
Tag::DefinitionListTitle => {
2434
-
let node_id = self.gen_node_id();
2435
-
2436
-
if self.end_newline {
2437
-
write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?;
2438
-
} else {
2439
-
write!(&mut self.writer, "\n<dt data-node-id=\"{}\">", node_id)?;
2440
-
}
2441
-
2442
-
self.begin_node(node_id);
2443
-
Ok(())
2444
-
}
2445
-
Tag::DefinitionListDefinition => {
2446
-
let node_id = self.gen_node_id();
2447
-
2448
-
if self.end_newline {
2449
-
write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?;
2450
-
} else {
2451
-
write!(&mut self.writer, "\n<dd data-node-id=\"{}\">", node_id)?;
2452
-
}
2453
-
2454
-
self.begin_node(node_id);
2455
-
Ok(())
2456
-
}
2457
-
Tag::Subscript => self.write("<sub>"),
2458
-
Tag::Superscript => self.write("<sup>"),
2459
-
Tag::Emphasis => self.write("<em>"),
2460
-
Tag::Strong => self.write("<strong>"),
2461
-
Tag::Strikethrough => self.write("<s>"),
2462
-
Tag::Link {
2463
-
link_type: LinkType::Email,
2464
-
dest_url,
2465
-
title,
2466
-
..
2467
-
} => {
2468
-
self.write("<a href=\"mailto:")?;
2469
-
escape_href(&mut self.writer, &dest_url)?;
2470
-
if !title.is_empty() {
2471
-
self.write("\" title=\"")?;
2472
-
escape_html(&mut self.writer, &title)?;
2473
-
}
2474
-
self.write("\">")
2475
-
}
2476
-
Tag::Link {
2477
-
link_type,
2478
-
dest_url,
2479
-
title,
2480
-
..
2481
-
} => {
2482
-
// Collect refs for later resolution
2483
-
let url = dest_url.as_ref();
2484
-
if matches!(link_type, LinkType::WikiLink { .. }) {
2485
-
let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url);
2486
-
self.ref_collector.add_wikilink(target, fragment, None);
2487
-
} else if url.starts_with("at://") {
2488
-
self.ref_collector.add_at_link(url);
2489
-
}
2490
-
2491
-
// Determine link validity class for wikilinks
2492
-
let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) {
2493
-
if let Some(index) = &self.entry_index {
2494
-
if index.resolve(dest_url.as_ref()).is_some() {
2495
-
" link-valid"
2496
-
} else {
2497
-
" link-broken"
2498
-
}
2499
-
} else {
2500
-
""
2501
-
}
2502
-
} else {
2503
-
""
2504
-
};
2505
-
2506
-
self.write("<a class=\"link")?;
2507
-
self.write(validity_class)?;
2508
-
self.write("\" href=\"")?;
2509
-
escape_href(&mut self.writer, &dest_url)?;
2510
-
if !title.is_empty() {
2511
-
self.write("\" title=\"")?;
2512
-
escape_html(&mut self.writer, &title)?;
2513
-
}
2514
-
self.write("\">")
2515
-
}
2516
-
Tag::Image {
2517
-
link_type,
2518
-
dest_url,
2519
-
title,
2520
-
id,
2521
-
attrs,
2522
-
} => {
2523
-
// Check if this is actually an AT embed disguised as a wikilink image
2524
-
// (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type)
2525
-
let url = dest_url.as_ref();
2526
-
if matches!(link_type, LinkType::WikiLink { .. })
2527
-
&& (url.starts_with("at://") || url.starts_with("did:"))
2528
-
{
2529
-
return self.write_embed(
2530
-
range,
2531
-
EmbedType::Other, // AT embeds - disambiguated via NSID later
2532
-
dest_url,
2533
-
title,
2534
-
id,
2535
-
attrs,
2536
-
);
2537
-
}
2538
-
2539
-
// Image rendering: all syntax elements share one syn_id for visibility toggling
2540
-
// Structure:  <img> cursor-landing
2541
-
let raw_text = &self.source[range.clone()];
2542
-
let syn_id = self.gen_syn_id();
2543
-
let opening_char_start = self.last_char_offset;
2544
-
2545
-
// Find the alt text and closing syntax positions
2546
-
let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len());
2547
-
let alt_text = if raw_text.starts_with("![") && paren_pos > 2 {
2548
-
&raw_text[2..paren_pos]
2549
-
} else {
2550
-
""
2551
-
};
2552
-
let closing_syntax = if paren_pos < raw_text.len() {
2553
-
&raw_text[paren_pos..]
2554
-
} else {
2555
-
""
2556
-
};
2557
-
2558
-
// Calculate char positions
2559
-
let alt_char_len = alt_text.chars().count();
2560
-
let closing_char_len = closing_syntax.chars().count();
2561
-
let opening_char_end = opening_char_start + 2; // " syntax span
2615
-
if !closing_syntax.is_empty() {
2616
-
write!(
2617
-
&mut self.writer,
2618
-
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
2619
-
syn_id, closing_char_start, closing_char_end
2620
-
)?;
2621
-
escape_html(&mut self.writer, closing_syntax)?;
2622
-
self.write("</span>")?;
2623
-
2624
-
self.syntax_spans.push(SyntaxSpanInfo {
2625
-
syn_id: syn_id.clone(),
2626
-
char_range: closing_char_start..closing_char_end,
2627
-
syntax_type: SyntaxType::Inline,
2628
-
formatted_range: Some(formatted_range.clone()),
2629
-
});
2630
-
2631
-
// Record offset mapping for ](url)
2632
-
self.record_mapping(
2633
-
range.start + paren_pos..range.end,
2634
-
closing_char_start..closing_char_end,
2635
-
);
2636
-
}
2637
-
2638
-
// 4. Emit <img> element (no syn_id - always visible)
2639
-
self.write("<img src=\"")?;
2640
-
let resolved_url = self
2641
-
.image_resolver
2642
-
.as_ref()
2643
-
.and_then(|r| r.resolve_image_url(&dest_url));
2644
-
if let Some(ref cdn_url) = resolved_url {
2645
-
escape_href(&mut self.writer, cdn_url)?;
2646
-
} else {
2647
-
escape_href(&mut self.writer, &dest_url)?;
2648
-
}
2649
-
self.write("\" alt=\"")?;
2650
-
escape_html(&mut self.writer, alt_text)?;
2651
-
self.write("\"")?;
2652
-
if !title.is_empty() {
2653
-
self.write(" title=\"")?;
2654
-
escape_html(&mut self.writer, &title)?;
2655
-
self.write("\"")?;
2656
-
}
2657
-
if let Some(attrs) = attrs {
2658
-
if !attrs.classes.is_empty() {
2659
-
self.write(" class=\"")?;
2660
-
for (i, class) in attrs.classes.iter().enumerate() {
2661
-
if i > 0 {
2662
-
self.write(" ")?;
2663
-
}
2664
-
escape_html(&mut self.writer, class)?;
2665
-
}
2666
-
self.write("\"")?;
2667
-
}
2668
-
for (attr, value) in &attrs.attrs {
2669
-
self.write(" ")?;
2670
-
escape_html(&mut self.writer, attr)?;
2671
-
self.write("=\"")?;
2672
-
escape_html(&mut self.writer, value)?;
2673
-
self.write("\"")?;
2674
-
}
2675
-
}
2676
-
self.write(" />")?;
2677
-
2678
-
// Consume the text events for alt (they're still in the iterator)
2679
-
// Use consume_until_end() since we already wrote alt text from source
2680
-
self.consume_until_end();
2681
-
2682
-
// Update offsets
2683
-
self.last_char_offset = closing_char_end;
2684
-
self.last_byte_offset = range.end;
2685
-
2686
-
Ok(())
2687
-
}
2688
-
Tag::Embed {
2689
-
embed_type,
2690
-
dest_url,
2691
-
title,
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;
2765
-
Ok(())
2766
-
}
2767
-
}
2768
-
}
2769
-
2770
-
fn end_tag(
2771
-
&mut self,
2772
-
tag: markdown_weaver::TagEnd,
2773
-
range: Range<usize>,
2774
-
) -> Result<(), fmt::Error> {
2775
-
use markdown_weaver::TagEnd;
2776
-
2777
-
// Emit tag HTML first
2778
-
let result = match tag {
2779
-
TagEnd::HtmlBlock => {
2780
-
// Capture paragraph boundary info BEFORE writing closing HTML
2781
-
// Skip if inside a list - list owns the paragraph boundary
2782
-
let para_boundary = if self.list_depth == 0 {
2783
-
self.current_paragraph_start
2784
-
.take()
2785
-
.map(|(byte_start, char_start)| {
2786
-
(
2787
-
byte_start..self.last_byte_offset,
2788
-
char_start..self.last_char_offset,
2789
-
)
2790
-
})
2791
-
} else {
2792
-
None
2793
-
};
2794
-
2795
-
// Write closing HTML to current segment
2796
-
self.end_node();
2797
-
self.write("</p>\n")?;
2798
-
2799
-
// Now finalize paragraph (starts new segment)
2800
-
if let Some((byte_range, char_range)) = para_boundary {
2801
-
self.finalize_paragraph(byte_range, char_range);
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 {
2809
-
self.current_paragraph_start
2810
-
.take()
2811
-
.map(|(byte_start, char_start)| {
2812
-
(
2813
-
byte_start..self.last_byte_offset,
2814
-
char_start..self.last_char_offset,
2815
-
)
2816
-
})
2817
-
} else {
2818
-
None
2819
-
};
2820
-
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 {
2828
-
self.finalize_paragraph(byte_range, char_range);
2829
-
}
2830
-
Ok(())
2831
-
}
2832
-
TagEnd::Heading(level) => {
2833
-
// Capture paragraph boundary info BEFORE writing closing HTML
2834
-
let para_boundary =
2835
-
self.current_paragraph_start
2836
-
.take()
2837
-
.map(|(byte_start, char_start)| {
2838
-
(
2839
-
byte_start..self.last_byte_offset,
2840
-
char_start..self.last_char_offset,
2841
-
)
2842
-
});
2843
-
2844
-
// Write closing HTML to current segment
2845
-
self.end_node();
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 {
2853
-
self.finalize_paragraph(byte_range, char_range);
2854
-
}
2855
-
Ok(())
2856
-
}
2857
-
TagEnd::Table => {
2858
-
if self.render_tables_as_markdown {
2859
-
// Emit the raw markdown table
2860
-
if let Some(start) = self.table_start_offset.take() {
2861
-
let table_text = &self.source[start..range.end];
2862
-
self.in_non_writing_block = false;
2863
-
2864
-
// Wrap in a pre or div for styling
2865
-
self.write("<pre class=\"table-markdown\">")?;
2866
-
escape_html(&mut self.writer, table_text)?;
2867
-
self.write("</pre>\n")?;
2868
-
}
2869
-
Ok(())
2870
-
} else {
2871
-
self.write("</tbody></table>\n")
2872
-
}
2873
-
}
2874
-
TagEnd::TableHead => {
2875
-
if self.render_tables_as_markdown {
2876
-
Ok(()) // Skip HTML rendering
2877
-
} else {
2878
-
self.write("</tr></thead><tbody>\n")?;
2879
-
self.table_state = TableState::Body;
2880
-
Ok(())
2881
-
}
2882
-
}
2883
-
TagEnd::TableRow => {
2884
-
if self.render_tables_as_markdown {
2885
-
Ok(()) // Skip HTML rendering
2886
-
} else {
2887
-
self.write("</tr>\n")
2888
-
}
2889
-
}
2890
-
TagEnd::TableCell => {
2891
-
if self.render_tables_as_markdown {
2892
-
Ok(()) // Skip HTML rendering
2893
-
} else {
2894
-
match self.table_state {
2895
-
TableState::Head => self.write("</th>")?,
2896
-
TableState::Body => self.write("</td>")?,
2897
-
}
2898
-
self.table_cell_index += 1;
2899
-
Ok(())
2900
-
}
2901
-
}
2902
-
TagEnd::BlockQuote(_) => {
2903
-
// If pending_blockquote_range is still set, the blockquote was empty
2904
-
// (no paragraph inside). Emit the > as its own minimal paragraph.
2905
-
let mut para_boundary = None;
2906
-
if let Some(bq_range) = self.pending_blockquote_range.take() {
2907
-
if bq_range.start < bq_range.end {
2908
-
let raw_text = &self.source[bq_range.clone()];
2909
-
if let Some(gt_pos) = raw_text.find('>') {
2910
-
let para_byte_start = bq_range.start + gt_pos;
2911
-
let para_char_start = self.last_char_offset;
2912
-
2913
-
// Create a minimal paragraph for the empty blockquote
2914
-
let node_id = self.gen_node_id();
2915
-
write!(&mut self.writer, "<div id=\"{}\"", node_id)?;
2916
-
2917
-
// Record start-of-node mapping for cursor positioning
2918
-
self.offset_maps.push(OffsetMapping {
2919
-
byte_range: para_byte_start..para_byte_start,
2920
-
char_range: para_char_start..para_char_start,
2921
-
node_id: node_id.clone(),
2922
-
char_offset_in_node: gt_pos,
2923
-
child_index: Some(0),
2924
-
utf16_len: 0,
2925
-
});
2926
-
2927
-
// Emit the > as block syntax
2928
-
let syntax = &raw_text[gt_pos..gt_pos + 1];
2929
-
self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?;
2930
-
2931
-
self.write("</div>\n")?;
2932
-
self.end_node();
2933
-
2934
-
// Capture paragraph boundary for later finalization
2935
-
let byte_range = para_byte_start..bq_range.end;
2936
-
let char_range = para_char_start..self.last_char_offset;
2937
-
para_boundary = Some((byte_range, char_range));
2938
-
}
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 {
2946
-
self.finalize_paragraph(byte_range, char_range);
2947
-
}
2948
-
Ok(())
2949
-
}
2950
-
TagEnd::CodeBlock => {
2951
-
use std::sync::LazyLock;
2952
-
use syntect::parsing::SyntaxSet;
2953
-
static SYNTAX_SET: LazyLock<SyntaxSet> =
2954
-
LazyLock::new(|| SyntaxSet::load_defaults_newlines());
2955
-
2956
-
if let Some((lang, buffer)) = self.code_buffer.take() {
2957
-
// Create offset mapping for code block content if we tracked ranges
2958
-
if let (Some(code_byte_range), Some(code_char_range)) = (
2959
-
self.code_buffer_byte_range.take(),
2960
-
self.code_buffer_char_range.take(),
2961
-
) {
2962
-
// Record mapping before writing HTML
2963
-
// (current_node_id should be set by start_tag for CodeBlock)
2964
-
self.record_mapping(code_byte_range, code_char_range);
2965
-
}
2966
-
2967
-
// Get node_id for data-node-id attribute (needed for cursor positioning)
2968
-
let node_id = self.current_node_id.clone();
2969
-
2970
-
if let Some(ref lang_str) = lang {
2971
-
// Use a temporary String buffer for syntect
2972
-
let mut temp_output = String::new();
2973
-
match weaver_renderer::code_pretty::highlight(
2974
-
&SYNTAX_SET,
2975
-
Some(lang_str),
2976
-
&buffer,
2977
-
&mut temp_output,
2978
-
) {
2979
-
Ok(_) => {
2980
-
// Inject data-node-id into the <pre> tag for cursor positioning
2981
-
if let Some(ref nid) = node_id {
2982
-
let injected = temp_output.replacen(
2983
-
"<pre>",
2984
-
&format!("<pre data-node-id=\"{}\">", nid),
2985
-
1,
2986
-
);
2987
-
self.write(&injected)?;
2988
-
} else {
2989
-
self.write(&temp_output)?;
2990
-
}
2991
-
}
2992
-
Err(_) => {
2993
-
// Fallback to plain code block
2994
-
if let Some(ref nid) = node_id {
2995
-
write!(
2996
-
&mut self.writer,
2997
-
"<pre data-node-id=\"{}\"><code class=\"language-",
2998
-
nid
2999
-
)?;
3000
-
} else {
3001
-
self.write("<pre><code class=\"language-")?;
3002
-
}
3003
-
escape_html(&mut self.writer, lang_str)?;
3004
-
self.write("\">")?;
3005
-
escape_html_body_text(&mut self.writer, &buffer)?;
3006
-
self.write("</code></pre>\n")?;
3007
-
}
3008
-
}
3009
-
} else {
3010
-
if let Some(ref nid) = node_id {
3011
-
write!(&mut self.writer, "<pre data-node-id=\"{}\"><code>", nid)?;
3012
-
} else {
3013
-
self.write("<pre><code>")?;
3014
-
}
3015
-
escape_html_body_text(&mut self.writer, &buffer)?;
3016
-
self.write("</code></pre>\n")?;
3017
-
}
3018
-
3019
-
// End node tracking
3020
-
self.end_node();
3021
-
} else {
3022
-
self.write("</code></pre>\n")?;
3023
-
}
3024
-
3025
-
// Emit closing ``` (emit_gap_before is skipped while buffering)
3026
-
// Track the opening span index and char start before we potentially clear them
3027
-
let opening_span_idx = self.code_block_opening_span_idx.take();
3028
-
let code_block_start = self.code_block_char_start.take();
3029
-
3030
-
if range.start < range.end {
3031
-
let raw_text = &self.source[range.clone()];
3032
-
if let Some(fence_line) = raw_text.lines().last() {
3033
-
if fence_line.trim().starts_with("```") {
3034
-
let fence = fence_line.trim();
3035
-
let fence_char_len = fence.chars().count();
3036
-
3037
-
let syn_id = self.gen_syn_id();
3038
-
let char_start = self.last_char_offset;
3039
-
let char_end = char_start + fence_char_len;
3040
-
3041
-
write!(
3042
-
&mut self.writer,
3043
-
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
3044
-
syn_id, char_start, char_end
3045
-
)?;
3046
-
escape_html(&mut self.writer, fence)?;
3047
-
self.write("</span>")?;
3048
-
3049
-
self.last_char_offset += fence_char_len;
3050
-
self.last_byte_offset += fence.len();
3051
-
3052
-
// Compute formatted_range for entire code block (opening fence to closing fence)
3053
-
let formatted_range =
3054
-
code_block_start.map(|start| start..self.last_char_offset);
3055
-
3056
-
// Update opening fence span with formatted_range
3057
-
if let (Some(idx), Some(fr)) =
3058
-
(opening_span_idx, formatted_range.as_ref())
3059
-
{
3060
-
if let Some(span) = self.syntax_spans.get_mut(idx) {
3061
-
span.formatted_range = Some(fr.clone());
3062
-
}
3063
-
}
3064
-
3065
-
// Push closing fence span with formatted_range
3066
-
self.syntax_spans.push(SyntaxSpanInfo {
3067
-
syn_id,
3068
-
char_range: char_start..char_end,
3069
-
syntax_type: SyntaxType::Block,
3070
-
formatted_range,
3071
-
});
3072
-
}
3073
-
}
3074
-
}
3075
-
3076
-
// Finalize code block paragraph
3077
-
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
3078
-
let byte_range = byte_start..self.last_byte_offset;
3079
-
let char_range = char_start..self.last_char_offset;
3080
-
self.finalize_paragraph(byte_range, char_range);
3081
-
}
3082
-
3083
-
Ok(())
3084
-
}
3085
-
TagEnd::List(true) => {
3086
-
self.list_depth = self.list_depth.saturating_sub(1);
3087
-
// Capture paragraph boundary BEFORE writing closing HTML
3088
-
let para_boundary =
3089
-
self.current_paragraph_start
3090
-
.take()
3091
-
.map(|(byte_start, char_start)| {
3092
-
(
3093
-
byte_start..self.last_byte_offset,
3094
-
char_start..self.last_char_offset,
3095
-
)
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 {
3103
-
self.finalize_paragraph(byte_range, char_range);
3104
-
}
3105
-
Ok(())
3106
-
}
3107
-
TagEnd::List(false) => {
3108
-
self.list_depth = self.list_depth.saturating_sub(1);
3109
-
// Capture paragraph boundary BEFORE writing closing HTML
3110
-
let para_boundary =
3111
-
self.current_paragraph_start
3112
-
.take()
3113
-
.map(|(byte_start, char_start)| {
3114
-
(
3115
-
byte_start..self.last_byte_offset,
3116
-
char_start..self.last_char_offset,
3117
-
)
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 {
3125
-
self.finalize_paragraph(byte_range, char_range);
3126
-
}
3127
-
Ok(())
3128
-
}
3129
-
TagEnd::Item => {
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")
3140
-
}
3141
-
TagEnd::DefinitionListDefinition => {
3142
-
self.end_node();
3143
-
self.write("</dd>\n")
3144
-
}
3145
-
TagEnd::Emphasis => {
3146
-
// Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
3147
-
self.write("</em>")?;
3148
-
self.emit_gap_before(range.end)?;
3149
-
self.finalize_paired_inline_format();
3150
-
Ok(())
3151
-
}
3152
-
TagEnd::Superscript => self.write("</sup>"),
3153
-
TagEnd::Subscript => self.write("</sub>"),
3154
-
TagEnd::Strong => {
3155
-
// Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
3156
-
self.write("</strong>")?;
3157
-
self.emit_gap_before(range.end)?;
3158
-
self.finalize_paired_inline_format();
3159
-
Ok(())
3160
-
}
3161
-
TagEnd::Strikethrough => {
3162
-
// Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
3163
-
self.write("</s>")?;
3164
-
self.emit_gap_before(range.end)?;
3165
-
self.finalize_paired_inline_format();
3166
-
Ok(())
3167
-
}
3168
-
TagEnd::Link => {
3169
-
self.write("</a>")?;
3170
-
// Check if this is a wiki link (ends with ]]) vs regular link (ends with ))
3171
-
let raw_text = &self.source[range.clone()];
3172
-
if raw_text.ends_with("]]") {
3173
-
// WikiLink: emit ]] as closing syntax
3174
-
let syn_id = self.gen_syn_id();
3175
-
let char_start = self.last_char_offset;
3176
-
let char_end = char_start + 2;
3177
-
3178
-
write!(
3179
-
&mut self.writer,
3180
-
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
3181
-
syn_id, char_start, char_end
3182
-
)?;
3183
-
3184
-
self.syntax_spans.push(SyntaxSpanInfo {
3185
-
syn_id,
3186
-
char_range: char_start..char_end,
3187
-
syntax_type: SyntaxType::Inline,
3188
-
formatted_range: None, // Will be set by finalize
3189
-
});
3190
-
3191
-
self.last_char_offset = char_end;
3192
-
self.last_byte_offset = range.end;
3193
-
} else {
3194
-
self.emit_gap_before(range.end)?;
3195
-
}
3196
-
self.finalize_paired_inline_format();
3197
-
Ok(())
3198
-
}
3199
-
TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event
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(())
3284
-
}
3285
-
};
3286
-
3287
-
result?;
3288
-
3289
-
// Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough)
3290
-
// is handled INSIDE their respective match arms above, AFTER writing the closing HTML.
3291
-
// This ensures the closing syntax span appears OUTSIDE the formatted element.
3292
-
// Other End events have their closing syntax emitted by emit_gap_before() in the main loop.
3293
-
3294
-
Ok(())
3295
-
}
3296
-
}
3297
-
3298
-
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
3299
-
EditorWriter<'a, I, E, R>
3300
-
{
3301
-
fn write_embed(
3302
-
&mut self,
3303
-
range: Range<usize>,
3304
-
embed_type: EmbedType,
3305
-
dest_url: CowStr<'_>,
3306
-
title: CowStr<'_>,
3307
-
id: CowStr<'_>,
3308
-
attrs: Option<markdown_weaver::WeaverAttributes<'_>>,
3309
-
) -> Result<(), fmt::Error> {
3310
-
// Embed rendering: all syntax elements share one syn_id for visibility toggling
3311
-
// Structure: ![[ url-as-link ]] <embed-content>
3312
-
let raw_text = &self.source[range.clone()];
3313
-
let syn_id = self.gen_syn_id();
3314
-
let opening_char_start = self.last_char_offset;
3315
-
3316
-
// Extract the URL from raw text (between ![[ and ]])
3317
-
let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") {
3318
-
&raw_text[3..raw_text.len() - 2]
3319
-
} else {
3320
-
dest_url.as_ref()
3321
-
};
3322
-
3323
-
// Calculate char positions
3324
-
let url_char_len = url_text.chars().count();
3325
-
let opening_char_end = opening_char_start + 3; // "![["
3326
-
let url_char_start = opening_char_end;
3327
-
let url_char_end = url_char_start + url_char_len;
3328
-
let closing_char_start = url_char_end;
3329
-
let closing_char_end = closing_char_start + 2; // "]]"
3330
-
let formatted_range = opening_char_start..closing_char_end;
3331
-
3332
-
// 1. Emit opening ![[ syntax span
3333
-
if raw_text.starts_with("![[") {
3334
-
write!(
3335
-
&mut self.writer,
3336
-
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>",
3337
-
syn_id, opening_char_start, opening_char_end
3338
-
)?;
3339
-
3340
-
self.syntax_spans.push(SyntaxSpanInfo {
3341
-
syn_id: syn_id.clone(),
3342
-
char_range: opening_char_start..opening_char_end,
3343
-
syntax_type: SyntaxType::Inline,
3344
-
formatted_range: Some(formatted_range.clone()),
3345
-
});
3346
-
3347
-
self.record_mapping(
3348
-
range.start..range.start + 3,
3349
-
opening_char_start..opening_char_end,
3350
-
);
3351
-
}
3352
-
3353
-
// 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax)
3354
-
let url = dest_url.as_ref();
3355
-
let link_href = if url.starts_with("at://") {
3356
-
format!("https://alpha.weaver.sh/record/{}", url)
3357
-
} else {
3358
-
url.to_string()
3359
-
};
3360
-
3361
-
write!(
3362
-
&mut self.writer,
3363
-
"<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">",
3364
-
link_href, syn_id, url_char_start, url_char_end
3365
-
)?;
3366
-
escape_html(&mut self.writer, url_text)?;
3367
-
self.write("</a>")?;
3368
-
3369
-
self.syntax_spans.push(SyntaxSpanInfo {
3370
-
syn_id: syn_id.clone(),
3371
-
char_range: url_char_start..url_char_end,
3372
-
syntax_type: SyntaxType::Inline,
3373
-
formatted_range: Some(formatted_range.clone()),
3374
-
});
3375
-
3376
-
self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end);
3377
-
3378
-
// 3. Emit closing ]] syntax span
3379
-
if raw_text.ends_with("]]") {
3380
-
write!(
3381
-
&mut self.writer,
3382
-
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
3383
-
syn_id, closing_char_start, closing_char_end
3384
-
)?;
3385
-
3386
-
self.syntax_spans.push(SyntaxSpanInfo {
3387
-
syn_id: syn_id.clone(),
3388
-
char_range: closing_char_start..closing_char_end,
3389
-
syntax_type: SyntaxType::Inline,
3390
-
formatted_range: Some(formatted_range.clone()),
3391
-
});
3392
-
3393
-
self.record_mapping(
3394
-
range.end - 2..range.end,
3395
-
closing_char_start..closing_char_end,
3396
-
);
3397
-
}
3398
-
3399
-
// Collect AT URI for later resolution
3400
-
if url.starts_with("at://") || url.starts_with("did:") {
3401
-
self.ref_collector.add_at_embed(
3402
-
url,
3403
-
if title.is_empty() {
3404
-
None
3405
-
} else {
3406
-
Some(title.as_ref())
3407
-
},
3408
-
);
3409
-
}
3410
-
3411
-
// 4. Emit the actual embed content
3412
-
// Try to get content from attributes first
3413
-
let content_from_attrs = if let Some(ref attrs) = attrs {
3414
-
attrs
3415
-
.attrs
3416
-
.iter()
3417
-
.find(|(k, _)| k.as_ref() == "content")
3418
-
.map(|(_, v)| v.as_ref().to_string())
3419
-
} else {
3420
-
None
3421
-
};
3422
-
3423
-
// If no content in attrs, try provider
3424
-
let content = if let Some(content) = content_from_attrs {
3425
-
Some(content)
3426
-
} else if let Some(ref provider) = self.embed_provider {
3427
-
let tag = Tag::Embed {
3428
-
embed_type,
3429
-
dest_url: dest_url.clone(),
3430
-
title: title.clone(),
3431
-
id: id.clone(),
3432
-
attrs: attrs.clone(),
3433
-
};
3434
-
provider.get_embed_content(&tag)
3435
-
} else {
3436
-
None
3437
-
};
3438
-
3439
-
if let Some(html_content) = content {
3440
-
// Write the pre-rendered content directly
3441
-
self.write(&html_content)?;
3442
-
} else {
3443
-
// Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs)
3444
-
self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?;
3445
-
self.write("<span class=\"embed-loading\">Loading embed...</span>")?;
3446
-
self.write("</div>")?;
3447
-
}
3448
-
3449
-
// Consume the text events for the URL (they're still in the iterator)
3450
-
// Use consume_until_end() since we already wrote the URL from source
3451
-
self.consume_until_end();
3452
-
3453
-
// Update offsets
3454
-
self.last_char_offset = closing_char_end;
3455
-
self.last_byte_offset = range.end;
3456
-
3457
Ok(())
3458
}
3459
}
···
5
//!
6
//! Uses Parser::into_offset_iter() to track gaps between events, which
7
//! represent consumed formatting characters.
8
+
pub mod embed;
9
+
pub mod segmented;
10
+
pub mod syntax;
11
+
pub mod tags;
12
+
13
+
use crate::components::editor::writer::segmented::SegmentedWriter;
14
+
pub use embed::{EditorImageResolver, EmbedContentProvider, ImageResolver};
15
+
pub use syntax::{SyntaxSpanInfo, SyntaxType};
16
+
17
#[allow(unused_imports)]
18
use super::offset_map::{OffsetMapping, RenderResult};
0
19
use loro::LoroText;
20
+
use markdown_weaver::{Alignment, CowStr, Event, WeaverAttributes};
21
+
use markdown_weaver_escape::{StrWrite, escape_html, escape_html_body_text_with_char_count};
0
0
0
0
0
0
22
use std::collections::HashMap;
23
use std::fmt;
24
use std::ops::Range;
25
+
use weaver_common::EntryIndex;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
26
27
/// Result of rendering with the EditorWriter.
28
#[derive(Debug, Clone)]
···
45
pub collected_refs_by_paragraph: Vec<Vec<weaver_common::ExtractedRef>>,
46
}
47
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
48
/// Tracks the type of wrapper element emitted for WeaverBlock prefix
49
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50
enum WrapperElement {
51
Aside,
52
Div,
53
+
}
54
+
55
+
#[derive(Debug, Clone, Copy)]
56
+
pub enum TableState {
57
+
Head,
58
+
Body,
59
}
60
61
/// HTML writer that preserves markdown formatting characters.
···
150
current_footnote_def: Option<(String, usize, usize)>,
151
152
_phantom: std::marker::PhantomData<&'a ()>,
0
0
0
0
0
0
153
}
154
155
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
···
424
}
425
}
426
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
427
/// Generate a unique node ID.
428
/// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`.
429
/// Otherwise produces `n{counter}` for backwards compatibility.
···
718
let key = key.trim();
719
let value = value.trim();
720
if !key.is_empty() && !value.is_empty() {
721
+
attrs.push((
722
+
CowStr::from(key.to_string()),
723
+
CowStr::from(value.to_string()),
724
+
));
725
}
726
} else {
727
// No colon - treat as class, strip leading dot if present
···
1347
self.weaver_block_buffer.push_str(&text);
1348
}
1349
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
1350
Ok(())
1351
}
1352
}
+371
crates/weaver-app/src/components/editor/writer/embed.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
use core::fmt;
2
+
use std::ops::Range;
3
+
4
+
use jacquard::types::{ident::AtIdentifier, string::Rkey};
5
+
use markdown_weaver::{CowStr, EmbedType, Event, Tag};
6
+
use markdown_weaver_escape::{StrWrite, escape_html};
7
+
use weaver_common::ResolvedContent;
8
+
9
+
use crate::components::editor::{
10
+
SyntaxSpanInfo, SyntaxType, document::EditorImage, writer::EditorWriter,
11
+
};
12
+
13
+
/// Synchronous callback for injecting embed content
14
+
///
15
+
/// Takes the embed tag and returns optional HTML content to inject.
16
+
pub trait EmbedContentProvider {
17
+
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>;
18
+
}
19
+
20
+
impl EmbedContentProvider for () {
21
+
fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> {
22
+
None
23
+
}
24
+
}
25
+
26
+
impl EmbedContentProvider for &ResolvedContent {
27
+
fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> {
28
+
if let Tag::Embed { dest_url, .. } = tag {
29
+
let url = dest_url.as_ref();
30
+
if url.starts_with("at://") {
31
+
if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) {
32
+
return ResolvedContent::get_embed_content(self, &at_uri)
33
+
.map(|s| s.to_string());
34
+
}
35
+
}
36
+
}
37
+
None
38
+
}
39
+
}
40
+
41
+
/// Resolves image URLs to CDN URLs based on stored images.
42
+
///
43
+
/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png").
44
+
/// This trait maps those names to the actual CDN URL using the blob CID and owner DID.
45
+
pub trait ImageResolver {
46
+
/// Resolve an image URL from markdown to a CDN URL.
47
+
///
48
+
/// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL.
49
+
fn resolve_image_url(&self, url: &str) -> Option<String>;
50
+
}
51
+
52
+
impl ImageResolver for () {
53
+
fn resolve_image_url(&self, _url: &str) -> Option<String> {
54
+
None
55
+
}
56
+
}
57
+
58
+
/// Concrete image resolver that maps image names to URLs.
59
+
///
60
+
/// Resolved image path type
61
+
#[derive(Clone, Debug)]
62
+
enum ResolvedImage {
63
+
/// Data URL for immediate preview (still uploading)
64
+
Pending(String),
65
+
/// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}`
66
+
Draft {
67
+
blob_rkey: Rkey<'static>,
68
+
ident: AtIdentifier<'static>,
69
+
},
70
+
/// Published image: `/image/{ident}/{entry_rkey}/{name}`
71
+
Published {
72
+
entry_rkey: Rkey<'static>,
73
+
ident: AtIdentifier<'static>,
74
+
},
75
+
}
76
+
77
+
/// Resolves image paths in the editor.
78
+
///
79
+
/// Supports three states for images:
80
+
/// - Pending: uses data URL for immediate preview while upload is in progress
81
+
/// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}`
82
+
/// - Published: uses path format `/image/{did}/{entry_rkey}/{name}`
83
+
///
84
+
/// Image URLs in markdown use the format `/image/{name}`.
85
+
#[derive(Clone, Default)]
86
+
pub struct EditorImageResolver {
87
+
/// All resolved images: name -> resolved path info
88
+
images: std::collections::HashMap<String, ResolvedImage>,
89
+
}
90
+
91
+
impl EditorImageResolver {
92
+
pub fn new() -> Self {
93
+
Self::default()
94
+
}
95
+
96
+
/// Add a pending image with a data URL for immediate preview.
97
+
pub fn add_pending(&mut self, name: String, data_url: String) {
98
+
self.images.insert(name, ResolvedImage::Pending(data_url));
99
+
}
100
+
101
+
/// Promote a pending image to uploaded (draft) status.
102
+
pub fn promote_to_uploaded(
103
+
&mut self,
104
+
name: &str,
105
+
blob_rkey: Rkey<'static>,
106
+
ident: AtIdentifier<'static>,
107
+
) {
108
+
self.images
109
+
.insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident });
110
+
}
111
+
112
+
/// Add an already-uploaded draft image.
113
+
pub fn add_uploaded(
114
+
&mut self,
115
+
name: String,
116
+
blob_rkey: Rkey<'static>,
117
+
ident: AtIdentifier<'static>,
118
+
) {
119
+
self.images
120
+
.insert(name, ResolvedImage::Draft { blob_rkey, ident });
121
+
}
122
+
123
+
/// Add a published image.
124
+
pub fn add_published(
125
+
&mut self,
126
+
name: String,
127
+
entry_rkey: Rkey<'static>,
128
+
ident: AtIdentifier<'static>,
129
+
) {
130
+
self.images
131
+
.insert(name, ResolvedImage::Published { entry_rkey, ident });
132
+
}
133
+
134
+
/// Check if an image is pending upload.
135
+
pub fn is_pending(&self, name: &str) -> bool {
136
+
matches!(self.images.get(name), Some(ResolvedImage::Pending(_)))
137
+
}
138
+
139
+
/// Build a resolver from editor images and user identifier.
140
+
///
141
+
/// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included.
142
+
/// For published mode (entry_rkey=Some), all images are included.
143
+
pub fn from_images<'a>(
144
+
images: impl IntoIterator<Item = &'a EditorImage>,
145
+
ident: AtIdentifier<'static>,
146
+
entry_rkey: Option<Rkey<'static>>,
147
+
) -> Self {
148
+
use jacquard::IntoStatic;
149
+
150
+
let mut resolver = Self::new();
151
+
for editor_image in images {
152
+
// Get the name from the Image (use alt text as fallback if name is empty)
153
+
let name = editor_image
154
+
.image
155
+
.name
156
+
.as_ref()
157
+
.map(|n| n.to_string())
158
+
.unwrap_or_else(|| editor_image.image.alt.to_string());
159
+
160
+
if name.is_empty() {
161
+
continue;
162
+
}
163
+
164
+
match &entry_rkey {
165
+
// Published mode: use entry rkey for all images
166
+
Some(rkey) => {
167
+
resolver.add_published(name, rkey.clone(), ident.clone());
168
+
}
169
+
// Draft mode: use published_blob_uri rkey
170
+
None => {
171
+
let blob_rkey = match &editor_image.published_blob_uri {
172
+
Some(uri) => match uri.rkey() {
173
+
Some(rkey) => rkey.0.clone().into_static(),
174
+
None => continue,
175
+
},
176
+
None => continue,
177
+
};
178
+
resolver.add_uploaded(name, blob_rkey, ident.clone());
179
+
}
180
+
}
181
+
}
182
+
resolver
183
+
}
184
+
}
185
+
186
+
impl ImageResolver for EditorImageResolver {
187
+
fn resolve_image_url(&self, url: &str) -> Option<String> {
188
+
// Extract image name from /image/{name} format
189
+
let name = url.strip_prefix("/image/").unwrap_or(url);
190
+
191
+
let resolved = self.images.get(name)?;
192
+
match resolved {
193
+
ResolvedImage::Pending(data_url) => Some(data_url.clone()),
194
+
ResolvedImage::Draft { blob_rkey, ident } => {
195
+
Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name))
196
+
}
197
+
ResolvedImage::Published { entry_rkey, ident } => {
198
+
Some(format!("/image/{}/{}/{}", ident, entry_rkey, name))
199
+
}
200
+
}
201
+
}
202
+
}
203
+
204
+
impl ImageResolver for &EditorImageResolver {
205
+
fn resolve_image_url(&self, url: &str) -> Option<String> {
206
+
(*self).resolve_image_url(url)
207
+
}
208
+
}
209
+
210
+
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
211
+
EditorWriter<'a, I, E, R>
212
+
{
213
+
pub(crate) fn write_embed(
214
+
&mut self,
215
+
range: Range<usize>,
216
+
embed_type: EmbedType,
217
+
dest_url: CowStr<'_>,
218
+
title: CowStr<'_>,
219
+
id: CowStr<'_>,
220
+
attrs: Option<markdown_weaver::WeaverAttributes<'_>>,
221
+
) -> Result<(), fmt::Error> {
222
+
// Embed rendering: all syntax elements share one syn_id for visibility toggling
223
+
// Structure: ![[ url-as-link ]] <embed-content>
224
+
let raw_text = &self.source[range.clone()];
225
+
let syn_id = self.gen_syn_id();
226
+
let opening_char_start = self.last_char_offset;
227
+
228
+
// Extract the URL from raw text (between ![[ and ]])
229
+
let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") {
230
+
&raw_text[3..raw_text.len() - 2]
231
+
} else {
232
+
dest_url.as_ref()
233
+
};
234
+
235
+
// Calculate char positions
236
+
let url_char_len = url_text.chars().count();
237
+
let opening_char_end = opening_char_start + 3; // "![["
238
+
let url_char_start = opening_char_end;
239
+
let url_char_end = url_char_start + url_char_len;
240
+
let closing_char_start = url_char_end;
241
+
let closing_char_end = closing_char_start + 2; // "]]"
242
+
let formatted_range = opening_char_start..closing_char_end;
243
+
244
+
// 1. Emit opening ![[ syntax span
245
+
if raw_text.starts_with("![[") {
246
+
write!(
247
+
&mut self.writer,
248
+
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>",
249
+
syn_id, opening_char_start, opening_char_end
250
+
)?;
251
+
252
+
self.syntax_spans.push(SyntaxSpanInfo {
253
+
syn_id: syn_id.clone(),
254
+
char_range: opening_char_start..opening_char_end,
255
+
syntax_type: SyntaxType::Inline,
256
+
formatted_range: Some(formatted_range.clone()),
257
+
});
258
+
259
+
self.record_mapping(
260
+
range.start..range.start + 3,
261
+
opening_char_start..opening_char_end,
262
+
);
263
+
}
264
+
265
+
// 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax)
266
+
let url = dest_url.as_ref();
267
+
let link_href = if url.starts_with("at://") {
268
+
format!("https://alpha.weaver.sh/record/{}", url)
269
+
} else {
270
+
url.to_string()
271
+
};
272
+
273
+
write!(
274
+
&mut self.writer,
275
+
"<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">",
276
+
link_href, syn_id, url_char_start, url_char_end
277
+
)?;
278
+
escape_html(&mut self.writer, url_text)?;
279
+
self.write("</a>")?;
280
+
281
+
self.syntax_spans.push(SyntaxSpanInfo {
282
+
syn_id: syn_id.clone(),
283
+
char_range: url_char_start..url_char_end,
284
+
syntax_type: SyntaxType::Inline,
285
+
formatted_range: Some(formatted_range.clone()),
286
+
});
287
+
288
+
self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end);
289
+
290
+
// 3. Emit closing ]] syntax span
291
+
if raw_text.ends_with("]]") {
292
+
write!(
293
+
&mut self.writer,
294
+
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
295
+
syn_id, closing_char_start, closing_char_end
296
+
)?;
297
+
298
+
self.syntax_spans.push(SyntaxSpanInfo {
299
+
syn_id: syn_id.clone(),
300
+
char_range: closing_char_start..closing_char_end,
301
+
syntax_type: SyntaxType::Inline,
302
+
formatted_range: Some(formatted_range.clone()),
303
+
});
304
+
305
+
self.record_mapping(
306
+
range.end - 2..range.end,
307
+
closing_char_start..closing_char_end,
308
+
);
309
+
}
310
+
311
+
// Collect AT URI for later resolution
312
+
if url.starts_with("at://") || url.starts_with("did:") {
313
+
self.ref_collector.add_at_embed(
314
+
url,
315
+
if title.is_empty() {
316
+
None
317
+
} else {
318
+
Some(title.as_ref())
319
+
},
320
+
);
321
+
}
322
+
323
+
// 4. Emit the actual embed content
324
+
// Try to get content from attributes first
325
+
let content_from_attrs = if let Some(ref attrs) = attrs {
326
+
attrs
327
+
.attrs
328
+
.iter()
329
+
.find(|(k, _)| k.as_ref() == "content")
330
+
.map(|(_, v)| v.as_ref().to_string())
331
+
} else {
332
+
None
333
+
};
334
+
335
+
// If no content in attrs, try provider
336
+
let content = if let Some(content) = content_from_attrs {
337
+
Some(content)
338
+
} else if let Some(ref provider) = self.embed_provider {
339
+
let tag = Tag::Embed {
340
+
embed_type,
341
+
dest_url: dest_url.clone(),
342
+
title: title.clone(),
343
+
id: id.clone(),
344
+
attrs: attrs.clone(),
345
+
};
346
+
provider.get_embed_content(&tag)
347
+
} else {
348
+
None
349
+
};
350
+
351
+
if let Some(html_content) = content {
352
+
// Write the pre-rendered content directly
353
+
self.write(&html_content)?;
354
+
} else {
355
+
// Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs)
356
+
self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?;
357
+
self.write("<span class=\"embed-loading\">Loading embed...</span>")?;
358
+
self.write("</div>")?;
359
+
}
360
+
361
+
// Consume the text events for the URL (they're still in the iterator)
362
+
// Use consume_until_end() since we already wrote the URL from source
363
+
self.consume_until_end();
364
+
365
+
// Update offsets
366
+
self.last_char_offset = closing_char_end;
367
+
self.last_byte_offset = range.end;
368
+
369
+
Ok(())
370
+
}
371
+
}
+66
crates/weaver-app/src/components/editor/writer/segmented.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
use core::fmt;
2
+
3
+
use markdown_weaver_escape::StrWrite;
4
+
5
+
/// Writer that segments output by paragraph boundaries.
6
+
///
7
+
/// Each paragraph's HTML is written to a separate String in the segments Vec.
8
+
/// Call `new_segment()` at paragraph boundaries to start a new segment.
9
+
#[derive(Debug, Clone, Default)]
10
+
pub struct SegmentedWriter {
11
+
pub segments: Vec<String>,
12
+
}
13
+
14
+
#[allow(dead_code)]
15
+
impl SegmentedWriter {
16
+
pub fn new() -> Self {
17
+
Self {
18
+
segments: vec![String::new()],
19
+
}
20
+
}
21
+
22
+
/// Start a new segment for the next paragraph.
23
+
pub fn new_segment(&mut self) {
24
+
self.segments.push(String::new());
25
+
}
26
+
27
+
/// Get the completed segments.
28
+
pub fn into_segments(self) -> Vec<String> {
29
+
self.segments
30
+
}
31
+
32
+
/// Get current segment count.
33
+
pub fn segment_count(&self) -> usize {
34
+
self.segments.len()
35
+
}
36
+
}
37
+
38
+
impl StrWrite for SegmentedWriter {
39
+
type Error = fmt::Error;
40
+
41
+
#[inline]
42
+
fn write_str(&mut self, s: &str) -> Result<(), Self::Error> {
43
+
if let Some(segment) = self.segments.last_mut() {
44
+
segment.push_str(s);
45
+
}
46
+
Ok(())
47
+
}
48
+
49
+
#[inline]
50
+
fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> {
51
+
if let Some(segment) = self.segments.last_mut() {
52
+
fmt::Write::write_fmt(segment, args)?;
53
+
}
54
+
Ok(())
55
+
}
56
+
}
57
+
58
+
impl fmt::Write for SegmentedWriter {
59
+
fn write_str(&mut self, s: &str) -> fmt::Result {
60
+
<Self as StrWrite>::write_str(self, s)
61
+
}
62
+
63
+
fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
64
+
<Self as StrWrite>::write_fmt(self, args)
65
+
}
66
+
}
+264
crates/weaver-app/src/components/editor/writer/syntax.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
use core::fmt;
2
+
use std::ops::Range;
3
+
4
+
use markdown_weaver::Event;
5
+
use markdown_weaver_escape::{StrWrite, escape_html};
6
+
7
+
use crate::components::editor::writer::{
8
+
EditorWriter,
9
+
embed::{EmbedContentProvider, ImageResolver},
10
+
};
11
+
12
+
/// Classification of markdown syntax characters
13
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14
+
pub enum SyntaxType {
15
+
/// Inline formatting: **, *, ~~, `, $, [, ], (, )
16
+
Inline,
17
+
/// Block formatting: #, >, -, *, 1., ```, ---
18
+
Block,
19
+
}
20
+
21
+
/// Information about a syntax span for conditional visibility
22
+
#[derive(Debug, Clone, PartialEq, Eq)]
23
+
pub struct SyntaxSpanInfo {
24
+
/// Unique identifier for this syntax span (e.g., "s0", "s1")
25
+
pub syn_id: String,
26
+
/// Source char range this syntax covers (just this marker)
27
+
pub char_range: Range<usize>,
28
+
/// Whether this is inline or block-level syntax
29
+
pub syntax_type: SyntaxType,
30
+
/// For paired inline syntax (**, *, etc), the full formatted region
31
+
/// from opening marker through content to closing marker.
32
+
/// When cursor is anywhere in this range, the syntax is visible.
33
+
pub formatted_range: Option<Range<usize>>,
34
+
}
35
+
36
+
impl SyntaxSpanInfo {
37
+
/// Adjust all position fields by a character delta.
38
+
///
39
+
/// This adjusts both `char_range` and `formatted_range` (if present) together,
40
+
/// ensuring they stay in sync. Use this instead of manually adjusting fields
41
+
/// to avoid forgetting one.
42
+
pub fn adjust_positions(&mut self, char_delta: isize) {
43
+
self.char_range.start = (self.char_range.start as isize + char_delta) as usize;
44
+
self.char_range.end = (self.char_range.end as isize + char_delta) as usize;
45
+
if let Some(ref mut fr) = self.formatted_range {
46
+
fr.start = (fr.start as isize + char_delta) as usize;
47
+
fr.end = (fr.end as isize + char_delta) as usize;
48
+
}
49
+
}
50
+
}
51
+
52
+
/// Classify syntax text as inline or block level
53
+
pub(crate) fn classify_syntax(text: &str) -> SyntaxType {
54
+
let trimmed = text.trim_start();
55
+
56
+
// Check for block-level markers
57
+
if trimmed.starts_with('#')
58
+
|| trimmed.starts_with('>')
59
+
|| trimmed.starts_with("```")
60
+
|| trimmed.starts_with("---")
61
+
|| (trimmed.starts_with('-')
62
+
&& trimmed
63
+
.chars()
64
+
.nth(1)
65
+
.map(|c| c.is_whitespace())
66
+
.unwrap_or(false))
67
+
|| (trimmed.starts_with('*')
68
+
&& trimmed
69
+
.chars()
70
+
.nth(1)
71
+
.map(|c| c.is_whitespace())
72
+
.unwrap_or(false))
73
+
|| trimmed
74
+
.chars()
75
+
.next()
76
+
.map(|c| c.is_ascii_digit())
77
+
.unwrap_or(false)
78
+
&& trimmed.contains('.')
79
+
{
80
+
SyntaxType::Block
81
+
} else {
82
+
SyntaxType::Inline
83
+
}
84
+
}
85
+
86
+
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
87
+
EditorWriter<'a, I, E, R>
88
+
{
89
+
/// Emit syntax span for a given range and record offset mapping
90
+
pub(crate) fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> {
91
+
if range.start < range.end {
92
+
let syntax = &self.source[range.clone()];
93
+
if !syntax.is_empty() {
94
+
let char_start = self.last_char_offset;
95
+
let syntax_char_len = syntax.chars().count();
96
+
let char_end = char_start + syntax_char_len;
97
+
98
+
tracing::trace!(
99
+
target: "weaver::writer",
100
+
byte_range = ?range,
101
+
char_range = ?(char_start..char_end),
102
+
syntax = %syntax.escape_debug(),
103
+
"emit_syntax"
104
+
);
105
+
106
+
// Whitespace-only content (trailing spaces, newlines) should be emitted
107
+
// as plain text, not wrapped in a hideable syntax span
108
+
let is_whitespace_only = syntax.trim().is_empty();
109
+
110
+
if is_whitespace_only {
111
+
// Emit as plain text with tracking span (not hideable)
112
+
let created_node = if self.current_node_id.is_none() {
113
+
let node_id = self.gen_node_id();
114
+
write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
115
+
self.begin_node(node_id);
116
+
true
117
+
} else {
118
+
false
119
+
};
120
+
121
+
escape_html(&mut self.writer, syntax)?;
122
+
123
+
// Record offset mapping BEFORE end_node (which clears current_node_id)
124
+
self.record_mapping(range.clone(), char_start..char_end);
125
+
self.last_char_offset = char_end;
126
+
self.last_byte_offset = range.end;
127
+
128
+
if created_node {
129
+
self.write("</span>")?;
130
+
self.end_node();
131
+
}
132
+
} else {
133
+
// Real syntax - wrap in hideable span
134
+
let syntax_type = classify_syntax(syntax);
135
+
let class = match syntax_type {
136
+
SyntaxType::Inline => "md-syntax-inline",
137
+
SyntaxType::Block => "md-syntax-block",
138
+
};
139
+
140
+
// Generate unique ID for this syntax span
141
+
let syn_id = self.gen_syn_id();
142
+
143
+
// If we're outside any node, create a wrapper span for tracking
144
+
let created_node = if self.current_node_id.is_none() {
145
+
let node_id = self.gen_node_id();
146
+
write!(
147
+
&mut self.writer,
148
+
"<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
149
+
node_id, class, syn_id, char_start, char_end
150
+
)?;
151
+
self.begin_node(node_id);
152
+
true
153
+
} else {
154
+
write!(
155
+
&mut self.writer,
156
+
"<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
157
+
class, syn_id, char_start, char_end
158
+
)?;
159
+
false
160
+
};
161
+
162
+
escape_html(&mut self.writer, syntax)?;
163
+
self.write("</span>")?;
164
+
165
+
// Record syntax span info for visibility toggling
166
+
self.syntax_spans.push(SyntaxSpanInfo {
167
+
syn_id,
168
+
char_range: char_start..char_end,
169
+
syntax_type,
170
+
formatted_range: None,
171
+
});
172
+
173
+
// Record offset mapping for this syntax
174
+
self.record_mapping(range.clone(), char_start..char_end);
175
+
self.last_char_offset = char_end;
176
+
self.last_byte_offset = range.end;
177
+
178
+
// Close wrapper if we created one
179
+
if created_node {
180
+
self.write("</span>")?;
181
+
self.end_node();
182
+
}
183
+
}
184
+
}
185
+
}
186
+
Ok(())
187
+
}
188
+
189
+
/// Emit syntax span inside current node with full offset tracking.
190
+
///
191
+
/// Use this for syntax markers that appear inside block elements (headings, lists,
192
+
/// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates
193
+
/// wrapper nodes, this assumes we're already inside a tracked node.
194
+
///
195
+
/// - Writes `<span class="md-syntax-{class}">{syntax}</span>`
196
+
/// - Records offset mapping (for cursor positioning)
197
+
/// - Updates both `last_char_offset` and `last_byte_offset`
198
+
pub(crate) fn emit_inner_syntax(
199
+
&mut self,
200
+
syntax: &str,
201
+
byte_start: usize,
202
+
syntax_type: SyntaxType,
203
+
) -> Result<(), fmt::Error> {
204
+
if syntax.is_empty() {
205
+
return Ok(());
206
+
}
207
+
208
+
let char_start = self.last_char_offset;
209
+
let syntax_char_len = syntax.chars().count();
210
+
let char_end = char_start + syntax_char_len;
211
+
let byte_end = byte_start + syntax.len();
212
+
213
+
let class_str = match syntax_type {
214
+
SyntaxType::Inline => "md-syntax-inline",
215
+
SyntaxType::Block => "md-syntax-block",
216
+
};
217
+
218
+
// Generate unique ID for this syntax span
219
+
let syn_id = self.gen_syn_id();
220
+
221
+
write!(
222
+
&mut self.writer,
223
+
"<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
224
+
class_str, syn_id, char_start, char_end
225
+
)?;
226
+
escape_html(&mut self.writer, syntax)?;
227
+
self.write("</span>")?;
228
+
229
+
// Record syntax span info for visibility toggling
230
+
self.syntax_spans.push(SyntaxSpanInfo {
231
+
syn_id,
232
+
char_range: char_start..char_end,
233
+
syntax_type,
234
+
formatted_range: None,
235
+
});
236
+
237
+
// Record offset mapping for cursor positioning
238
+
self.record_mapping(byte_start..byte_end, char_start..char_end);
239
+
240
+
self.last_char_offset = char_end;
241
+
self.last_byte_offset = byte_end;
242
+
243
+
Ok(())
244
+
}
245
+
246
+
/// Emit any gap between last position and next offset
247
+
pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> {
248
+
// Skip gap emission if we're inside a table being rendered as markdown
249
+
if self.table_start_offset.is_some() && self.render_tables_as_markdown {
250
+
return Ok(());
251
+
}
252
+
253
+
// Skip gap emission if we're buffering code block content
254
+
// The code block handler manages its own syntax emission
255
+
if self.code_buffer.is_some() {
256
+
return Ok(());
257
+
}
258
+
259
+
if next_offset > self.last_byte_offset {
260
+
self.emit_syntax(self.last_byte_offset..next_offset)?;
261
+
}
262
+
Ok(())
263
+
}
264
+
}
+1464
crates/weaver-app/src/components/editor/writer/tags.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
1
+
use core::fmt;
2
+
use std::ops::Range;
3
+
4
+
use markdown_weaver::{Alignment, BlockQuoteKind, CodeBlockKind, EmbedType, Event, LinkType, Tag};
5
+
use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text};
6
+
7
+
use crate::components::editor::{
8
+
OffsetMapping, SyntaxSpanInfo, SyntaxType,
9
+
writer::{
10
+
EditorWriter, TableState,
11
+
embed::{EmbedContentProvider, ImageResolver},
12
+
syntax::classify_syntax,
13
+
},
14
+
};
15
+
16
+
impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver>
17
+
EditorWriter<'a, I, E, R>
18
+
{
19
+
pub(crate) fn start_tag(
20
+
&mut self,
21
+
tag: Tag<'_>,
22
+
range: Range<usize>,
23
+
) -> Result<(), fmt::Error> {
24
+
// Check if this is a block-level tag that should have syntax inside
25
+
let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_));
26
+
27
+
// For inline tags, emit syntax before tag
28
+
if !is_block_tag && range.start < range.end {
29
+
let raw_text = &self.source[range.clone()];
30
+
let opening_syntax = match &tag {
31
+
Tag::Strong => {
32
+
if raw_text.starts_with("**") {
33
+
Some("**")
34
+
} else if raw_text.starts_with("__") {
35
+
Some("__")
36
+
} else {
37
+
None
38
+
}
39
+
}
40
+
Tag::Emphasis => {
41
+
if raw_text.starts_with("*") {
42
+
Some("*")
43
+
} else if raw_text.starts_with("_") {
44
+
Some("_")
45
+
} else {
46
+
None
47
+
}
48
+
}
49
+
Tag::Strikethrough => {
50
+
if raw_text.starts_with("~~") {
51
+
Some("~~")
52
+
} else {
53
+
None
54
+
}
55
+
}
56
+
Tag::Link { link_type, .. } => {
57
+
if matches!(link_type, LinkType::WikiLink { .. }) {
58
+
if raw_text.starts_with("[[") {
59
+
Some("[[")
60
+
} else {
61
+
None
62
+
}
63
+
} else if raw_text.starts_with('[') {
64
+
Some("[")
65
+
} else {
66
+
None
67
+
}
68
+
}
69
+
// Note: Tag::Image and Tag::Embed handle their own syntax spans
70
+
// in their respective handlers, so don't emit here
71
+
_ => None,
72
+
};
73
+
74
+
if let Some(syntax) = opening_syntax {
75
+
let syntax_type = classify_syntax(syntax);
76
+
let class = match syntax_type {
77
+
SyntaxType::Inline => "md-syntax-inline",
78
+
SyntaxType::Block => "md-syntax-block",
79
+
};
80
+
81
+
let char_start = self.last_char_offset;
82
+
let syntax_char_len = syntax.chars().count();
83
+
let char_end = char_start + syntax_char_len;
84
+
let syntax_byte_len = syntax.len();
85
+
86
+
// Generate unique ID for this syntax span
87
+
let syn_id = self.gen_syn_id();
88
+
89
+
write!(
90
+
&mut self.writer,
91
+
"<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
92
+
class, syn_id, char_start, char_end
93
+
)?;
94
+
escape_html(&mut self.writer, syntax)?;
95
+
self.write("</span>")?;
96
+
97
+
// Record syntax span info for visibility toggling
98
+
self.syntax_spans.push(SyntaxSpanInfo {
99
+
syn_id: syn_id.clone(),
100
+
char_range: char_start..char_end,
101
+
syntax_type,
102
+
formatted_range: None, // Will be updated when closing tag is emitted
103
+
});
104
+
105
+
// Record offset mapping for cursor positioning
106
+
// This is critical - without it, current_node_char_offset is wrong
107
+
// and all subsequent cursor positions are shifted
108
+
let byte_start = range.start;
109
+
let byte_end = range.start + syntax_byte_len;
110
+
self.record_mapping(byte_start..byte_end, char_start..char_end);
111
+
112
+
// For paired inline syntax, track opening span for formatted_range
113
+
if matches!(
114
+
tag,
115
+
Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. }
116
+
) {
117
+
self.pending_inline_formats.push((syn_id, char_start));
118
+
}
119
+
120
+
// Update tracking - we've consumed this opening syntax
121
+
self.last_char_offset = char_end;
122
+
self.last_byte_offset = range.start + syntax_byte_len;
123
+
}
124
+
}
125
+
126
+
// Emit the opening tag
127
+
match tag {
128
+
// HTML blocks get their own paragraph to try and corral them better
129
+
Tag::HtmlBlock => {
130
+
// Record paragraph start for boundary tracking
131
+
// BUT skip if inside a list - list owns the paragraph boundary
132
+
if self.list_depth == 0 {
133
+
self.current_paragraph_start =
134
+
Some((self.last_byte_offset, self.last_char_offset));
135
+
}
136
+
let node_id = self.gen_node_id();
137
+
138
+
if self.end_newline {
139
+
write!(
140
+
&mut self.writer,
141
+
r#"<p id="{}" class="html-embed html-embed-block">"#,
142
+
node_id
143
+
)?;
144
+
} else {
145
+
write!(
146
+
&mut self.writer,
147
+
r#"\n<p id="{}" class="html-embed html-embed-block">"#,
148
+
node_id
149
+
)?;
150
+
}
151
+
self.begin_node(node_id.clone());
152
+
153
+
// Map the start position of the paragraph (before any content)
154
+
// This allows cursor to be placed at the very beginning
155
+
let para_start_char = self.last_char_offset;
156
+
let mapping = OffsetMapping {
157
+
byte_range: range.start..range.start,
158
+
char_range: para_start_char..para_start_char,
159
+
node_id,
160
+
char_offset_in_node: 0,
161
+
child_index: Some(0), // position before first child
162
+
utf16_len: 0,
163
+
};
164
+
self.offset_maps.push(mapping);
165
+
166
+
Ok(())
167
+
}
168
+
Tag::Paragraph(_) => {
169
+
// Handle wrapper before block
170
+
self.emit_wrapper_start()?;
171
+
172
+
// Record paragraph start for boundary tracking
173
+
// BUT skip if inside a list - list owns the paragraph boundary
174
+
if self.list_depth == 0 {
175
+
self.current_paragraph_start =
176
+
Some((self.last_byte_offset, self.last_char_offset));
177
+
}
178
+
179
+
let node_id = self.gen_node_id();
180
+
if self.end_newline {
181
+
write!(&mut self.writer, "<p id=\"{}\">", node_id)?;
182
+
} else {
183
+
write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?;
184
+
}
185
+
self.begin_node(node_id.clone());
186
+
187
+
// Map the start position of the paragraph (before any content)
188
+
// This allows cursor to be placed at the very beginning
189
+
let para_start_char = self.last_char_offset;
190
+
let mapping = OffsetMapping {
191
+
byte_range: range.start..range.start,
192
+
char_range: para_start_char..para_start_char,
193
+
node_id,
194
+
char_offset_in_node: 0,
195
+
child_index: Some(0), // position before first child
196
+
utf16_len: 0,
197
+
};
198
+
self.offset_maps.push(mapping);
199
+
200
+
// Emit > syntax if we're inside a blockquote
201
+
if let Some(bq_range) = self.pending_blockquote_range.take() {
202
+
if bq_range.start < bq_range.end {
203
+
let raw_text = &self.source[bq_range.clone()];
204
+
if let Some(gt_pos) = raw_text.find('>') {
205
+
// Extract > [!NOTE] or just >
206
+
let after_gt = &raw_text[gt_pos + 1..];
207
+
let syntax_end = if after_gt.trim_start().starts_with("[!") {
208
+
// Find the closing ]
209
+
if let Some(close_bracket) = after_gt.find(']') {
210
+
gt_pos + 1 + close_bracket + 1
211
+
} else {
212
+
gt_pos + 1
213
+
}
214
+
} else {
215
+
// Just > and maybe a space
216
+
(gt_pos + 1).min(raw_text.len())
217
+
};
218
+
219
+
let syntax = &raw_text[gt_pos..syntax_end];
220
+
let syntax_byte_start = bq_range.start + gt_pos;
221
+
self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
222
+
}
223
+
}
224
+
}
225
+
Ok(())
226
+
}
227
+
Tag::Heading {
228
+
level,
229
+
id,
230
+
classes,
231
+
attrs,
232
+
} => {
233
+
// Emit wrapper if pending (but don't close on heading end - wraps following block too)
234
+
self.emit_wrapper_start()?;
235
+
236
+
// Record paragraph start for boundary tracking
237
+
// Treat headings as paragraph-level blocks
238
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
239
+
240
+
if !self.end_newline {
241
+
self.write("\n")?;
242
+
}
243
+
244
+
// Generate node ID for offset tracking
245
+
let node_id = self.gen_node_id();
246
+
247
+
self.write("<")?;
248
+
write!(&mut self.writer, "{}", level)?;
249
+
250
+
// Add our tracking ID as data attribute (preserve user's id if present)
251
+
self.write(" data-node-id=\"")?;
252
+
self.write(&node_id)?;
253
+
self.write("\"")?;
254
+
255
+
if let Some(id) = id {
256
+
self.write(" id=\"")?;
257
+
escape_html(&mut self.writer, &id)?;
258
+
self.write("\"")?;
259
+
}
260
+
if !classes.is_empty() {
261
+
self.write(" class=\"")?;
262
+
for (i, class) in classes.iter().enumerate() {
263
+
if i > 0 {
264
+
self.write(" ")?;
265
+
}
266
+
escape_html(&mut self.writer, class)?;
267
+
}
268
+
self.write("\"")?;
269
+
}
270
+
for (attr, value) in attrs {
271
+
self.write(" ")?;
272
+
escape_html(&mut self.writer, &attr)?;
273
+
if let Some(val) = value {
274
+
self.write("=\"")?;
275
+
escape_html(&mut self.writer, &val)?;
276
+
self.write("\"")?;
277
+
} else {
278
+
self.write("=\"\"")?;
279
+
}
280
+
}
281
+
self.write(">")?;
282
+
283
+
// Begin node tracking for offset mapping
284
+
self.begin_node(node_id.clone());
285
+
286
+
// Map the start position of the heading (before any content)
287
+
// This allows cursor to be placed at the very beginning
288
+
let heading_start_char = self.last_char_offset;
289
+
let mapping = OffsetMapping {
290
+
byte_range: range.start..range.start,
291
+
char_range: heading_start_char..heading_start_char,
292
+
node_id: node_id.clone(),
293
+
char_offset_in_node: 0,
294
+
child_index: Some(0), // position before first child
295
+
utf16_len: 0,
296
+
};
297
+
self.offset_maps.push(mapping);
298
+
299
+
// Emit # syntax inside the heading tag
300
+
if range.start < range.end {
301
+
let raw_text = &self.source[range.clone()];
302
+
let count = level as usize;
303
+
let pattern = "#".repeat(count);
304
+
305
+
// Find where the # actually starts (might have leading whitespace)
306
+
if let Some(hash_pos) = raw_text.find(&pattern) {
307
+
// Extract "# " or "## " etc
308
+
let syntax_end = (hash_pos + count + 1).min(raw_text.len());
309
+
let syntax = &raw_text[hash_pos..syntax_end];
310
+
let syntax_byte_start = range.start + hash_pos;
311
+
312
+
self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?;
313
+
}
314
+
}
315
+
Ok(())
316
+
}
317
+
Tag::Table(alignments) => {
318
+
if self.render_tables_as_markdown {
319
+
// Store start offset and skip HTML rendering
320
+
self.table_start_offset = Some(range.start);
321
+
self.in_non_writing_block = true; // Suppress content output
322
+
Ok(())
323
+
} else {
324
+
self.emit_wrapper_start()?;
325
+
self.table_alignments = alignments;
326
+
self.write("<table>")
327
+
}
328
+
}
329
+
Tag::TableHead => {
330
+
if self.render_tables_as_markdown {
331
+
Ok(()) // Skip HTML rendering
332
+
} else {
333
+
self.table_state = TableState::Head;
334
+
self.table_cell_index = 0;
335
+
self.write("<thead><tr>")
336
+
}
337
+
}
338
+
Tag::TableRow => {
339
+
if self.render_tables_as_markdown {
340
+
Ok(()) // Skip HTML rendering
341
+
} else {
342
+
self.table_cell_index = 0;
343
+
self.write("<tr>")
344
+
}
345
+
}
346
+
Tag::TableCell => {
347
+
if self.render_tables_as_markdown {
348
+
Ok(()) // Skip HTML rendering
349
+
} else {
350
+
match self.table_state {
351
+
TableState::Head => self.write("<th")?,
352
+
TableState::Body => self.write("<td")?,
353
+
}
354
+
match self.table_alignments.get(self.table_cell_index) {
355
+
Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
356
+
Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
357
+
Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
358
+
_ => self.write(">"),
359
+
}
360
+
}
361
+
}
362
+
Tag::BlockQuote(kind) => {
363
+
self.emit_wrapper_start()?;
364
+
365
+
let class_str = match kind {
366
+
None => "",
367
+
Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"",
368
+
Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"",
369
+
Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"",
370
+
Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"",
371
+
Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"",
372
+
};
373
+
if self.end_newline {
374
+
write!(&mut self.writer, "<blockquote{}>\n", class_str)?;
375
+
} else {
376
+
write!(&mut self.writer, "\n<blockquote{}>\n", class_str)?;
377
+
}
378
+
379
+
// Store range for emitting > inside the next paragraph
380
+
self.pending_blockquote_range = Some(range);
381
+
Ok(())
382
+
}
383
+
Tag::CodeBlock(info) => {
384
+
self.emit_wrapper_start()?;
385
+
386
+
// Track code block as paragraph-level block
387
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
388
+
389
+
if !self.end_newline {
390
+
self.write_newline()?;
391
+
}
392
+
393
+
// Generate node ID for code block
394
+
let node_id = self.gen_node_id();
395
+
396
+
match info {
397
+
CodeBlockKind::Fenced(info) => {
398
+
// Emit opening ```language and track both char and byte offsets
399
+
if range.start < range.end {
400
+
let raw_text = &self.source[range.clone()];
401
+
if let Some(fence_pos) = raw_text.find("```") {
402
+
let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len());
403
+
let syntax = &raw_text[fence_pos..fence_end];
404
+
let syntax_char_len = syntax.chars().count() + 1; // +1 for newline
405
+
let syntax_byte_len = syntax.len() + 1; // +1 for newline
406
+
407
+
let syn_id = self.gen_syn_id();
408
+
let char_start = self.last_char_offset;
409
+
let char_end = char_start + syntax_char_len;
410
+
411
+
write!(
412
+
&mut self.writer,
413
+
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
414
+
syn_id, char_start, char_end
415
+
)?;
416
+
escape_html(&mut self.writer, syntax)?;
417
+
self.write("</span>\n")?;
418
+
419
+
// Track opening span index for formatted_range update later
420
+
self.code_block_opening_span_idx = Some(self.syntax_spans.len());
421
+
self.code_block_char_start = Some(char_start);
422
+
423
+
self.syntax_spans.push(SyntaxSpanInfo {
424
+
syn_id,
425
+
char_range: char_start..char_end,
426
+
syntax_type: SyntaxType::Block,
427
+
formatted_range: None, // Will be set in TagEnd::CodeBlock
428
+
});
429
+
430
+
self.last_char_offset += syntax_char_len;
431
+
self.last_byte_offset = range.start + fence_pos + syntax_byte_len;
432
+
}
433
+
}
434
+
435
+
let lang = info.split(' ').next().unwrap();
436
+
let lang_opt = if lang.is_empty() {
437
+
None
438
+
} else {
439
+
Some(lang.to_string())
440
+
};
441
+
// Start buffering
442
+
self.code_buffer = Some((lang_opt, String::new()));
443
+
444
+
// Begin node tracking for offset mapping
445
+
self.begin_node(node_id);
446
+
Ok(())
447
+
}
448
+
CodeBlockKind::Indented => {
449
+
// Ignore indented code blocks (as per executive decision)
450
+
self.code_buffer = Some((None, String::new()));
451
+
452
+
// Begin node tracking for offset mapping
453
+
self.begin_node(node_id);
454
+
Ok(())
455
+
}
456
+
}
457
+
}
458
+
Tag::List(Some(1)) => {
459
+
self.emit_wrapper_start()?;
460
+
// Track list as paragraph-level block
461
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
462
+
self.list_depth += 1;
463
+
if self.end_newline {
464
+
self.write("<ol>\n")
465
+
} else {
466
+
self.write("\n<ol>\n")
467
+
}
468
+
}
469
+
Tag::List(Some(start)) => {
470
+
self.emit_wrapper_start()?;
471
+
// Track list as paragraph-level block
472
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
473
+
self.list_depth += 1;
474
+
if self.end_newline {
475
+
self.write("<ol start=\"")?;
476
+
} else {
477
+
self.write("\n<ol start=\"")?;
478
+
}
479
+
write!(&mut self.writer, "{}", start)?;
480
+
self.write("\">\n")
481
+
}
482
+
Tag::List(None) => {
483
+
self.emit_wrapper_start()?;
484
+
// Track list as paragraph-level block
485
+
self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
486
+
self.list_depth += 1;
487
+
if self.end_newline {
488
+
self.write("<ul>\n")
489
+
} else {
490
+
self.write("\n<ul>\n")
491
+
}
492
+
}
493
+
Tag::Item => {
494
+
// Generate node ID for list item
495
+
let node_id = self.gen_node_id();
496
+
497
+
if self.end_newline {
498
+
write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?;
499
+
} else {
500
+
write!(&mut self.writer, "\n<li data-node-id=\"{}\">", node_id)?;
501
+
}
502
+
503
+
// Begin node tracking
504
+
self.begin_node(node_id);
505
+
506
+
// Emit list marker syntax inside the <li> tag and track both offsets
507
+
if range.start < range.end {
508
+
let raw_text = &self.source[range.clone()];
509
+
510
+
// Try to find the list marker (-, *, or digit.)
511
+
let trimmed = raw_text.trim_start();
512
+
let leading_ws_bytes = raw_text.len() - trimmed.len();
513
+
let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count();
514
+
515
+
if let Some(marker) = trimmed.chars().next() {
516
+
if marker == '-' || marker == '*' {
517
+
// Unordered list: extract "- " or "* "
518
+
let marker_end = trimmed
519
+
.find(|c: char| c != '-' && c != '*')
520
+
.map(|pos| pos + 1)
521
+
.unwrap_or(1);
522
+
let syntax = &trimmed[..marker_end.min(trimmed.len())];
523
+
let char_start = self.last_char_offset;
524
+
let syntax_char_len = leading_ws_chars + syntax.chars().count();
525
+
let syntax_byte_len = leading_ws_bytes + syntax.len();
526
+
let char_end = char_start + syntax_char_len;
527
+
528
+
let syn_id = self.gen_syn_id();
529
+
write!(
530
+
&mut self.writer,
531
+
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
532
+
syn_id, char_start, char_end
533
+
)?;
534
+
escape_html(&mut self.writer, syntax)?;
535
+
self.write("</span>")?;
536
+
537
+
self.syntax_spans.push(SyntaxSpanInfo {
538
+
syn_id,
539
+
char_range: char_start..char_end,
540
+
syntax_type: SyntaxType::Block,
541
+
formatted_range: None,
542
+
});
543
+
544
+
// Record offset mapping for cursor positioning
545
+
self.record_mapping(
546
+
range.start..range.start + syntax_byte_len,
547
+
char_start..char_end,
548
+
);
549
+
self.last_char_offset = char_end;
550
+
self.last_byte_offset = range.start + syntax_byte_len;
551
+
} else if marker.is_ascii_digit() {
552
+
// Ordered list: extract "1. " or similar (including trailing space)
553
+
if let Some(dot_pos) = trimmed.find('.') {
554
+
let syntax_end = (dot_pos + 2).min(trimmed.len());
555
+
let syntax = &trimmed[..syntax_end];
556
+
let char_start = self.last_char_offset;
557
+
let syntax_char_len = leading_ws_chars + syntax.chars().count();
558
+
let syntax_byte_len = leading_ws_bytes + syntax.len();
559
+
let char_end = char_start + syntax_char_len;
560
+
561
+
let syn_id = self.gen_syn_id();
562
+
write!(
563
+
&mut self.writer,
564
+
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
565
+
syn_id, char_start, char_end
566
+
)?;
567
+
escape_html(&mut self.writer, syntax)?;
568
+
self.write("</span>")?;
569
+
570
+
self.syntax_spans.push(SyntaxSpanInfo {
571
+
syn_id,
572
+
char_range: char_start..char_end,
573
+
syntax_type: SyntaxType::Block,
574
+
formatted_range: None,
575
+
});
576
+
577
+
// Record offset mapping for cursor positioning
578
+
self.record_mapping(
579
+
range.start..range.start + syntax_byte_len,
580
+
char_start..char_end,
581
+
);
582
+
self.last_char_offset = char_end;
583
+
self.last_byte_offset = range.start + syntax_byte_len;
584
+
}
585
+
}
586
+
}
587
+
}
588
+
Ok(())
589
+
}
590
+
Tag::DefinitionList => {
591
+
self.emit_wrapper_start()?;
592
+
if self.end_newline {
593
+
self.write("<dl>\n")
594
+
} else {
595
+
self.write("\n<dl>\n")
596
+
}
597
+
}
598
+
Tag::DefinitionListTitle => {
599
+
let node_id = self.gen_node_id();
600
+
601
+
if self.end_newline {
602
+
write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?;
603
+
} else {
604
+
write!(&mut self.writer, "\n<dt data-node-id=\"{}\">", node_id)?;
605
+
}
606
+
607
+
self.begin_node(node_id);
608
+
Ok(())
609
+
}
610
+
Tag::DefinitionListDefinition => {
611
+
let node_id = self.gen_node_id();
612
+
613
+
if self.end_newline {
614
+
write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?;
615
+
} else {
616
+
write!(&mut self.writer, "\n<dd data-node-id=\"{}\">", node_id)?;
617
+
}
618
+
619
+
self.begin_node(node_id);
620
+
Ok(())
621
+
}
622
+
Tag::Subscript => self.write("<sub>"),
623
+
Tag::Superscript => self.write("<sup>"),
624
+
Tag::Emphasis => self.write("<em>"),
625
+
Tag::Strong => self.write("<strong>"),
626
+
Tag::Strikethrough => self.write("<s>"),
627
+
Tag::Link {
628
+
link_type: LinkType::Email,
629
+
dest_url,
630
+
title,
631
+
..
632
+
} => {
633
+
self.write("<a href=\"mailto:")?;
634
+
escape_href(&mut self.writer, &dest_url)?;
635
+
if !title.is_empty() {
636
+
self.write("\" title=\"")?;
637
+
escape_html(&mut self.writer, &title)?;
638
+
}
639
+
self.write("\">")
640
+
}
641
+
Tag::Link {
642
+
link_type,
643
+
dest_url,
644
+
title,
645
+
..
646
+
} => {
647
+
// Collect refs for later resolution
648
+
let url = dest_url.as_ref();
649
+
if matches!(link_type, LinkType::WikiLink { .. }) {
650
+
let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url);
651
+
self.ref_collector.add_wikilink(target, fragment, None);
652
+
} else if url.starts_with("at://") {
653
+
self.ref_collector.add_at_link(url);
654
+
}
655
+
656
+
// Determine link validity class for wikilinks
657
+
let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) {
658
+
if let Some(index) = &self.entry_index {
659
+
if index.resolve(dest_url.as_ref()).is_some() {
660
+
" link-valid"
661
+
} else {
662
+
" link-broken"
663
+
}
664
+
} else {
665
+
""
666
+
}
667
+
} else {
668
+
""
669
+
};
670
+
671
+
self.write("<a class=\"link")?;
672
+
self.write(validity_class)?;
673
+
self.write("\" href=\"")?;
674
+
escape_href(&mut self.writer, &dest_url)?;
675
+
if !title.is_empty() {
676
+
self.write("\" title=\"")?;
677
+
escape_html(&mut self.writer, &title)?;
678
+
}
679
+
self.write("\">")
680
+
}
681
+
Tag::Image {
682
+
link_type,
683
+
dest_url,
684
+
title,
685
+
id,
686
+
attrs,
687
+
} => {
688
+
// Check if this is actually an AT embed disguised as a wikilink image
689
+
// (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type)
690
+
let url = dest_url.as_ref();
691
+
if matches!(link_type, LinkType::WikiLink { .. })
692
+
&& (url.starts_with("at://") || url.starts_with("did:"))
693
+
{
694
+
return self.write_embed(
695
+
range,
696
+
EmbedType::Other, // AT embeds - disambiguated via NSID later
697
+
dest_url,
698
+
title,
699
+
id,
700
+
attrs,
701
+
);
702
+
}
703
+
704
+
// Image rendering: all syntax elements share one syn_id for visibility toggling
705
+
// Structure:  <img> cursor-landing
706
+
let raw_text = &self.source[range.clone()];
707
+
let syn_id = self.gen_syn_id();
708
+
let opening_char_start = self.last_char_offset;
709
+
710
+
// Find the alt text and closing syntax positions
711
+
let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len());
712
+
let alt_text = if raw_text.starts_with("![") && paren_pos > 2 {
713
+
&raw_text[2..paren_pos]
714
+
} else {
715
+
""
716
+
};
717
+
let closing_syntax = if paren_pos < raw_text.len() {
718
+
&raw_text[paren_pos..]
719
+
} else {
720
+
""
721
+
};
722
+
723
+
// Calculate char positions
724
+
let alt_char_len = alt_text.chars().count();
725
+
let closing_char_len = closing_syntax.chars().count();
726
+
let opening_char_end = opening_char_start + 2; // " syntax span
780
+
if !closing_syntax.is_empty() {
781
+
write!(
782
+
&mut self.writer,
783
+
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
784
+
syn_id, closing_char_start, closing_char_end
785
+
)?;
786
+
escape_html(&mut self.writer, closing_syntax)?;
787
+
self.write("</span>")?;
788
+
789
+
self.syntax_spans.push(SyntaxSpanInfo {
790
+
syn_id: syn_id.clone(),
791
+
char_range: closing_char_start..closing_char_end,
792
+
syntax_type: SyntaxType::Inline,
793
+
formatted_range: Some(formatted_range.clone()),
794
+
});
795
+
796
+
// Record offset mapping for ](url)
797
+
self.record_mapping(
798
+
range.start + paren_pos..range.end,
799
+
closing_char_start..closing_char_end,
800
+
);
801
+
}
802
+
803
+
// 4. Emit <img> element (no syn_id - always visible)
804
+
self.write("<img src=\"")?;
805
+
let resolved_url = self
806
+
.image_resolver
807
+
.as_ref()
808
+
.and_then(|r| r.resolve_image_url(&dest_url));
809
+
if let Some(ref cdn_url) = resolved_url {
810
+
escape_href(&mut self.writer, cdn_url)?;
811
+
} else {
812
+
escape_href(&mut self.writer, &dest_url)?;
813
+
}
814
+
self.write("\" alt=\"")?;
815
+
escape_html(&mut self.writer, alt_text)?;
816
+
self.write("\"")?;
817
+
if !title.is_empty() {
818
+
self.write(" title=\"")?;
819
+
escape_html(&mut self.writer, &title)?;
820
+
self.write("\"")?;
821
+
}
822
+
if let Some(attrs) = attrs {
823
+
if !attrs.classes.is_empty() {
824
+
self.write(" class=\"")?;
825
+
for (i, class) in attrs.classes.iter().enumerate() {
826
+
if i > 0 {
827
+
self.write(" ")?;
828
+
}
829
+
escape_html(&mut self.writer, class)?;
830
+
}
831
+
self.write("\"")?;
832
+
}
833
+
for (attr, value) in &attrs.attrs {
834
+
self.write(" ")?;
835
+
escape_html(&mut self.writer, attr)?;
836
+
self.write("=\"")?;
837
+
escape_html(&mut self.writer, value)?;
838
+
self.write("\"")?;
839
+
}
840
+
}
841
+
self.write(" />")?;
842
+
843
+
// Consume the text events for alt (they're still in the iterator)
844
+
// Use consume_until_end() since we already wrote alt text from source
845
+
self.consume_until_end();
846
+
847
+
// Update offsets
848
+
self.last_char_offset = closing_char_end;
849
+
self.last_byte_offset = range.end;
850
+
851
+
Ok(())
852
+
}
853
+
Tag::Embed {
854
+
embed_type,
855
+
dest_url,
856
+
title,
857
+
id,
858
+
attrs,
859
+
} => self.write_embed(range, embed_type, dest_url, title, id, attrs),
860
+
Tag::WeaverBlock(_, attrs) => {
861
+
self.in_non_writing_block = true;
862
+
self.weaver_block_buffer.clear();
863
+
self.weaver_block_char_start = Some(self.last_char_offset);
864
+
// Store attrs from Start tag, will merge with parsed text on End
865
+
if !attrs.classes.is_empty() || !attrs.attrs.is_empty() {
866
+
self.pending_block_attrs = Some(attrs.into_static());
867
+
}
868
+
Ok(())
869
+
}
870
+
Tag::FootnoteDefinition(name) => {
871
+
// Emit the [^name]: prefix as a hideable syntax span
872
+
// The source should have "[^name]: " at the start
873
+
let prefix = format!("[^{}]: ", name);
874
+
let char_start = self.last_char_offset;
875
+
let prefix_char_len = prefix.chars().count();
876
+
let char_end = char_start + prefix_char_len;
877
+
let syn_id = self.gen_syn_id();
878
+
879
+
if !self.end_newline {
880
+
self.write("\n")?;
881
+
}
882
+
883
+
write!(
884
+
&mut self.writer,
885
+
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
886
+
syn_id, char_start, char_end
887
+
)?;
888
+
escape_html(&mut self.writer, &prefix)?;
889
+
self.write("</span>")?;
890
+
891
+
// Track this span for linking with the footnote reference
892
+
let def_span_index = self.syntax_spans.len();
893
+
self.syntax_spans.push(SyntaxSpanInfo {
894
+
syn_id,
895
+
char_range: char_start..char_end,
896
+
syntax_type: SyntaxType::Block,
897
+
formatted_range: None, // Set at FootnoteDefinition end
898
+
});
899
+
900
+
// Store the definition info for linking at end
901
+
self.current_footnote_def = Some((name.to_string(), def_span_index, char_start));
902
+
903
+
// Record offset mapping for the syntax span
904
+
self.record_mapping(
905
+
range.start..range.start + prefix.len(),
906
+
char_start..char_end,
907
+
);
908
+
909
+
// Update tracking for the prefix
910
+
self.last_char_offset = char_end;
911
+
self.last_byte_offset = range.start + prefix.len();
912
+
913
+
// Emit the definition container
914
+
write!(
915
+
&mut self.writer,
916
+
"<div class=\"footnote-definition\" id=\"fn-{}\">",
917
+
name
918
+
)?;
919
+
920
+
// Get/create footnote number for the label
921
+
let len = self.numbers.len() + 1;
922
+
let number = *self.numbers.entry(name.to_string()).or_insert(len);
923
+
write!(
924
+
&mut self.writer,
925
+
"<sup class=\"footnote-definition-label\">{}</sup>",
926
+
number
927
+
)?;
928
+
929
+
Ok(())
930
+
}
931
+
Tag::MetadataBlock(_) => {
932
+
self.in_non_writing_block = true;
933
+
Ok(())
934
+
}
935
+
}
936
+
}
937
+
938
+
pub(crate) fn end_tag(
939
+
&mut self,
940
+
tag: markdown_weaver::TagEnd,
941
+
range: Range<usize>,
942
+
) -> Result<(), fmt::Error> {
943
+
use markdown_weaver::TagEnd;
944
+
945
+
// Emit tag HTML first
946
+
let result = match tag {
947
+
TagEnd::HtmlBlock => {
948
+
// Capture paragraph boundary info BEFORE writing closing HTML
949
+
// Skip if inside a list - list owns the paragraph boundary
950
+
let para_boundary = if self.list_depth == 0 {
951
+
self.current_paragraph_start
952
+
.take()
953
+
.map(|(byte_start, char_start)| {
954
+
(
955
+
byte_start..self.last_byte_offset,
956
+
char_start..self.last_char_offset,
957
+
)
958
+
})
959
+
} else {
960
+
None
961
+
};
962
+
963
+
// Write closing HTML to current segment
964
+
self.end_node();
965
+
self.write("</p>\n")?;
966
+
967
+
// Now finalize paragraph (starts new segment)
968
+
if let Some((byte_range, char_range)) = para_boundary {
969
+
self.finalize_paragraph(byte_range, char_range);
970
+
}
971
+
Ok(())
972
+
}
973
+
TagEnd::Paragraph(_) => {
974
+
// Capture paragraph boundary info BEFORE writing closing HTML
975
+
// Skip if inside a list - list owns the paragraph boundary
976
+
let para_boundary = if self.list_depth == 0 {
977
+
self.current_paragraph_start
978
+
.take()
979
+
.map(|(byte_start, char_start)| {
980
+
(
981
+
byte_start..self.last_byte_offset,
982
+
char_start..self.last_char_offset,
983
+
)
984
+
})
985
+
} else {
986
+
None
987
+
};
988
+
989
+
// Write closing HTML to current segment
990
+
self.end_node();
991
+
self.write("</p>\n")?;
992
+
self.close_wrapper()?;
993
+
994
+
// Now finalize paragraph (starts new segment)
995
+
if let Some((byte_range, char_range)) = para_boundary {
996
+
self.finalize_paragraph(byte_range, char_range);
997
+
}
998
+
Ok(())
999
+
}
1000
+
TagEnd::Heading(level) => {
1001
+
// Capture paragraph boundary info BEFORE writing closing HTML
1002
+
let para_boundary =
1003
+
self.current_paragraph_start
1004
+
.take()
1005
+
.map(|(byte_start, char_start)| {
1006
+
(
1007
+
byte_start..self.last_byte_offset,
1008
+
char_start..self.last_char_offset,
1009
+
)
1010
+
});
1011
+
1012
+
// Write closing HTML to current segment
1013
+
self.end_node();
1014
+
self.write("</")?;
1015
+
write!(&mut self.writer, "{}", level)?;
1016
+
self.write(">\n")?;
1017
+
// Note: Don't close wrapper here - headings typically go with following block
1018
+
1019
+
// Now finalize paragraph (starts new segment)
1020
+
if let Some((byte_range, char_range)) = para_boundary {
1021
+
self.finalize_paragraph(byte_range, char_range);
1022
+
}
1023
+
Ok(())
1024
+
}
1025
+
TagEnd::Table => {
1026
+
if self.render_tables_as_markdown {
1027
+
// Emit the raw markdown table
1028
+
if let Some(start) = self.table_start_offset.take() {
1029
+
let table_text = &self.source[start..range.end];
1030
+
self.in_non_writing_block = false;
1031
+
1032
+
// Wrap in a pre or div for styling
1033
+
self.write("<pre class=\"table-markdown\">")?;
1034
+
escape_html(&mut self.writer, table_text)?;
1035
+
self.write("</pre>\n")?;
1036
+
}
1037
+
Ok(())
1038
+
} else {
1039
+
self.write("</tbody></table>\n")
1040
+
}
1041
+
}
1042
+
TagEnd::TableHead => {
1043
+
if self.render_tables_as_markdown {
1044
+
Ok(()) // Skip HTML rendering
1045
+
} else {
1046
+
self.write("</tr></thead><tbody>\n")?;
1047
+
self.table_state = TableState::Body;
1048
+
Ok(())
1049
+
}
1050
+
}
1051
+
TagEnd::TableRow => {
1052
+
if self.render_tables_as_markdown {
1053
+
Ok(()) // Skip HTML rendering
1054
+
} else {
1055
+
self.write("</tr>\n")
1056
+
}
1057
+
}
1058
+
TagEnd::TableCell => {
1059
+
if self.render_tables_as_markdown {
1060
+
Ok(()) // Skip HTML rendering
1061
+
} else {
1062
+
match self.table_state {
1063
+
TableState::Head => self.write("</th>")?,
1064
+
TableState::Body => self.write("</td>")?,
1065
+
}
1066
+
self.table_cell_index += 1;
1067
+
Ok(())
1068
+
}
1069
+
}
1070
+
TagEnd::BlockQuote(_) => {
1071
+
// If pending_blockquote_range is still set, the blockquote was empty
1072
+
// (no paragraph inside). Emit the > as its own minimal paragraph.
1073
+
let mut para_boundary = None;
1074
+
if let Some(bq_range) = self.pending_blockquote_range.take() {
1075
+
if bq_range.start < bq_range.end {
1076
+
let raw_text = &self.source[bq_range.clone()];
1077
+
if let Some(gt_pos) = raw_text.find('>') {
1078
+
let para_byte_start = bq_range.start + gt_pos;
1079
+
let para_char_start = self.last_char_offset;
1080
+
1081
+
// Create a minimal paragraph for the empty blockquote
1082
+
let node_id = self.gen_node_id();
1083
+
write!(&mut self.writer, "<div id=\"{}\"", node_id)?;
1084
+
1085
+
// Record start-of-node mapping for cursor positioning
1086
+
self.offset_maps.push(OffsetMapping {
1087
+
byte_range: para_byte_start..para_byte_start,
1088
+
char_range: para_char_start..para_char_start,
1089
+
node_id: node_id.clone(),
1090
+
char_offset_in_node: gt_pos,
1091
+
child_index: Some(0),
1092
+
utf16_len: 0,
1093
+
});
1094
+
1095
+
// Emit the > as block syntax
1096
+
let syntax = &raw_text[gt_pos..gt_pos + 1];
1097
+
self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?;
1098
+
1099
+
self.write("</div>\n")?;
1100
+
self.end_node();
1101
+
1102
+
// Capture paragraph boundary for later finalization
1103
+
let byte_range = para_byte_start..bq_range.end;
1104
+
let char_range = para_char_start..self.last_char_offset;
1105
+
para_boundary = Some((byte_range, char_range));
1106
+
}
1107
+
}
1108
+
}
1109
+
self.write("</blockquote>\n")?;
1110
+
self.close_wrapper()?;
1111
+
1112
+
// Now finalize paragraph if we had one
1113
+
if let Some((byte_range, char_range)) = para_boundary {
1114
+
self.finalize_paragraph(byte_range, char_range);
1115
+
}
1116
+
Ok(())
1117
+
}
1118
+
TagEnd::CodeBlock => {
1119
+
use std::sync::LazyLock;
1120
+
use syntect::parsing::SyntaxSet;
1121
+
static SYNTAX_SET: LazyLock<SyntaxSet> =
1122
+
LazyLock::new(|| SyntaxSet::load_defaults_newlines());
1123
+
1124
+
if let Some((lang, buffer)) = self.code_buffer.take() {
1125
+
// Create offset mapping for code block content if we tracked ranges
1126
+
if let (Some(code_byte_range), Some(code_char_range)) = (
1127
+
self.code_buffer_byte_range.take(),
1128
+
self.code_buffer_char_range.take(),
1129
+
) {
1130
+
// Record mapping before writing HTML
1131
+
// (current_node_id should be set by start_tag for CodeBlock)
1132
+
self.record_mapping(code_byte_range, code_char_range);
1133
+
}
1134
+
1135
+
// Get node_id for data-node-id attribute (needed for cursor positioning)
1136
+
let node_id = self.current_node_id.clone();
1137
+
1138
+
if let Some(ref lang_str) = lang {
1139
+
// Use a temporary String buffer for syntect
1140
+
let mut temp_output = String::new();
1141
+
match weaver_renderer::code_pretty::highlight(
1142
+
&SYNTAX_SET,
1143
+
Some(lang_str),
1144
+
&buffer,
1145
+
&mut temp_output,
1146
+
) {
1147
+
Ok(_) => {
1148
+
// Inject data-node-id into the <pre> tag for cursor positioning
1149
+
if let Some(ref nid) = node_id {
1150
+
let injected = temp_output.replacen(
1151
+
"<pre>",
1152
+
&format!("<pre data-node-id=\"{}\">", nid),
1153
+
1,
1154
+
);
1155
+
self.write(&injected)?;
1156
+
} else {
1157
+
self.write(&temp_output)?;
1158
+
}
1159
+
}
1160
+
Err(_) => {
1161
+
// Fallback to plain code block
1162
+
if let Some(ref nid) = node_id {
1163
+
write!(
1164
+
&mut self.writer,
1165
+
"<pre data-node-id=\"{}\"><code class=\"language-",
1166
+
nid
1167
+
)?;
1168
+
} else {
1169
+
self.write("<pre><code class=\"language-")?;
1170
+
}
1171
+
escape_html(&mut self.writer, lang_str)?;
1172
+
self.write("\">")?;
1173
+
escape_html_body_text(&mut self.writer, &buffer)?;
1174
+
self.write("</code></pre>\n")?;
1175
+
}
1176
+
}
1177
+
} else {
1178
+
if let Some(ref nid) = node_id {
1179
+
write!(&mut self.writer, "<pre data-node-id=\"{}\"><code>", nid)?;
1180
+
} else {
1181
+
self.write("<pre><code>")?;
1182
+
}
1183
+
escape_html_body_text(&mut self.writer, &buffer)?;
1184
+
self.write("</code></pre>\n")?;
1185
+
}
1186
+
1187
+
// End node tracking
1188
+
self.end_node();
1189
+
} else {
1190
+
self.write("</code></pre>\n")?;
1191
+
}
1192
+
1193
+
// Emit closing ``` (emit_gap_before is skipped while buffering)
1194
+
// Track the opening span index and char start before we potentially clear them
1195
+
let opening_span_idx = self.code_block_opening_span_idx.take();
1196
+
let code_block_start = self.code_block_char_start.take();
1197
+
1198
+
if range.start < range.end {
1199
+
let raw_text = &self.source[range.clone()];
1200
+
if let Some(fence_line) = raw_text.lines().last() {
1201
+
if fence_line.trim().starts_with("```") {
1202
+
let fence = fence_line.trim();
1203
+
let fence_char_len = fence.chars().count();
1204
+
1205
+
let syn_id = self.gen_syn_id();
1206
+
let char_start = self.last_char_offset;
1207
+
let char_end = char_start + fence_char_len;
1208
+
1209
+
write!(
1210
+
&mut self.writer,
1211
+
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
1212
+
syn_id, char_start, char_end
1213
+
)?;
1214
+
escape_html(&mut self.writer, fence)?;
1215
+
self.write("</span>")?;
1216
+
1217
+
self.last_char_offset += fence_char_len;
1218
+
self.last_byte_offset += fence.len();
1219
+
1220
+
// Compute formatted_range for entire code block (opening fence to closing fence)
1221
+
let formatted_range =
1222
+
code_block_start.map(|start| start..self.last_char_offset);
1223
+
1224
+
// Update opening fence span with formatted_range
1225
+
if let (Some(idx), Some(fr)) =
1226
+
(opening_span_idx, formatted_range.as_ref())
1227
+
{
1228
+
if let Some(span) = self.syntax_spans.get_mut(idx) {
1229
+
span.formatted_range = Some(fr.clone());
1230
+
}
1231
+
}
1232
+
1233
+
// Push closing fence span with formatted_range
1234
+
self.syntax_spans.push(SyntaxSpanInfo {
1235
+
syn_id,
1236
+
char_range: char_start..char_end,
1237
+
syntax_type: SyntaxType::Block,
1238
+
formatted_range,
1239
+
});
1240
+
}
1241
+
}
1242
+
}
1243
+
1244
+
// Finalize code block paragraph
1245
+
if let Some((byte_start, char_start)) = self.current_paragraph_start.take() {
1246
+
let byte_range = byte_start..self.last_byte_offset;
1247
+
let char_range = char_start..self.last_char_offset;
1248
+
self.finalize_paragraph(byte_range, char_range);
1249
+
}
1250
+
1251
+
Ok(())
1252
+
}
1253
+
TagEnd::List(true) => {
1254
+
self.list_depth = self.list_depth.saturating_sub(1);
1255
+
// Capture paragraph boundary BEFORE writing closing HTML
1256
+
let para_boundary =
1257
+
self.current_paragraph_start
1258
+
.take()
1259
+
.map(|(byte_start, char_start)| {
1260
+
(
1261
+
byte_start..self.last_byte_offset,
1262
+
char_start..self.last_char_offset,
1263
+
)
1264
+
});
1265
+
1266
+
self.write("</ol>\n")?;
1267
+
self.close_wrapper()?;
1268
+
1269
+
// Finalize paragraph after closing HTML
1270
+
if let Some((byte_range, char_range)) = para_boundary {
1271
+
self.finalize_paragraph(byte_range, char_range);
1272
+
}
1273
+
Ok(())
1274
+
}
1275
+
TagEnd::List(false) => {
1276
+
self.list_depth = self.list_depth.saturating_sub(1);
1277
+
// Capture paragraph boundary BEFORE writing closing HTML
1278
+
let para_boundary =
1279
+
self.current_paragraph_start
1280
+
.take()
1281
+
.map(|(byte_start, char_start)| {
1282
+
(
1283
+
byte_start..self.last_byte_offset,
1284
+
char_start..self.last_char_offset,
1285
+
)
1286
+
});
1287
+
1288
+
self.write("</ul>\n")?;
1289
+
self.close_wrapper()?;
1290
+
1291
+
// Finalize paragraph after closing HTML
1292
+
if let Some((byte_range, char_range)) = para_boundary {
1293
+
self.finalize_paragraph(byte_range, char_range);
1294
+
}
1295
+
Ok(())
1296
+
}
1297
+
TagEnd::Item => {
1298
+
self.end_node();
1299
+
self.write("</li>\n")
1300
+
}
1301
+
TagEnd::DefinitionList => {
1302
+
self.write("</dl>\n")?;
1303
+
self.close_wrapper()
1304
+
}
1305
+
TagEnd::DefinitionListTitle => {
1306
+
self.end_node();
1307
+
self.write("</dt>\n")
1308
+
}
1309
+
TagEnd::DefinitionListDefinition => {
1310
+
self.end_node();
1311
+
self.write("</dd>\n")
1312
+
}
1313
+
TagEnd::Emphasis => {
1314
+
// Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
1315
+
self.write("</em>")?;
1316
+
self.emit_gap_before(range.end)?;
1317
+
self.finalize_paired_inline_format();
1318
+
Ok(())
1319
+
}
1320
+
TagEnd::Superscript => self.write("</sup>"),
1321
+
TagEnd::Subscript => self.write("</sub>"),
1322
+
TagEnd::Strong => {
1323
+
// Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
1324
+
self.write("</strong>")?;
1325
+
self.emit_gap_before(range.end)?;
1326
+
self.finalize_paired_inline_format();
1327
+
Ok(())
1328
+
}
1329
+
TagEnd::Strikethrough => {
1330
+
// Write closing tag FIRST, then emit closing syntax OUTSIDE the tag
1331
+
self.write("</s>")?;
1332
+
self.emit_gap_before(range.end)?;
1333
+
self.finalize_paired_inline_format();
1334
+
Ok(())
1335
+
}
1336
+
TagEnd::Link => {
1337
+
self.write("</a>")?;
1338
+
// Check if this is a wiki link (ends with ]]) vs regular link (ends with ))
1339
+
let raw_text = &self.source[range.clone()];
1340
+
if raw_text.ends_with("]]") {
1341
+
// WikiLink: emit ]] as closing syntax
1342
+
let syn_id = self.gen_syn_id();
1343
+
let char_start = self.last_char_offset;
1344
+
let char_end = char_start + 2;
1345
+
1346
+
write!(
1347
+
&mut self.writer,
1348
+
"<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>",
1349
+
syn_id, char_start, char_end
1350
+
)?;
1351
+
1352
+
self.syntax_spans.push(SyntaxSpanInfo {
1353
+
syn_id,
1354
+
char_range: char_start..char_end,
1355
+
syntax_type: SyntaxType::Inline,
1356
+
formatted_range: None, // Will be set by finalize
1357
+
});
1358
+
1359
+
self.last_char_offset = char_end;
1360
+
self.last_byte_offset = range.end;
1361
+
} else {
1362
+
self.emit_gap_before(range.end)?;
1363
+
}
1364
+
self.finalize_paired_inline_format();
1365
+
Ok(())
1366
+
}
1367
+
TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event
1368
+
TagEnd::Embed => Ok(()),
1369
+
TagEnd::WeaverBlock(_) => {
1370
+
self.in_non_writing_block = false;
1371
+
1372
+
// Emit the { content } as a hideable syntax span
1373
+
if let Some(char_start) = self.weaver_block_char_start.take() {
1374
+
// Build the full syntax text: { buffered_content }
1375
+
let syntax_text = format!("{{{}}}", self.weaver_block_buffer);
1376
+
let syntax_char_len = syntax_text.chars().count();
1377
+
let char_end = char_start + syntax_char_len;
1378
+
1379
+
let syn_id = self.gen_syn_id();
1380
+
1381
+
write!(
1382
+
&mut self.writer,
1383
+
"<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
1384
+
syn_id, char_start, char_end
1385
+
)?;
1386
+
escape_html(&mut self.writer, &syntax_text)?;
1387
+
self.write("</span>")?;
1388
+
1389
+
// Track the syntax span
1390
+
self.syntax_spans.push(SyntaxSpanInfo {
1391
+
syn_id,
1392
+
char_range: char_start..char_end,
1393
+
syntax_type: SyntaxType::Block,
1394
+
formatted_range: None,
1395
+
});
1396
+
1397
+
// Record offset mapping for the syntax span
1398
+
self.record_mapping(range.clone(), char_start..char_end);
1399
+
1400
+
// Update tracking
1401
+
self.last_char_offset = char_end;
1402
+
self.last_byte_offset = range.end;
1403
+
}
1404
+
1405
+
// Parse the buffered text for attrs and store for next block
1406
+
if !self.weaver_block_buffer.is_empty() {
1407
+
let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer);
1408
+
self.weaver_block_buffer.clear();
1409
+
// Merge with any existing pending attrs or set new
1410
+
if let Some(ref mut existing) = self.pending_block_attrs {
1411
+
existing.classes.extend(parsed.classes);
1412
+
existing.attrs.extend(parsed.attrs);
1413
+
} else {
1414
+
self.pending_block_attrs = Some(parsed);
1415
+
}
1416
+
}
1417
+
1418
+
Ok(())
1419
+
}
1420
+
TagEnd::FootnoteDefinition => {
1421
+
self.write("</div>\n")?;
1422
+
1423
+
// Link the footnote definition span with its reference span
1424
+
if let Some((name, def_span_index, _def_char_start)) =
1425
+
self.current_footnote_def.take()
1426
+
{
1427
+
let def_char_end = self.last_char_offset;
1428
+
1429
+
// Look up the reference span
1430
+
if let Some(&(ref_span_index, ref_char_start)) =
1431
+
self.footnote_ref_spans.get(&name)
1432
+
{
1433
+
// Create formatted_range spanning from ref start to def end
1434
+
let formatted_range = ref_char_start..def_char_end;
1435
+
1436
+
// Update both spans with the same formatted_range
1437
+
// so they show/hide together based on cursor proximity
1438
+
if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) {
1439
+
ref_span.formatted_range = Some(formatted_range.clone());
1440
+
}
1441
+
if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) {
1442
+
def_span.formatted_range = Some(formatted_range);
1443
+
}
1444
+
}
1445
+
}
1446
+
1447
+
Ok(())
1448
+
}
1449
+
TagEnd::MetadataBlock(_) => {
1450
+
self.in_non_writing_block = false;
1451
+
Ok(())
1452
+
}
1453
+
};
1454
+
1455
+
result?;
1456
+
1457
+
// Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough)
1458
+
// is handled INSIDE their respective match arms above, AFTER writing the closing HTML.
1459
+
// This ensures the closing syntax span appears OUTSIDE the formatted element.
1460
+
// Other End events have their closing syntax emitted by emit_gap_before() in the main loop.
1461
+
1462
+
Ok(())
1463
+
}
1464
+
}
+21
-8
crates/weaver-app/src/components/entry.rs
···
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(),
···
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(),
236
ident: ident(),
237
book_title: book_title()
238
}
0
0
239
}
240
241
{
···
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
}
0
0
263
}
264
}
265
···
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(),
···
284
}
285
}
286
287
-
if let Some(ref next) = next_entry {
288
NavButton {
289
direction: "next",
290
entry: next.entry.clone(),
···
726
727
/// Render some text as markdown.
728
pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element {
729
-
let (_res, processed) = crate::data::use_rendered_markdown(props.content, props.ident);
0
0
0
0
0
0
0
0
0
0
0
0
730
#[cfg(feature = "fullstack-server")]
731
_res?;
732
···
212
213
tracing::info!("Entry: {book_title} - {title}");
214
0
0
0
215
rsx! {
216
EntryOgMeta {
217
title: title.to_string(),
···
226
div { class: "entry-page",
227
// Header: nav prev + metadata + nav next
228
header { class: "entry-header",
229
+
if let Some(ref prev) = book_entry_view().prev {
230
NavButton {
231
direction: "prev",
232
entry: prev.entry.clone(),
233
ident: ident(),
234
book_title: book_title()
235
}
236
+
} else {
237
+
div { class: "nav-placeholder" }
238
}
239
240
{
···
252
}
253
}
254
255
+
if let Some(ref next) = book_entry_view().next {
256
NavButton {
257
direction: "next",
258
entry: next.entry.clone(),
259
ident: ident(),
260
book_title: book_title()
261
}
262
+
} else {
263
+
div { class: "nav-placeholder" }
264
}
265
}
266
···
276
277
// Footer navigation
278
footer { class: "entry-footer-nav",
279
+
if let Some(ref prev) = book_entry_view().prev {
280
NavButton {
281
direction: "prev",
282
entry: prev.entry.clone(),
···
285
}
286
}
287
288
+
if let Some(ref next) = book_entry_view().next {
289
NavButton {
290
direction: "next",
291
entry: next.entry.clone(),
···
727
728
/// Render some text as markdown.
729
pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element {
730
+
let (mut _res, processed) = crate::data::use_rendered_markdown(props.content, props.ident);
731
+
732
+
// Track entry title to detect content change and restart resource
733
+
let mut last_title = use_signal(|| (props.content)().title.to_string());
734
+
let current_title = (props.content)().title.to_string();
735
+
if current_title != last_title() {
736
+
#[cfg(feature = "fullstack-server")]
737
+
if let Ok(ref mut r) = _res {
738
+
r.restart();
739
+
}
740
+
last_title.set(current_title);
741
+
}
742
+
743
#[cfg(feature = "fullstack-server")]
744
_res?;
745
+4
-4
crates/weaver-app/src/data.rs
···
421
) -> (Resource<Option<String>>, Memo<Option<String>>) {
422
let fetcher = use_context::<crate::fetch::Fetcher>();
423
let fetcher = fetcher.clone();
424
-
let res = use_resource(move || {
425
let fetcher = fetcher.clone();
426
async move {
427
let entry = content();
···
434
435
Some(render_markdown_impl(entry, did, resolved_content).await)
436
}
437
-
});
438
-
let memo = use_memo(move || {
439
if let Some(Some(value)) = &*res.read() {
440
Some(value.clone())
441
} else {
442
None
443
}
444
-
});
445
(res, memo)
446
}
447
···
421
) -> (Resource<Option<String>>, Memo<Option<String>>) {
422
let fetcher = use_context::<crate::fetch::Fetcher>();
423
let fetcher = fetcher.clone();
424
+
let res = use_resource(use_reactive!(|(content, ident)| {
425
let fetcher = fetcher.clone();
426
async move {
427
let entry = content();
···
434
435
Some(render_markdown_impl(entry, did, resolved_content).await)
436
}
437
+
}));
438
+
let memo = use_memo(use_reactive!(|res| {
439
if let Some(Some(value)) = &*res.read() {
440
Some(value.clone())
441
} else {
442
None
443
}
444
+
}));
445
(res, memo)
446
}
447
+22
-8
crates/weaver-index/src/endpoints/notebook.rs
···
139
.maybe_path(non_empty_cowstr(¬ebook_row.path))
140
.build();
141
142
-
// Build entry views
143
-
let mut entries: Vec<BookEntryView<'static>> = Vec::with_capacity(entry_rows.len());
144
-
for (idx, entry_row) in entry_rows.iter().enumerate() {
145
let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| {
146
tracing::error!("Invalid entry URI in db: {}", e);
147
XrpcErrorResponse::internal_error("Invalid URI stored")
···
185
.maybe_path(non_empty_cowstr(&entry_row.path))
186
.build();
187
188
-
let book_entry = BookEntryView::new()
189
-
.entry(entry_view)
190
-
.index(idx as i64)
191
-
.build();
192
193
-
entries.push(book_entry);
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
194
}
195
196
// Build cursor for pagination (position-based)
···
139
.maybe_path(non_empty_cowstr(¬ebook_row.path))
140
.build();
141
142
+
// Build entry views (first pass: create EntryViews)
143
+
let mut entry_views: Vec<EntryView<'static>> = Vec::with_capacity(entry_rows.len());
144
+
for entry_row in entry_rows.iter() {
145
let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| {
146
tracing::error!("Invalid entry URI in db: {}", e);
147
XrpcErrorResponse::internal_error("Invalid URI stored")
···
185
.maybe_path(non_empty_cowstr(&entry_row.path))
186
.build();
187
188
+
entry_views.push(entry_view);
189
+
}
0
0
190
191
+
// Build BookEntryViews with prev/next navigation
192
+
let mut entries: Vec<BookEntryView<'static>> = Vec::with_capacity(entry_views.len());
193
+
for (idx, entry_view) in entry_views.iter().enumerate() {
194
+
let prev = (idx > 0)
195
+
.then(|| BookEntryRef::new().entry(entry_views[idx - 1].clone()).build());
196
+
let next = entry_views
197
+
.get(idx + 1)
198
+
.map(|e| BookEntryRef::new().entry(e.clone()).build());
199
+
200
+
entries.push(
201
+
BookEntryView::new()
202
+
.entry(entry_view.clone())
203
+
.index(idx as i64)
204
+
.maybe_prev(prev)
205
+
.maybe_next(next)
206
+
.build(),
207
+
);
208
}
209
210
// Build cursor for pagination (position-based)
+9
-8
crates/weaver-renderer/src/css.rs
···
322
color: var(--color-primary);
323
}}
324
325
-
/* Aside blocks (via WeaverBlock prefix) */
326
-
aside, .aside {{
0
327
float: left;
328
width: 40%;
329
margin: 0 1.5rem 1rem 0;
···
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;
···
322
color: var(--color-primary);
323
}}
324
325
+
/* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */
326
+
.notebook-content aside,
327
+
.notebook-content .aside {{
328
float: left;
329
width: 40%;
330
margin: 0 1.5rem 1rem 0;
···
335
clear: left;
336
}}
337
338
+
.notebook-content aside > *:first-child,
339
+
.notebook-content .aside > *:first-child {{
340
margin-top: 0;
341
}}
342
343
+
.notebook-content aside > *:last-child,
344
+
.notebook-content .aside > *:last-child {{
345
margin-bottom: 0;
346
}}
347
348
/* Reset blockquote styling inside asides */
349
+
.notebook-content aside > blockquote,
350
+
.notebook-content .aside > blockquote {{
351
border-left: none;
352
background: transparent;
353
padding: 0;
+1279
test-sidenotes.html
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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) - scoped to notebook content */
278
+
.notebook-content aside,
279
+
.notebook-content .aside {
280
+
float: left;
281
+
width: 40%;
282
+
margin: 0 1.5rem 1rem 0;
283
+
padding: 1rem;
284
+
background: var(--color-surface);
285
+
border-right: 3px solid var(--color-primary);
286
+
font-size: 0.9em;
287
+
clear: left;
288
+
}
289
+
290
+
.notebook-content aside > *:first-child,
291
+
.notebook-content .aside > *:first-child {
292
+
margin-top: 0;
293
+
}
294
+
295
+
.notebook-content aside > *:last-child,
296
+
.notebook-content .aside > *:last-child {
297
+
margin-bottom: 0;
298
+
}
299
+
300
+
/* Reset blockquote styling inside asides */
301
+
.notebook-content aside > blockquote,
302
+
.notebook-content .aside > blockquote {
303
+
border-left: none;
304
+
background: transparent;
305
+
padding: 0;
306
+
margin: 0;
307
+
font-size: inherit;
308
+
}
309
+
310
+
/* Indent utilities */
311
+
.indent-1 { margin-left: 1em; }
312
+
.indent-2 { margin-left: 2em; }
313
+
.indent-3 { margin-left: 3em; }
314
+
315
+
/* Tufte-style Sidenotes */
316
+
/* Hide checkbox for sidenote toggle */
317
+
.margin-toggle {
318
+
display: none;
319
+
}
320
+
321
+
/* Sidenote number marker (inline superscript) */
322
+
.sidenote-number {
323
+
counter-increment: sidenote-counter;
324
+
}
325
+
326
+
.sidenote-number::after {
327
+
content: counter(sidenote-counter);
328
+
font-size: 0.7em;
329
+
position: relative;
330
+
top: -0.5em;
331
+
color: var(--color-primary);
332
+
padding-left: 0.1em;
333
+
}
334
+
335
+
/* Sidenote content (margin notes on wide screens) */
336
+
.sidenote {
337
+
float: right;
338
+
clear: right;
339
+
margin-right: -15.5rem;
340
+
width: 14rem;
341
+
margin-top: 0.3rem;
342
+
margin-bottom: 1rem;
343
+
font-size: 0.85em;
344
+
line-height: 1.4;
345
+
color: var(--color-subtle);
346
+
}
347
+
348
+
.sidenote::before {
349
+
content: counter(sidenote-counter) ". ";
350
+
color: var(--color-primary);
351
+
}
352
+
353
+
/* Mobile sidenotes: toggle behavior */
354
+
@media (max-width: 900px) {
355
+
/* Reset sidenote gutter on mobile */
356
+
body:has(.sidenote) {
357
+
padding-right: 0;
358
+
}
359
+
360
+
aside, .aside {
361
+
float: none;
362
+
width: 100%;
363
+
margin: 1rem 0;
364
+
}
365
+
366
+
.sidenote {
367
+
display: none;
368
+
}
369
+
370
+
.margin-toggle:checked + .sidenote {
371
+
display: block;
372
+
float: none;
373
+
width: 95%;
374
+
margin: 0.5rem 2.5%;
375
+
padding: 0.5rem;
376
+
background: var(--color-surface);
377
+
border-left: 2px solid var(--color-primary);
378
+
}
379
+
380
+
label.sidenote-number {
381
+
cursor: pointer;
382
+
}
383
+
384
+
label.sidenote-number::after {
385
+
text-decoration: underline;
386
+
}
387
+
}
388
+
389
+
/* Images */
390
+
img {
391
+
max-width: 100%;
392
+
height: auto;
393
+
display: block;
394
+
margin: 1rem 0;
395
+
border-radius: 4px;
396
+
}
397
+
398
+
/* Hygiene for iframes */
399
+
.html-embed-block {
400
+
max-width: 100%;
401
+
height: auto;
402
+
display: block;
403
+
margin: 1rem 0;
404
+
}
405
+
406
+
/* AT Protocol Embeds - Container */
407
+
/* Light mode: paper with shadow, dark mode: blueprint with borders */
408
+
.atproto-embed {
409
+
display: block;
410
+
position: relative;
411
+
max-width: 550px;
412
+
margin: 1rem 0;
413
+
padding: 1rem;
414
+
background: var(--color-surface);
415
+
border-left: 2px solid var(--color-secondary);
416
+
box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent);
417
+
}
418
+
419
+
.atproto-embed:hover {
420
+
border-left-color: var(--color-primary);
421
+
}
422
+
423
+
@media (prefers-color-scheme: dark) {
424
+
.atproto-embed {
425
+
box-shadow: none;
426
+
border: 1px solid var(--color-border);
427
+
border-left: 2px solid var(--color-secondary);
428
+
}
429
+
}
430
+
431
+
.atproto-embed-placeholder {
432
+
color: var(--color-muted);
433
+
font-style: italic;
434
+
}
435
+
436
+
.embed-loading {
437
+
display: block;
438
+
padding: 0.5rem 0;
439
+
color: var(--color-subtle);
440
+
font-family: var(--font-mono);
441
+
font-size: 0.85rem;
442
+
}
443
+
444
+
/* Embed Author Block */
445
+
.embed-author {
446
+
display: flex;
447
+
align-items: center;
448
+
gap: 0.75rem;
449
+
padding-bottom: 0.5rem;
450
+
}
451
+
452
+
.embed-avatar {
453
+
width: 36px;
454
+
height: 36px;
455
+
max-width: 36px;
456
+
max-height: 36px;
457
+
aspect-ratio: 1;
458
+
margin: 0;
459
+
object-fit: cover;
460
+
}
461
+
462
+
.embed-author-info {
463
+
display: flex;
464
+
flex-direction: column;
465
+
gap: 0;
466
+
min-width: 0;
467
+
}
468
+
469
+
.embed-avatar-link {
470
+
display: block;
471
+
flex-shrink: 0;
472
+
}
473
+
474
+
.embed-author-name {
475
+
font-weight: 600;
476
+
color: var(--color-text);
477
+
overflow: hidden;
478
+
text-overflow: ellipsis;
479
+
white-space: nowrap;
480
+
text-decoration: none;
481
+
line-height: 1.2;
482
+
}
483
+
484
+
a.embed-author-name:hover {
485
+
color: var(--color-link);
486
+
}
487
+
488
+
.embed-author-handle {
489
+
font-size: 0.85em;
490
+
font-family: var(--font-mono);
491
+
color: var(--color-subtle);
492
+
text-decoration: none;
493
+
overflow: hidden;
494
+
text-overflow: ellipsis;
495
+
white-space: nowrap;
496
+
line-height: 1.2;
497
+
}
498
+
499
+
.embed-author-handle:hover {
500
+
color: var(--color-link);
501
+
}
502
+
503
+
/* Card-wide clickable link (sits behind content) */
504
+
.embed-card-link {
505
+
position: absolute;
506
+
inset: 0;
507
+
z-index: 0;
508
+
}
509
+
510
+
.embed-card-link:focus {
511
+
outline: 2px solid var(--color-primary);
512
+
outline-offset: 2px;
513
+
}
514
+
515
+
/* Interactive elements sit above the card link */
516
+
.embed-author,
517
+
.embed-external,
518
+
.embed-quote,
519
+
.embed-images,
520
+
.embed-meta {
521
+
position: relative;
522
+
z-index: 1;
523
+
}
524
+
525
+
/* Embed Content Block */
526
+
.embed-content {
527
+
display: block;
528
+
color: var(--color-text);
529
+
line-height: 1.5;
530
+
margin-bottom: 0.75rem;
531
+
white-space: pre-wrap;
532
+
}
533
+
534
+
535
+
536
+
.embed-description {
537
+
display: block;
538
+
color: var(--color-text);
539
+
font-size: 0.95em;
540
+
line-height: 1.4;
541
+
}
542
+
543
+
/* Embed Metadata Block */
544
+
.embed-meta {
545
+
display: flex;
546
+
justify-content: space-between;
547
+
align-items: center;
548
+
font-size: 0.85em;
549
+
color: var(--color-muted);
550
+
margin-top: 0.75rem;
551
+
}
552
+
553
+
.embed-stats {
554
+
display: flex;
555
+
gap: 1rem;
556
+
font-family: var(--font-mono);
557
+
}
558
+
559
+
.embed-stat {
560
+
color: var(--color-subtle);
561
+
font-size: 0.9em;
562
+
}
563
+
564
+
.embed-time {
565
+
color: var(--color-subtle);
566
+
text-decoration: none;
567
+
font-family: var(--font-mono);
568
+
font-size: 0.9em;
569
+
}
570
+
571
+
.embed-time:hover {
572
+
color: var(--color-link);
573
+
}
574
+
575
+
.embed-type {
576
+
font-size: 0.8em;
577
+
color: var(--color-subtle);
578
+
font-family: var(--font-mono);
579
+
text-transform: uppercase;
580
+
letter-spacing: 0.05em;
581
+
}
582
+
583
+
/* Embed URL link (shown with syntax in editor) */
584
+
.embed-url {
585
+
color: var(--color-link);
586
+
font-family: var(--font-mono);
587
+
font-size: 0.9em;
588
+
word-break: break-all;
589
+
}
590
+
591
+
/* External link cards */
592
+
.embed-external {
593
+
display: flex;
594
+
gap: 0.75rem;
595
+
padding: 0.75rem;
596
+
background: var(--color-surface);
597
+
border: 1px dashed var(--color-border);
598
+
text-decoration: none;
599
+
color: inherit;
600
+
margin-top: 0.5rem;
601
+
}
602
+
603
+
.embed-external:hover {
604
+
border-left: 2px solid var(--color-primary);
605
+
margin-left: -1px;
606
+
}
607
+
608
+
@media (prefers-color-scheme: dark) {
609
+
.embed-external {
610
+
border: 1px solid var(--color-border);
611
+
}
612
+
613
+
.embed-external:hover {
614
+
border-left: 2px solid var(--color-primary);
615
+
margin-left: -1px;
616
+
}
617
+
}
618
+
619
+
.embed-external-thumb {
620
+
width: 120px;
621
+
height: 80px;
622
+
object-fit: cover;
623
+
flex-shrink: 0;
624
+
}
625
+
626
+
.embed-external-info {
627
+
display: flex;
628
+
flex-direction: column;
629
+
gap: 0.25rem;
630
+
min-width: 0;
631
+
}
632
+
633
+
.embed-external-title {
634
+
font-weight: 600;
635
+
color: var(--color-text);
636
+
overflow: hidden;
637
+
text-overflow: ellipsis;
638
+
white-space: nowrap;
639
+
}
640
+
641
+
.embed-external-description {
642
+
font-size: 0.9em;
643
+
color: var(--color-muted);
644
+
overflow: hidden;
645
+
text-overflow: ellipsis;
646
+
display: -webkit-box;
647
+
-webkit-line-clamp: 2;
648
+
-webkit-box-orient: vertical;
649
+
}
650
+
651
+
.embed-external-url {
652
+
font-size: 0.8em;
653
+
font-family: var(--font-mono);
654
+
color: var(--color-subtle);
655
+
}
656
+
657
+
/* Image embeds */
658
+
.embed-images {
659
+
display: grid;
660
+
gap: 4px;
661
+
margin-top: 0.5rem;
662
+
overflow: hidden;
663
+
}
664
+
665
+
.embed-images-1 {
666
+
grid-template-columns: 1fr;
667
+
}
668
+
669
+
.embed-images-2 {
670
+
grid-template-columns: 1fr 1fr;
671
+
}
672
+
673
+
.embed-images-3 {
674
+
grid-template-columns: 1fr 1fr;
675
+
}
676
+
677
+
.embed-images-4 {
678
+
grid-template-columns: 1fr 1fr;
679
+
}
680
+
681
+
.embed-image-link {
682
+
display: block;
683
+
line-height: 0;
684
+
}
685
+
686
+
.embed-image {
687
+
width: 100%;
688
+
height: auto;
689
+
max-height: 500px;
690
+
object-fit: cover;
691
+
object-position: center;
692
+
margin: 0;
693
+
}
694
+
695
+
/* Quoted records */
696
+
.embed-quote {
697
+
display: block;
698
+
margin-top: 0.5rem;
699
+
padding: 0.75rem;
700
+
background: var(--color-overlay);
701
+
border-left: 2px solid var(--color-tertiary);
702
+
}
703
+
704
+
@media (prefers-color-scheme: dark) {
705
+
.embed-quote {
706
+
border: 1px solid var(--color-border);
707
+
border-left: 2px solid var(--color-tertiary);
708
+
}
709
+
}
710
+
711
+
.embed-quote .embed-author {
712
+
margin-bottom: 0.5rem;
713
+
}
714
+
715
+
.embed-quote .embed-avatar {
716
+
width: 24px;
717
+
height: 24px;
718
+
min-width: 24px;
719
+
min-height: 24px;
720
+
max-width: 24px;
721
+
max-height: 24px;
722
+
}
723
+
724
+
.embed-quote .embed-content {
725
+
font-size: 0.95em;
726
+
margin-bottom: 0;
727
+
}
728
+
729
+
/* Placeholder states */
730
+
.embed-video-placeholder,
731
+
.embed-not-found,
732
+
.embed-blocked,
733
+
.embed-detached,
734
+
.embed-unknown {
735
+
display: block;
736
+
padding: 1rem;
737
+
background: var(--color-overlay);
738
+
border-left: 2px solid var(--color-border);
739
+
color: var(--color-muted);
740
+
font-style: italic;
741
+
margin-top: 0.5rem;
742
+
font-family: var(--font-mono);
743
+
font-size: 0.9em;
744
+
}
745
+
746
+
@media (prefers-color-scheme: dark) {
747
+
.embed-video-placeholder,
748
+
.embed-not-found,
749
+
.embed-blocked,
750
+
.embed-detached,
751
+
.embed-unknown {
752
+
border: 1px dashed var(--color-border);
753
+
}
754
+
}
755
+
756
+
/* Record card embeds (feeds, lists, labelers, starter packs) */
757
+
.embed-record-card {
758
+
display: block;
759
+
margin-top: 0.5rem;
760
+
padding: 0.75rem;
761
+
background: var(--color-overlay);
762
+
border-left: 2px solid var(--color-tertiary);
763
+
}
764
+
765
+
.embed-record-card > .embed-author-name {
766
+
display: block;
767
+
font-size: 1.1em;
768
+
}
769
+
770
+
.embed-subtitle {
771
+
display: block;
772
+
font-size: 0.85em;
773
+
color: var(--color-muted);
774
+
margin-bottom: 0.5rem;
775
+
}
776
+
777
+
.embed-record-card .embed-description {
778
+
display: block;
779
+
margin: 0.5rem 0;
780
+
}
781
+
782
+
.embed-record-card .embed-stats {
783
+
display: block;
784
+
margin-top: 0.25rem;
785
+
}
786
+
787
+
/* Generic record fields */
788
+
.embed-fields {
789
+
display: block;
790
+
margin-top: 0.5rem;
791
+
font-family: var(--font-ui);
792
+
font-size: 0.85rem;
793
+
color: var(--color-muted);
794
+
}
795
+
796
+
.embed-field {
797
+
display: block;
798
+
margin-top: 0.25rem;
799
+
}
800
+
801
+
/* Nested fields get indentation */
802
+
.embed-fields .embed-fields {
803
+
display: block;
804
+
margin-top: 0.5rem;
805
+
margin-left: 1rem;
806
+
padding-left: 0.5rem;
807
+
border-left: 1px solid var(--color-border);
808
+
}
809
+
810
+
/* Type label inside fields should be block with spacing */
811
+
.embed-fields > .embed-author-handle {
812
+
display: block;
813
+
margin-bottom: 0.25rem;
814
+
}
815
+
816
+
.embed-field-name {
817
+
color: var(--color-subtle);
818
+
}
819
+
820
+
.embed-field-number {
821
+
color: var(--color-tertiary);
822
+
}
823
+
824
+
.embed-field-date {
825
+
color: var(--color-muted);
826
+
}
827
+
828
+
.embed-field-count {
829
+
color: var(--color-muted);
830
+
font-style: italic;
831
+
}
832
+
833
+
.embed-field-bool-true {
834
+
color: var(--color-success);
835
+
}
836
+
837
+
.embed-field-bool-false {
838
+
color: var(--color-muted);
839
+
}
840
+
841
+
.embed-field-link,
842
+
.embed-field-aturi {
843
+
color: var(--color-link);
844
+
text-decoration: none;
845
+
}
846
+
847
+
.embed-field-link:hover,
848
+
.embed-field-aturi:hover {
849
+
text-decoration: underline;
850
+
}
851
+
852
+
.embed-field-did {
853
+
font-family: var(--font-mono);
854
+
font-size: 0.9em;
855
+
}
856
+
857
+
.embed-field-did .did-scheme,
858
+
.embed-field-did .did-separator {
859
+
color: var(--color-muted);
860
+
}
861
+
862
+
.embed-field-did .did-method {
863
+
color: var(--color-tertiary);
864
+
}
865
+
866
+
.embed-field-did .did-identifier {
867
+
color: var(--color-text);
868
+
}
869
+
870
+
.embed-field-nsid {
871
+
color: var(--color-secondary);
872
+
}
873
+
874
+
.embed-field-handle {
875
+
color: var(--color-link);
876
+
}
877
+
878
+
/* AT URI highlighting */
879
+
.aturi-scheme {
880
+
color: var(--color-muted);
881
+
}
882
+
883
+
.aturi-slash {
884
+
color: var(--color-muted);
885
+
}
886
+
887
+
.aturi-authority {
888
+
color: var(--color-link);
889
+
}
890
+
891
+
.aturi-collection {
892
+
color: var(--color-secondary);
893
+
}
894
+
895
+
.aturi-rkey {
896
+
color: var(--color-tertiary);
897
+
}
898
+
899
+
/* Generic AT Protocol record embed */
900
+
.atproto-record > .embed-author-handle {
901
+
display: block;
902
+
margin-bottom: 0.25rem;
903
+
}
904
+
905
+
.atproto-record > .embed-author-name {
906
+
display: block;
907
+
margin-bottom: 0.5rem;
908
+
}
909
+
910
+
.atproto-record > .embed-content {
911
+
margin-bottom: 0.5rem;
912
+
}
913
+
914
+
/* Notebook entry embed - full width, expandable */
915
+
.atproto-entry {
916
+
max-width: none;
917
+
width: 100%;
918
+
margin: 1.5rem 0;
919
+
padding: 0;
920
+
background: var(--color-surface);
921
+
border: 1px solid var(--color-border);
922
+
border-left: 1px solid var(--color-border);
923
+
box-shadow: none;
924
+
overflow: hidden;
925
+
}
926
+
927
+
.atproto-entry:hover {
928
+
border-left-color: var(--color-border);
929
+
}
930
+
931
+
@media (prefers-color-scheme: dark) {
932
+
.atproto-entry {
933
+
border: 1px solid var(--color-border);
934
+
border-left: 1px solid var(--color-border);
935
+
}
936
+
}
937
+
938
+
.embed-entry-header {
939
+
display: flex;
940
+
flex-wrap: wrap;
941
+
align-items: baseline;
942
+
gap: 0.5rem 1rem;
943
+
padding: 0.75rem 1rem;
944
+
background: var(--color-overlay);
945
+
border-bottom: 1px solid var(--color-border);
946
+
}
947
+
948
+
.embed-entry-title {
949
+
font-size: 1.1em;
950
+
font-weight: 600;
951
+
color: var(--color-text);
952
+
}
953
+
954
+
.embed-entry-author {
955
+
font-size: 0.85em;
956
+
color: var(--color-muted);
957
+
}
958
+
959
+
/* Hidden checkbox for expand/collapse */
960
+
.embed-entry-toggle {
961
+
display: none;
962
+
}
963
+
964
+
/* Content wrapper - scrollable when collapsed */
965
+
.embed-entry-content {
966
+
max-height: 30rem;
967
+
overflow-y: auto;
968
+
padding: 1rem;
969
+
transition: max-height 0.3s ease;
970
+
}
971
+
972
+
/* When checkbox is checked, expand fully */
973
+
.embed-entry-toggle:checked ~ .embed-entry-content {
974
+
max-height: none;
975
+
}
976
+
977
+
/* Expand/collapse button */
978
+
.embed-entry-expand {
979
+
display: block;
980
+
width: 100%;
981
+
padding: 0.5rem;
982
+
text-align: center;
983
+
font-size: 0.85em;
984
+
font-family: var(--font-ui);
985
+
color: var(--color-muted);
986
+
background: var(--color-overlay);
987
+
border-top: 1px solid var(--color-border);
988
+
cursor: pointer;
989
+
user-select: none;
990
+
}
991
+
992
+
.embed-entry-expand:hover {
993
+
color: var(--color-text);
994
+
background: var(--color-surface);
995
+
}
996
+
997
+
/* Toggle button text */
998
+
.embed-entry-expand::before {
999
+
content: "Expand ↓";
1000
+
}
1001
+
1002
+
.embed-entry-toggle:checked ~ .embed-entry-expand::before {
1003
+
content: "Collapse ↑";
1004
+
}
1005
+
1006
+
/* Hide expand button if content doesn't overflow (via JS class) */
1007
+
.atproto-entry.no-overflow .embed-entry-expand {
1008
+
display: none;
1009
+
}
1010
+
1011
+
/* Horizontal Rule */
1012
+
hr {
1013
+
border: none;
1014
+
border-top: 2px solid var(--color-border);
1015
+
margin: 2rem 0;
1016
+
}
1017
+
1018
+
/* Tablet and mobile responsiveness */
1019
+
@media (max-width: 900px) {
1020
+
.notebook-content {
1021
+
padding: 1.5rem 1rem;
1022
+
max-width: 100%;
1023
+
}
1024
+
1025
+
h1 { font-size: 1.85rem; }
1026
+
h2 { font-size: 1.4rem; }
1027
+
h3 { font-size: 1.2rem; }
1028
+
1029
+
blockquote {
1030
+
margin-left: 0;
1031
+
margin-right: 0;
1032
+
}
1033
+
}
1034
+
1035
+
/* Small mobile phones */
1036
+
@media (max-width: 480px) {
1037
+
.notebook-content {
1038
+
padding: 1rem 0.75rem;
1039
+
}
1040
+
1041
+
h1 { font-size: 1.65rem; }
1042
+
h2 { font-size: 1.3rem; }
1043
+
h3 { font-size: 1.1rem; }
1044
+
1045
+
blockquote {
1046
+
padding-left: 0.75rem;
1047
+
padding-right: 0.75rem;
1048
+
}
1049
+
}
1050
+
</style>
1051
+
<style>
1052
+
/* Syntax highlighting - Light Mode (default) */
1053
+
/*
1054
+
* theme "Rosé Pine Dawn" generated by syntect
1055
+
*/
1056
+
1057
+
.wvc-code {
1058
+
color: #575279;
1059
+
background-color: #faf4ed;
1060
+
}
1061
+
1062
+
.wvc-comment {
1063
+
color: #797593;
1064
+
font-style: italic;
1065
+
}
1066
+
.wvc-string, .wvc-punctuation.wvc-definition.wvc-string {
1067
+
color: #ea9d34;
1068
+
}
1069
+
.wvc-constant.wvc-numeric {
1070
+
color: #ea9d34;
1071
+
}
1072
+
.wvc-constant.wvc-language {
1073
+
color: #ea9d34;
1074
+
font-weight: bold;
1075
+
}
1076
+
.wvc-constant.wvc-character, .wvc-constant.wvc-other {
1077
+
color: #ea9d34;
1078
+
}
1079
+
.wvc-variable {
1080
+
color: #575279;
1081
+
font-style: italic;
1082
+
}
1083
+
.wvc-keyword {
1084
+
color: #286983;
1085
+
}
1086
+
.wvc-storage {
1087
+
color: #56949f;
1088
+
}
1089
+
.wvc-storage.wvc-type {
1090
+
color: #56949f;
1091
+
}
1092
+
.wvc-entity.wvc-name.wvc-class {
1093
+
color: #286983;
1094
+
font-weight: bold;
1095
+
}
1096
+
.wvc-entity.wvc-other.wvc-inherited-class {
1097
+
color: #286983;
1098
+
font-style: italic;
1099
+
}
1100
+
.wvc-entity.wvc-name.wvc-function {
1101
+
color: #d7827e;
1102
+
font-style: italic;
1103
+
}
1104
+
.wvc-variable.wvc-parameter {
1105
+
color: #907aa9;
1106
+
}
1107
+
.wvc-entity.wvc-name.wvc-tag {
1108
+
color: #286983;
1109
+
font-weight: bold;
1110
+
}
1111
+
.wvc-entity.wvc-other.wvc-attribute-name {
1112
+
color: #907aa9;
1113
+
}
1114
+
.wvc-support.wvc-function {
1115
+
color: #d7827e;
1116
+
font-weight: bold;
1117
+
}
1118
+
.wvc-support.wvc-constant {
1119
+
color: #ea9d34;
1120
+
font-weight: bold;
1121
+
}
1122
+
.wvc-support.wvc-type, .wvc-support.wvc-class {
1123
+
color: #56949f;
1124
+
font-weight: bold;
1125
+
}
1126
+
.wvc-support.wvc-other.wvc-variable {
1127
+
color: #b4637a;
1128
+
font-weight: bold;
1129
+
}
1130
+
.wvc-invalid {
1131
+
color: #575279;
1132
+
background-color: #b4637a;
1133
+
}
1134
+
.wvc-invalid.wvc-deprecated {
1135
+
color: #575279;
1136
+
background-color: #907aa9;
1137
+
}
1138
+
.wvc-punctuation, .wvc-keyword.wvc-operator {
1139
+
color: #797593;
1140
+
}
1141
+
1142
+
1143
+
/* Syntax highlighting - Dark Mode */
1144
+
@media (prefers-color-scheme: dark) {
1145
+
/*
1146
+
* theme "Rosé Pine" generated by syntect
1147
+
*/
1148
+
1149
+
.wvc-code {
1150
+
color: #e0def4;
1151
+
background-color: #191724;
1152
+
}
1153
+
1154
+
.wvc-comment {
1155
+
color: #908caa;
1156
+
font-style: italic;
1157
+
}
1158
+
.wvc-string, .wvc-punctuation.wvc-definition.wvc-string {
1159
+
color: #f6c177;
1160
+
}
1161
+
.wvc-constant.wvc-numeric {
1162
+
color: #f6c177;
1163
+
}
1164
+
.wvc-constant.wvc-language {
1165
+
color: #f6c177;
1166
+
font-weight: bold;
1167
+
}
1168
+
.wvc-constant.wvc-character, .wvc-constant.wvc-other {
1169
+
color: #f6c177;
1170
+
}
1171
+
.wvc-variable {
1172
+
color: #e0def4;
1173
+
font-style: italic;
1174
+
}
1175
+
.wvc-keyword {
1176
+
color: #31748f;
1177
+
}
1178
+
.wvc-storage {
1179
+
color: #9ccfd8;
1180
+
}
1181
+
.wvc-storage.wvc-type {
1182
+
color: #9ccfd8;
1183
+
}
1184
+
.wvc-entity.wvc-name.wvc-class {
1185
+
color: #31748f;
1186
+
font-weight: bold;
1187
+
}
1188
+
.wvc-entity.wvc-other.wvc-inherited-class {
1189
+
color: #31748f;
1190
+
font-style: italic;
1191
+
}
1192
+
.wvc-entity.wvc-name.wvc-function {
1193
+
color: #ebbcba;
1194
+
font-style: italic;
1195
+
}
1196
+
.wvc-variable.wvc-parameter {
1197
+
color: #c4a7e7;
1198
+
}
1199
+
.wvc-entity.wvc-name.wvc-tag {
1200
+
color: #31748f;
1201
+
font-weight: bold;
1202
+
}
1203
+
.wvc-entity.wvc-other.wvc-attribute-name {
1204
+
color: #c4a7e7;
1205
+
}
1206
+
.wvc-support.wvc-function {
1207
+
color: #ebbcba;
1208
+
font-weight: bold;
1209
+
}
1210
+
.wvc-support.wvc-constant {
1211
+
color: #f6c177;
1212
+
font-weight: bold;
1213
+
}
1214
+
.wvc-support.wvc-type, .wvc-support.wvc-class {
1215
+
color: #9ccfd8;
1216
+
font-weight: bold;
1217
+
}
1218
+
.wvc-support.wvc-other.wvc-variable {
1219
+
color: #eb6f92;
1220
+
font-weight: bold;
1221
+
}
1222
+
.wvc-invalid {
1223
+
color: #e0def4;
1224
+
background-color: #eb6f92;
1225
+
}
1226
+
.wvc-invalid.wvc-deprecated {
1227
+
color: #e0def4;
1228
+
background-color: #c4a7e7;
1229
+
}
1230
+
.wvc-punctuation, .wvc-keyword.wvc-operator {
1231
+
color: #908caa;
1232
+
}
1233
+
}
1234
+
</style>
1235
+
</head>
1236
+
<body style="background: var(--color-base); min-height: 100vh;">
1237
+
<div class="notebook-content">
1238
+
<h1>Weaver: Long-form Writing on AT Protocol</h1>
1239
+
<p><em>Or: "Get in kid, we're rebuilding the blogosphere!"</em></p>
1240
+
<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>
1241
+
<blockquote>
1242
+
<p><img src="weaver_photo_med.jpg" alt="weaver_photo_med.jpg" /><em>The namesake of what I'm building</em></p>
1243
+
</blockquote>
1244
+
<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>
1245
+
<h2>The Blogosphere</h2>
1246
+
<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>
1247
+
<aside>
1248
+
<blockquote>
1249
+
<p><strong>On Platform Decay</strong></p>
1250
+
<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>
1251
+
</blockquote>
1252
+
</aside>
1253
+
<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>
1254
+
<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>
1255
+
<h2>The Pitch</h2>
1256
+
<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>
1257
+
<aside>
1258
+
<blockquote>
1259
+
<p><strong>The Ultimate Goal</strong></p>
1260
+
<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>
1261
+
</blockquote>
1262
+
</aside>
1263
+
<h2>How It Works</h2>
1264
+
<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>
1265
+
<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>
1266
+
<h2>Why Rust?</h2>
1267
+
<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>
1268
+
<aside>
1269
+
<blockquote>
1270
+
<p><strong>On Interoperability</strong></p>
1271
+
<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>
1272
+
</blockquote>
1273
+
</aside>
1274
+
<h2>Evolution</h2>
1275
+
<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>
1276
+
<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>
1277
+
</div>
1278
+
</body>
1279
+
</html>