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