···15851585 ident: ReadSignal<AtIdentifier<'static>>,
15861586 rkey: ReadSignal<SmolStr>,
15871587) -> (
15881588- Result<Resource<Option<(serde_json::Value, serde_json::Value, Option<String>)>>, RenderError>,
15881588+ Result<Resource<Option<(serde_json::Value, serde_json::Value, Option<String>, Option<String>)>>, RenderError>,
15891589 Memo<Option<crate::fetch::LeafletDocumentData>>,
15901590) {
15911591 use weaver_api::pub_leaflet::document::Document;
···15951595 let res = use_server_future(move || {
15961596 let fetcher = fetcher.clone();
15971597 async move {
15981598+ use jacquard::IntoStatic;
15981599 use jacquard::client::AgentSessionExt;
16001600+ use jacquard::prelude::IdentityResolver;
16011601+ use weaver_api::pub_leaflet::document::DocumentPagesItem;
15991602 use weaver_api::pub_leaflet::publication::Publication;
16031603+ use weaver_renderer::leaflet::{LeafletRenderContext, render_linear_document};
1600160416011605 let ident = ident();
16021606 let rkey = rkey();
···16461650 None
16471651 };
1648165216531653+ // Render HTML
16541654+ let rendered_html = {
16551655+ let author_did = match &record.value.author {
16561656+ AtIdentifier::Did(d) => d.clone().into_static(),
16571657+ AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
16581658+ };
16591659+ let ctx = LeafletRenderContext::new(author_did);
16601660+ let mut html = String::new();
16611661+ for page in &record.value.pages {
16621662+ match page {
16631663+ DocumentPagesItem::LinearDocument(linear_doc) => {
16641664+ html.push_str(&render_linear_document(linear_doc, &ctx, &fetcher).await);
16651665+ }
16661666+ DocumentPagesItem::Canvas(_) => {
16671667+ html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>");
16681668+ }
16691669+ DocumentPagesItem::Unknown(_) => {
16701670+ html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>");
16711671+ }
16721672+ }
16731673+ }
16741674+ Some(html)
16751675+ };
16761676+16491677 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1650167816511679 Some((
16521680 serde_json::to_value(&record.value).ok()?,
16531681 serde_json::to_value(&*profile).ok()?,
16541682 publication_base_path,
16831683+ rendered_html,
16551684 ))
16561685 }
16571686 });
···16611690 use weaver_api::sh_weaver::actor::ProfileDataView;
1662169116631692 let res = res.as_ref().ok()?;
16641664- if let Some(Some((doc_json, profile_json, base_path))) = &*res.read() {
16931693+ if let Some(Some((doc_json, profile_json, base_path, rendered_html))) = &*res.read() {
16651694 let document = jacquard::from_json_value::<Document>(doc_json.clone()).ok()?;
16661695 let profile =
16671696 jacquard::from_json_value::<ProfileDataView>(profile_json.clone()).ok()?;
···16691698 document,
16701699 profile,
16711700 publication_base_path: base_path.clone(),
17011701+ rendered_html: rendered_html.clone(),
16721702 })
16731703 } else {
16741704 None
···16871717 Memo<Option<crate::fetch::LeafletDocumentData>>,
16881718) {
16891719 use jacquard::IntoStatic;
16901690- use weaver_api::pub_leaflet::document::Document;
17201720+ use jacquard::prelude::IdentityResolver;
17211721+ use weaver_api::pub_leaflet::document::{Document, DocumentPagesItem};
16911722 use weaver_api::pub_leaflet::publication::Publication;
17231723+ use weaver_renderer::leaflet::{LeafletRenderContext, render_linear_document};
1692172416931725 let fetcher = use_context::<crate::fetch::Fetcher>();
16941726···17301762 None
17311763 };
1732176417651765+ // Render HTML
17661766+ let rendered_html = {
17671767+ let author_did = match &record.value.author {
17681768+ AtIdentifier::Did(d) => d.clone().into_static(),
17691769+ AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
17701770+ };
17711771+ let ctx = LeafletRenderContext::new(author_did);
17721772+ let mut html = String::new();
17731773+ for page in &record.value.pages {
17741774+ match page {
17751775+ DocumentPagesItem::LinearDocument(linear_doc) => {
17761776+ html.push_str(&render_linear_document(linear_doc, &ctx, &fetcher).await);
17771777+ }
17781778+ DocumentPagesItem::Canvas(_) => {
17791779+ html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>");
17801780+ }
17811781+ DocumentPagesItem::Unknown(_) => {
17821782+ html.push_str("<div class=\"embed-video-placeholder\">[Unknown page type]</div>");
17831783+ }
17841784+ }
17851785+ }
17861786+ Some(html)
17871787+ };
17881788+17331789 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1734179017351791 Some(crate::fetch::LeafletDocumentData {
17361792 document: record.value.into_static(),
17371793 profile: (*profile).clone(),
17381794 publication_base_path,
17951795+ rendered_html,
17391796 })
17401797 }
17411798 });
···17531810 ident: ReadSignal<AtIdentifier<'static>>,
17541811 rkey: ReadSignal<SmolStr>,
17551812) -> (
17561756- Result<Resource<Option<(serde_json::Value, serde_json::Value, Option<String>)>>, RenderError>,
18131813+ Result<Resource<Option<(serde_json::Value, serde_json::Value, Option<String>, Option<String>)>>, RenderError>,
17571814 Memo<Option<crate::fetch::PcktDocumentData>>,
17581815) {
17591816 let fetcher = use_context::<crate::fetch::Fetcher>();
···17611818 let res = use_server_future(move || {
17621819 let fetcher = fetcher.clone();
17631820 async move {
18211821+ use jacquard::IntoStatic;
17641822 use jacquard::client::AgentSessionExt;
18231823+ use jacquard::prelude::IdentityResolver;
17651824 use weaver_api::site_standard::document::Document as SiteStandardDocument;
17661825 use weaver_api::site_standard::publication::Publication;
18261826+ use weaver_renderer::pckt::{PcktRenderContext, render_content_blocks};
1767182717681828 let ident = ident();
17691829 let rkey = rkey();
···18071867 None
18081868 };
1809186918701870+ // Render HTML
18711871+ let rendered_html = {
18721872+ let author_did = match &ident {
18731873+ AtIdentifier::Did(d) => d.clone().into_static(),
18741874+ AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
18751875+ };
18761876+ let ctx = PcktRenderContext::new(author_did);
18771877+ if let Some(blocks) = &doc.content {
18781878+ Some(render_content_blocks(blocks, &ctx, &fetcher).await)
18791879+ } else {
18801880+ Some(String::from("<p>No content</p>"))
18811881+ }
18821882+ };
18831883+18101884 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1811188518121886 Some((
18131887 serde_json::to_value(&doc).ok()?,
18141888 serde_json::to_value(&*profile).ok()?,
18151889 publication_url,
18901890+ rendered_html,
18161891 ))
18171892 }
18181893 });
···18221897 use weaver_api::site_standard::document::Document as SiteStandardDocument;
1823189818241899 let res = res.as_ref().ok()?;
18251825- if let Some(Some((doc_json, profile_json, publication_url))) = &*res.read() {
19001900+ if let Some(Some((doc_json, profile_json, publication_url, rendered_html))) = &*res.read() {
18261901 let document =
18271902 jacquard::from_json_value::<SiteStandardDocument>(doc_json.clone()).ok()?;
18281903 let profile =
···18311906 document,
18321907 profile,
18331908 publication_url: publication_url.clone(),
19091909+ rendered_html: rendered_html.clone(),
18341910 })
18351911 } else {
18361912 None
···18491925 Memo<Option<crate::fetch::PcktDocumentData>>,
18501926) {
18511927 use jacquard::IntoStatic;
19281928+ use jacquard::prelude::IdentityResolver;
18521929 use weaver_api::site_standard::document::Document as SiteStandardDocument;
18531930 use weaver_api::site_standard::publication::Publication;
19311931+ use weaver_renderer::pckt::{PcktRenderContext, render_content_blocks};
1854193218551933 let fetcher = use_context::<crate::fetch::Fetcher>();
18561934···18991977 None
19001978 };
1901197919801980+ // Render HTML
19811981+ let rendered_html = {
19821982+ let author_did = match &ident {
19831983+ AtIdentifier::Did(d) => d.clone().into_static(),
19841984+ AtIdentifier::Handle(h) => fetcher.resolve_handle(h).await.ok()?.into_static(),
19851985+ };
19861986+ let ctx = PcktRenderContext::new(author_did);
19871987+ if let Some(blocks) = &doc.content {
19881988+ Some(render_content_blocks(blocks, &ctx, &fetcher).await)
19891989+ } else {
19901990+ Some(String::from("<p>No content</p>"))
19911991+ }
19921992+ };
19931993+19021994 let profile = fetcher.fetch_profile(&ident).await.ok()?;
1903199519041996 Some(crate::fetch::PcktDocumentData {
19051997 document: doc,
19061998 profile: (*profile).clone(),
19071999 publication_url,
20002000+ rendered_html,
19082001 })
19092002 }
19102003 });
+4
crates/weaver-app/src/fetch.rs
···9090 pub profile: ProfileDataView<'static>,
9191 /// Publication base_path for constructing external URL (e.g., "connectedplaces.leaflet.pub")
9292 pub publication_base_path: Option<String>,
9393+ /// Pre-rendered HTML content
9494+ pub rendered_html: Option<String>,
9395}
94969597/// Data for a site.standard / blog.pckt document
···100102 pub profile: ProfileDataView<'static>,
101103 /// Publication URL for constructing external URL (e.g., "https://crypto.pckt.blog")
102104 pub publication_url: Option<String>,
105105+ /// Pre-rendered HTML content
106106+ pub rendered_html: Option<String>,
103107}
104108105109pub struct Client {
+3-3
crates/weaver-app/src/views/entry.rs
···7777 document::Link { rel: "stylesheet", href: ENTRY_CSS }
7878 NotebookCss { ident: ident().to_smolstr(), notebook: book_title.clone() }
79798080- div { class: "entry-page-layout",
8080+ div { class: "entry-page",
8181 if let Some(ref prev) = book_entry_view.prev {
8282 div { class: "nav-gutter nav-prev",
8383 NavButton {
···133133 DefaultNotebookCss {}
134134135135136136- div { class: "entry-page-layout",
136136+ div { class: "entry-page",
137137 div { class: "entry-content-main notebook-content",
138138 {
139139 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record.content);
···234234 document::Link { rel: "stylesheet", href: ENTRY_CSS }
235235 NotebookCss { ident: ident().to_smolstr(), notebook: book_title() }
236236237237- div { class: "entry-page-layout",
237237+ div { class: "entry-page",
238238 if let Some(ref prev) = book_entry_view.prev {
239239 div { class: "nav-gutter nav-prev",
240240 NavButton {
+17-127
crates/weaver-app/src/views/external.rs
···7788use crate::components::css::DefaultNotebookCss;
99use crate::components::{AuthorList, extract_author_info};
1010-use crate::fetch::Fetcher;
11101211#[component]
1312pub fn WhiteWindEntry(
···7170 document::Link { rel: "stylesheet", href: ENTRY_CSS }
7271 DefaultNotebookCss {}
73727474- div { class: "entry-page-layout",
7373+ div { class: "entry-page",
7574 div { class: "entry-content-main notebook-content",
7675 header { class: "entry-metadata",
7776 div { class: "entry-header-row",
···141140 rkey: ReadSignal<SmolStr>,
142141) -> Element {
143142 use crate::components::{ENTRY_CSS, EntryOgMeta};
144144- use weaver_api::pub_leaflet::document::DocumentPagesItem;
145143146144 let (entry_res, entry_data) = crate::data::use_leaflet_document_data(ident, rkey);
147145···170168 .record(data.profile.clone())
171169 .build();
172170173173- let pages = data.document.pages.clone();
174174- let author_did = data.document.author.clone();
175175-176171 rsx! {
177172 EntryOgMeta {
178173 title: title.to_string(),
···184179 document::Link { rel: "stylesheet", href: ENTRY_CSS }
185180 DefaultNotebookCss {}
186181187187- div { class: "entry-page-layout",
182182+ div { class: "entry-page",
188183 div { class: "entry-content-main notebook-content",
189184 header { class: "entry-metadata",
190185 div { class: "entry-header-row",
···206201 }
207202 }
208203 }
209209- LeafletContent {
210210- pages: pages,
211211- author_did: author_did,
204204+ if let Some(ref html) = data.rendered_html {
205205+ div {
206206+ class: "entry leaflet-document",
207207+ dangerous_inner_html: "{html}"
208208+ }
209209+ } else {
210210+ p { "Rendering..." }
212211 }
213212 }
214213 }
···218217 }
219218}
220219221221-#[component]
222222-fn LeafletContent(
223223- pages: Vec<weaver_api::pub_leaflet::document::DocumentPagesItem<'static>>,
224224- author_did: jacquard::types::string::AtIdentifier<'static>,
225225-) -> Element {
226226- use jacquard::IntoStatic;
227227- use jacquard::prelude::*;
228228- use weaver_api::pub_leaflet::document::DocumentPagesItem;
229229- use weaver_renderer::leaflet::{LeafletRenderContext, render_linear_document};
230230-231231- let fetcher = use_context::<Fetcher>();
232232-233233- let html = use_resource(move || {
234234- let pages = pages.clone();
235235- let author_did = author_did.clone();
236236- let fetcher = fetcher.clone();
237237- async move {
238238- let mut html = String::new();
239239-240240- // Resolve author DID
241241- let did = match &author_did {
242242- jacquard::types::string::AtIdentifier::Did(d) => d.clone().into_static(),
243243- jacquard::types::string::AtIdentifier::Handle(h) => {
244244- match fetcher.resolve_handle(h).await {
245245- Ok(d) => d.into_static(),
246246- Err(_) => return String::from("<p>Failed to resolve author</p>"),
247247- }
248248- }
249249- };
250250-251251- let ctx = LeafletRenderContext::new(did);
252252-253253- for page in &pages {
254254- match page {
255255- DocumentPagesItem::LinearDocument(linear_doc) => {
256256- html.push_str(&render_linear_document(linear_doc, &ctx, &fetcher).await);
257257- }
258258- DocumentPagesItem::Canvas(_) => {
259259- html.push_str("<div class=\"embed-video-placeholder\">[Canvas layout not yet supported]</div>");
260260- }
261261- DocumentPagesItem::Unknown(_) => {
262262- html.push_str(
263263- "<div class=\"embed-video-placeholder\">[Unknown page type]</div>",
264264- );
265265- }
266266- }
267267- }
268268-269269- html
270270- }
271271- });
272272-273273- match &*html.read() {
274274- Some(content) => rsx! {
275275- div {
276276- class: "entry leaflet-document",
277277- dangerous_inner_html: "{content}"
278278- }
279279- },
280280- None => rsx! { p { "Rendering..." } },
281281- }
282282-}
283283-284220#[cfg(feature = "pckt")]
285221#[component]
286222pub fn PcktEntry(ident: ReadSignal<AtIdentifier<'static>>, rkey: ReadSignal<SmolStr>) -> Element {
···327263 .format("%B %d, %Y")
328264 .to_string();
329265330330- let content = data.document.content.clone();
331331- let author_did = ident();
332332-333266 // Build external URL from publication URL + path (or rkey)
334267 let doc_path = data
335268 .document
···349282 document::Link { rel: "stylesheet", href: ENTRY_CSS }
350283 DefaultNotebookCss {}
351284352352- div { class: "entry-page-layout",
285285+ div { class: "entry-page",
353286 div { class: "entry-content-main notebook-content",
354287 header { class: "entry-metadata",
355288 div { class: "entry-header-row",
···379312 }
380313 }
381314 }
382382- PcktContent {
383383- content: content,
384384- author_did: author_did,
315315+ if let Some(ref html) = data.rendered_html {
316316+ div {
317317+ class: "entry pckt-document",
318318+ dangerous_inner_html: "{html}"
319319+ }
320320+ } else {
321321+ p { "Rendering..." }
385322 }
386323 }
387324 }
388325 }
389326 }
390327 None => rsx! { p { "Loading..." } },
391391- }
392392-}
393393-394394-#[cfg(feature = "pckt")]
395395-#[component]
396396-fn PcktContent(
397397- content: Option<Vec<jacquard::types::value::Data<'static>>>,
398398- author_did: AtIdentifier<'static>,
399399-) -> Element {
400400- use jacquard::IntoStatic;
401401- use jacquard::prelude::*;
402402- use weaver_renderer::pckt::{PcktRenderContext, render_content_blocks};
403403-404404- let fetcher = use_context::<Fetcher>();
405405-406406- let html = use_resource(move || {
407407- let content = content.clone();
408408- let author_did = author_did.clone();
409409- let fetcher = fetcher.clone();
410410- async move {
411411- // Resolve author DID
412412- let did = match &author_did {
413413- AtIdentifier::Did(d) => d.clone().into_static(),
414414- AtIdentifier::Handle(h) => match fetcher.resolve_handle(h).await {
415415- Ok(d) => d.into_static(),
416416- Err(_) => return String::from("<p>Failed to resolve author</p>"),
417417- },
418418- };
419419-420420- let ctx = PcktRenderContext::new(did);
421421-422422- if let Some(blocks) = &content {
423423- render_content_blocks(blocks, &ctx, &fetcher).await
424424- } else {
425425- String::from("<p>No content</p>")
426426- }
427427- }
428428- });
429429-430430- match &*html.read() {
431431- Some(content) => rsx! {
432432- div {
433433- class: "entry pckt-document",
434434- dangerous_inner_html: "{content}"
435435- }
436436- },
437437- None => rsx! { p { "Rendering..." } },
438328 }
439329}
440330
+1-2
crates/weaver-renderer/src/css.rs
···132132/* When sidenotes exist, body padding creates the gutter */
133133/* Left padding shrinks first as viewport narrows, right stays for sidenotes */
134134body:has(.sidenote) {{
135135- padding-inline-start: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem);
135135+ padding-inline-start: clamp(1rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem);
136136 padding-inline-end: 15.5rem;
137137}}
138138···260260 font-size: 0.95em;
261261 border-bottom-right-radius: 5px;
262262 border-top-right-radius: 5px;
263263-}}
264263}}
265264266265/* Tables */
+95-61
test-sidenotes.html
···8484/* When sidenotes exist, body padding creates the gutter */
8585/* Left padding shrinks first as viewport narrows, right stays for sidenotes */
8686body:has(.sidenote) {
8787- padding-left: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem);
8888- padding-right: 15.5rem;
8787+ padding-inline-start: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem);
8888+ padding-inline-end: 15.5rem;
8989}
90909191/* Typography */
···154154155155/* Lists */
156156ul, ol {
157157- margin-left: 1rem;
157157+ margin-inline-start: 1rem;
158158 margin-bottom: 1rem;
159159}
160160···202202203203/* Blockquotes */
204204blockquote {
205205- border-left: 2px solid var(--color-secondary);
205205+ border-inline-start: 2px solid var(--color-secondary);
206206 background: var(--color-surface);
207207- padding-left: 1rem;
208208- padding-right: 1rem;
207207+ padding-inline-start: 1rem;
208208+ padding-inline-end: 1rem;
209209 padding-top: 0.5rem;
210210 padding-bottom: 0.04rem;
211211 margin: 1rem 0;
···228228th, td {
229229 border: 1px solid var(--color-border);
230230 padding: 0.5rem;
231231- text-align: left;
231231+ text-align: start;
232232}
233233234234th {
···270270271271.footnote-definition-label {
272272 font-weight: 600;
273273- margin-right: 0.5rem;
273273+ margin-inline-end: 0.5rem;
274274 color: var(--color-primary);
275275}
276276277277/* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */
278278.notebook-content aside,
279279.notebook-content .aside {
280280- float: left;
280280+ float: inline-start;
281281 width: 40%;
282282 margin: 0 1.5rem 1rem 0;
283283 padding: 1rem;
284284 background: var(--color-surface);
285285- border-right: 3px solid var(--color-primary);
285285+ border-inline-end: 3px solid var(--color-primary);
286286 font-size: 0.9em;
287287- clear: left;
287287+ clear: inline-start;
288288}
289289290290.notebook-content aside > *:first-child,
···300300/* Reset blockquote styling inside asides */
301301.notebook-content aside > blockquote,
302302.notebook-content .aside > blockquote {
303303- border-left: none;
303303+ border-inline-start: none;
304304 background: transparent;
305305 padding: 0;
306306 margin: 0;
···308308}
309309310310/* Indent utilities */
311311-.indent-1 { margin-left: 1em; }
312312-.indent-2 { margin-left: 2em; }
313313-.indent-3 { margin-left: 3em; }
311311+.indent-1 { margin-inline-start: 1em; }
312312+.indent-2 { margin-inline-start: 2em; }
313313+.indent-3 { margin-inline-start: 3em; }
314314315315/* Tufte-style Sidenotes */
316316/* Hide checkbox for sidenote toggle */
···329329 position: relative;
330330 top: -0.5em;
331331 color: var(--color-primary);
332332- padding-left: 0.1em;
332332+ padding-inline-start: 0.1em;
333333}
334334335335/* Sidenote content (margin notes on wide screens) */
336336.sidenote {
337337- float: right;
338338- clear: right;
339339- margin-right: -15.5rem;
337337+ float: inline-end;
338338+ clear: inline-end;
339339+ margin-inline-end: -15.5rem;
340340 width: 14rem;
341341 margin-top: 0.3rem;
342342 margin-bottom: 1rem;
···354354@media (max-width: 900px) {
355355 /* Reset sidenote gutter on mobile */
356356 body:has(.sidenote) {
357357- padding-right: 0;
357357+ padding-inline-end: 0;
358358 }
359359360360 aside, .aside {
···374374 margin: 0.5rem 2.5%;
375375 padding: 0.5rem;
376376 background: var(--color-surface);
377377- border-left: 2px solid var(--color-primary);
377377+ border-inline-start: 2px solid var(--color-primary);
378378 }
379379380380 label.sidenote-number {
···412412 margin: 1rem 0;
413413 padding: 1rem;
414414 background: var(--color-surface);
415415- border-left: 2px solid var(--color-secondary);
415415+ border-inline-start: 2px solid var(--color-secondary);
416416 box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent);
417417}
418418419419.atproto-embed:hover {
420420- border-left-color: var(--color-primary);
420420+ border-inline-start-color: var(--color-primary);
421421}
422422423423@media (prefers-color-scheme: dark) {
424424 .atproto-embed {
425425 box-shadow: none;
426426 border: 1px solid var(--color-border);
427427- border-left: 2px solid var(--color-secondary);
427427+ border-inline-start: 2px solid var(--color-secondary);
428428 }
429429}
430430···601601}
602602603603.embed-external:hover {
604604- border-left: 2px solid var(--color-primary);
605605- margin-left: -1px;
604604+ border-inline-start: 2px solid var(--color-primary);
605605+ margin-inline-start: -1px;
606606}
607607608608@media (prefers-color-scheme: dark) {
···611611 }
612612613613 .embed-external:hover {
614614- border-left: 2px solid var(--color-primary);
615615- margin-left: -1px;
614614+ border-inline-start: 2px solid var(--color-primary);
615615+ margin-inline-start: -1px;
616616 }
617617}
618618···698698 margin-top: 0.5rem;
699699 padding: 0.75rem;
700700 background: var(--color-overlay);
701701- border-left: 2px solid var(--color-tertiary);
701701+ border-inline-start: 2px solid var(--color-tertiary);
702702}
703703704704@media (prefers-color-scheme: dark) {
705705 .embed-quote {
706706 border: 1px solid var(--color-border);
707707- border-left: 2px solid var(--color-tertiary);
707707+ border-inline-start: 2px solid var(--color-tertiary);
708708 }
709709}
710710···735735 display: block;
736736 padding: 1rem;
737737 background: var(--color-overlay);
738738- border-left: 2px solid var(--color-border);
738738+ border-inline-start: 2px solid var(--color-border);
739739 color: var(--color-muted);
740740 font-style: italic;
741741 margin-top: 0.5rem;
···759759 margin-top: 0.5rem;
760760 padding: 0.75rem;
761761 background: var(--color-overlay);
762762- border-left: 2px solid var(--color-tertiary);
762762+ border-inline-start: 2px solid var(--color-tertiary);
763763}
764764765765.embed-record-card > .embed-author-name {
···802802.embed-fields .embed-fields {
803803 display: block;
804804 margin-top: 0.5rem;
805805- margin-left: 1rem;
806806- padding-left: 0.5rem;
807807- border-left: 1px solid var(--color-border);
805805+ margin-inline-start: 1rem;
806806+ padding-inline-start: 0.5rem;
807807+ border-inline-start: 1px solid var(--color-border);
808808}
809809810810/* Type label inside fields should be block with spacing */
···919919 padding: 0;
920920 background: var(--color-surface);
921921 border: 1px solid var(--color-border);
922922- border-left: 1px solid var(--color-border);
922922+ border-inline-start: 1px solid var(--color-border);
923923 box-shadow: none;
924924 overflow: hidden;
925925}
926926927927.atproto-entry:hover {
928928- border-left-color: var(--color-border);
928928+ border-inline-start-color: var(--color-border);
929929}
930930931931@media (prefers-color-scheme: dark) {
932932 .atproto-entry {
933933 border: 1px solid var(--color-border);
934934- border-left: 1px solid var(--color-border);
934934+ border-inline-start: 1px solid var(--color-border);
935935 }
936936}
937937···10271027 h3 { font-size: 1.2rem; }
1028102810291029 blockquote {
10301030- margin-left: 0;
10311031- margin-right: 0;
10301030+ margin-inline-start: 0;
10311031+ margin-inline-end: 0;
10321032 }
10331033}
10341034···10431043 h3 { font-size: 1.1rem; }
1044104410451045 blockquote {
10461046- padding-left: 0.75rem;
10471047- padding-right: 0.75rem;
10461046+ padding-inline-start: 0.75rem;
10471047+ padding-inline-end: 0.75rem;
10481048 }
10491049}
10501050+10511051+/* Leaflet document embeds */
10521052+.atproto-leaflet {
10531053+ max-width: none;
10541054+ width: 100%;
10551055+ margin: 1rem 0;
10561056+}
10571057+10581058+.leaflet-document {
10591059+ display: block;
10601060+}
10611061+10621062+.leaflet-text {
10631063+ margin: 0.5rem 0;
10641064+}
10651065+10661066+.leaflet-button {
10671067+ display: inline-block;
10681068+ padding: 0.5rem 1rem;
10691069+ background: var(--color-primary);
10701070+ color: var(--color-base);
10711071+ text-decoration: none;
10721072+ border-radius: 4px;
10731073+ margin: 0.5rem 0;
10741074+}
10751075+10761076+.leaflet-button:hover {
10771077+ opacity: 0.9;
10781078+}
10791079+10801080+/* Alignment utilities */
10811081+.align-center { text-align: center; }
10821082+.align-right { text-align: right; }
10831083+.align-justify { text-align: justify; }
10501084 </style>
10511085 <style>
10521086/* Syntax highlighting - Light Mode (default) */
···12361270<body style="background: var(--color-base); min-height: 100vh;">
12371271<div class="notebook-content">
12381272<h1>Weaver: Long-form Writing on AT Protocol</h1>
12391239-<p><em>Or: "Get in kid, we're rebuilding the blogosphere!"</em></p>
12401240-<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>
12731273+<em><p dir="ltr">Or: "Get in kid, we're rebuilding the blogosphere!"</em></p>
12741274+<p dir="ltr">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>
12411275<blockquote>
12421242-<p><img src="weaver_photo_med.jpg" alt="weaver_photo_med.jpg" /><em>The namesake of what I'm building</em></p>
12761276+<img src="weaver_photo_med.jpg" alt="weaver_photo_med.jpg" /><em><p dir="ltr">The namesake of what I'm building</em></p>
12431277</blockquote>
12441244-<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>
12781278+<p dir="ltr">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>
12451279<h2>The Blogosphere</h2>
12461246-<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>
12801280+<p dir="ltr">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>
12471281<aside>
12481282<blockquote>
12491249-<p><strong>On Platform Decay</strong></p>
12501250-<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>
12831283+<strong><p dir="ltr">On Platform Decay</strong>
12841284+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>
12511285</blockquote>
12521286</aside>
12531253-<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>
12541254-<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>
12871287+<p dir="ltr">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>
12881288+<p dir="ltr">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>
12551289<h2>The Pitch</h2>
12561256-<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>
12901290+<p dir="ltr">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>
12571291<aside>
12581292<blockquote>
12591259-<p><strong>The Ultimate Goal</strong></p>
12601260-<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>
12931293+<strong><p dir="ltr">The Ultimate Goal</strong></p>
12941294+<p dir="ltr">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>
12611295</blockquote>
12621296</aside>
12631297<h2>How It Works</h2>
12641264-<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>
12651265-<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>
12981298+<p dir="ltr">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>
12991299+<p dir="ltr">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>
12661300<h2>Why Rust?</h2>
12671267-<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>
13011301+<p dir="ltr">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>
12681302<aside>
12691303<blockquote>
12701270-<p><strong>On Interoperability</strong></p>
12711271-<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>
13041304+<strong><p dir="ltr">On Interoperability</strong></p>
13051305+<p dir="ltr">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>
12721306</blockquote>
12731307</aside>
12741308<h2>Evolution</h2>
12751275-<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>
12761276-<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>
13091309+<p dir="ltr">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>
13101310+<p dir="ltr">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>
12771311</div>
12781312</body>
12791313</html>