From cba9ceaee261cb112cf09e447019f3b100ceab22 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 21:58:53 -0700 Subject: [PATCH 01/10] Read a stock studio 0.40+ imported feed; stop syncing on a refused token GET /api/reading/imported on a stock blygger-studio 0.40+ is upstream's listImportedReading: camelCase rows, cursor-paged, 1..100 a page, no page flags. Burrow sent the old Worker patch's limit=500&before=, got a 400, and every sync failed. - wire: ReadingPage reads both shapes (camelCase rows => upstream; the old patch page carries read_state). upstream_row maps withdrawn, readVersion, stubOf, forkedFrom, transclusions; lineage is known for every non-L0, non-withdrawn row. - The walk's first page sends only limit=100 (both servers take it); later pages send cursor= (upstream) or before= (old patch, 500 a page). - Read sync on upstream comes from GET /api/reading's read_state and read_state_clear flags, read once a session; readVersion is kept when on. - A 401 the credential can't recover from (a revoked token, an ended sign-in, an owner token a stock blyg doesn't take) emits SignedOut once: the worker stops sending and pulling until a new sign-in or a manual sync, queued changes stay queued, and the app opens the sign-in sheet (browser first) saying why. - e2e: cursor paging with small pages (no repeats, same order), a stub's lineage from its feed row, a refused token stopping sync and keeping the change; the two-Mac read-sync test now also runs on a stock studio. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-app/src/app.rs | 35 +++- crates/blyg-core/src/api/mod.rs | 45 +++-- crates/blyg-core/src/api/wire.rs | 251 ++++++++++++++++++++++++++-- crates/blyg-core/src/model.rs | 5 + crates/blyg-core/src/sync/mod.rs | 100 ++++++++--- crates/blyg-core/src/sync/worker.rs | 12 ++ crates/blyg-core/tests/e2e.rs | 205 +++++++++++++++++++++-- 7 files changed, 594 insertions(+), 59 deletions(-) diff --git a/crates/blyg-app/src/app.rs b/crates/blyg-app/src/app.rs index 837fe59..c38f8f2 100644 --- a/crates/blyg-app/src/app.rs +++ b/crates/blyg-app/src/app.rs @@ -487,12 +487,7 @@ impl MainView { // An ended browser sign-in: ask for a new one (the // sheet says why) rather than retrying. (Err(CoreError::Unauthorized), _) if v.sheet.is_none() => { - v.show_toast( - "Your blyg signed Burrow out", - Some("Sign in again to sync".into()), - cx, - ); - v.open_connect(window, cx); + v.signed_out(window, cx); } (Err(e), _) => { v.show_toast(format!("Couldn't load from the blyg: {e}"), None, cx) @@ -1309,6 +1304,34 @@ impl MainView { CoreEvent::Error(msg) => self.show_toast(msg, None, cx), CoreEvent::ServerOutdated => self.server_outdated(cx), CoreEvent::ServerLimited => self.server_limited(window, cx), + CoreEvent::SignedOut => { + if self.sheet.is_none() { + self.signed_out(window, cx); + } + } + } + } + + /// The blyg refused the stored credential (401): a revoked or expired + /// token, an ended browser sign-in, or an owner token a blyg no longer + /// takes. Sync has stopped (no retries) and changes wait in the outbox; + /// ask for a new sign-in, browser first. + fn signed_out(&mut self, window: &mut Window, cx: &mut Context) { + self.show_toast( + "Your blyg signed Burrow out", + Some("Sign in again to sync; your changes wait on this Mac".into()), + cx, + ); + self.open_connect(window, cx); + if let Some(Sheet::Connect { error, .. }) = self.sheet.as_mut() + && error.is_none() + { + *error = Some( + "Your blyg no longer accepts Burrow's saved sign-in. Sign in with the \ + browser, or paste a new API token from Studio → Access, or use the \ + Studio password. Changes made meanwhile are kept and sync after." + .into(), + ); } } diff --git a/crates/blyg-core/src/api/mod.rs b/crates/blyg-core/src/api/mod.rs index bf3dfa2..092eada 100644 --- a/crates/blyg-core/src/api/mod.rs +++ b/crates/blyg-core/src/api/mod.rs @@ -799,32 +799,51 @@ impl Api { // ---------- reads (404 → None) ---------- - /// `GET /api/reading/imported` (extension 3): imported rows in the app's - /// shape, keyset-paged. Upstream's own `/api/reading` is a different, - /// rendered resource. `content_html` comes back raw, as stored: the UI - /// sanitizes it before display. - pub fn reading(&self, limit: u32, before: Option<&str>) -> Result> { + /// `GET /api/reading/imported`: imported rows in the app's shape, + /// keyset-paged. Upstream's `listImportedReading` (studio 0.40+) pages by + /// `cursor`, at most 100 a page; the old Worker patch 3 by `before`. + /// `upstream` is which the server turned out to be on the walk's first + /// page (`None` before then: that page sends only `limit`, which both + /// take, and `ReadingPage::upstream` says which answered). Upstream's + /// own `/api/reading` is a different, rendered resource. `content_html` + /// is kept as served: the UI sanitizes it before display. + pub fn reading( + &self, + limit: u32, + cursor: Option<&str>, + upstream: Option, + ) -> Result> { + let old = upstream == Some(false); + let limit = if old { + limit.max(1) + } else { + limit.clamp(1, UPSTREAM_READING_MAX) + }; let mut path = format!("/api/reading/imported?limit={limit}"); - if let Some(b) = before { - path.push_str("&before="); - path.push_str(&enc(b)); + if let Some(c) = cursor { + path.push_str(if old { "&before=" } else { "&cursor=" }); + path.push_str(&enc(c)); } let page: Option = Self::optional(self.call_as("GET", &path, None))?; Ok(page.map(|mut p| { - let read_sync = p.read_sync(); + // Upstream rows say per row; the patch says per page. let lineage = p.lineage == Some(true); for it in &mut p.items { it.page = it.page.take().and_then(|pg| absolute_page(&it.origin, &pg)); - it.lineage_known = lineage; - if !read_sync { - // Only a server that advertises read state means it. - it.read_version = None; + if !p.upstream { + it.lineage_known = lineage; } } p })) } + /// `GET /api/reading?limit=1`, for its read-state flags only (studio + /// 0.39+). `None` on 404. + pub fn reading_flags(&self) -> Result> { + Self::optional(self.call_as("GET", "/api/reading?limit=1", None)) + } + // ---------- reading on a stock server (no `/reading/imported`) ---------- /// Upstream's `GET /api/reading`, one page (at most 50). `None` on 404. diff --git a/crates/blyg-core/src/api/wire.rs b/crates/blyg-core/src/api/wire.rs index 2f6a0ca..f5472f4 100644 --- a/crates/blyg-core/src/api/wire.rs +++ b/crates/blyg-core/src/api/wire.rs @@ -317,26 +317,81 @@ pub struct Media { pub duplicate: bool, } -/// `GET /api/reading/imported` (patch 3) → `{items, next}`, plus `read_state: true` -/// from a server that stores read state (extension 5; each item then carries -/// its `read_version`), and `lineage: true` from one whose rows carry their -/// `stub_of` and `forked_from` (null when none). -#[derive(Debug, Clone, Deserialize)] +/// `GET /api/reading/imported`, one page, in either of two shapes: +/// +/// - upstream's `listImportedReading` (studio 0.40+): `{items, next, limit}`, +/// camelCase rows (`subscriptionId`, `readVersion`, `stubOf`, …), paged by +/// `cursor`, at most 100 a page. No page-level flags: read state is +/// advertised on `GET /api/reading`, and every row carries the origin's own +/// lineage (null when none). +/// - the maintainer's old Worker patch 3: `{items, next}` with snake_case rows, +/// paged by `before`, plus `read_state: true` from a server that stores read +/// state (each item then carries its `read_version`), `read_state_clear`, and +/// `lineage: true` from one whose rows carry `stub_of` and `forked_from`. +/// +/// `upstream` says which: a page without the patch's `read_state` flag, whose +/// rows are camelCase (or that echoes `limit`), is upstream's. +#[derive(Debug, Clone)] pub struct ReadingPage { pub items: Vec, - #[serde(default)] pub next: Option, - #[serde(default, deserialize_with = "crate::model::lenient")] pub read_state: Option, - #[serde(default, deserialize_with = "crate::model::lenient")] pub lineage: Option, /// Read state can be cleared (mark unread, `read_at` on reads). - #[serde(default, deserialize_with = "crate::model::lenient")] pub read_state_clear: Option, + /// Upstream's shape (see above). + pub upstream: bool, +} + +impl<'de> Deserialize<'de> for ReadingPage { + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + struct Raw { + items: Vec, + #[serde(default)] + next: Option, + #[serde(default)] + read_state: Option, + #[serde(default)] + read_state_clear: Option, + #[serde(default)] + lineage: Option, + #[serde(default)] + limit: Option, + } + let raw = Raw::deserialize(d)?; + let flag = |v: &Option| v.as_ref().and_then(Value::as_bool); + let camel = raw + .items + .first() + .is_some_and(|i| i.get("subscriptionId").is_some()); + let upstream = raw.read_state.is_none() && (camel || raw.limit.is_some()); + let mut items = Vec::with_capacity(raw.items.len()); + for row in raw.items { + let it = if row.get("subscriptionId").is_some() { + upstream_row(&row) + } else { + serde_json::from_value(row).map_err(|e| e.to_string()) + }; + items.push(it.map_err(serde::de::Error::custom)?); + } + Ok(ReadingPage { + items, + next: raw + .next + .as_ref() + .and_then(Value::as_str) + .map(str::to_string), + read_state: flag(&raw.read_state), + lineage: flag(&raw.lineage), + read_state_clear: flag(&raw.read_state_clear), + upstream, + }) + } } impl ReadingPage { - /// The server advertises read-state sync (extension 5). + /// The server advertises read-state sync (the patch's page flag). pub fn read_sync(&self) -> bool { self.read_state == Some(true) } @@ -347,6 +402,97 @@ impl ReadingPage { } } +/// Upstream's `listImportedReading` serves at most this many rows a page. +pub const UPSTREAM_READING_MAX: u32 = 100; + +/// One upstream `ImportedReadingEntry` (camelCase) in the app's shape. +/// Tolerant: only `subscriptionId` and `remoteId` are required; anything +/// else odd reads as absent. `withdrawn` is the old `state: "tombstone"`. +/// The row's lineage is the origin's own (null when none), so it is known +/// for every row but an L0 one or a withdrawn one. +pub fn upstream_row(row: &Value) -> Result { + let o = row + .as_object() + .ok_or("a reading row that isn't an object")?; + let s = |k: &str| o.get(k).and_then(Value::as_str).map(str::to_string); + let b = |k: &str| o.get(k).and_then(Value::as_bool).unwrap_or(false); + let n = |k: &str| { + o.get(k) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + }; + let sub = s("subscriptionId").ok_or("a reading row without subscriptionId")?; + let rid = s("remoteId").ok_or("a reading row without remoteId")?; + let withdrawn = b("withdrawn"); + let l0 = b("l0"); + let kind = if s("kind").as_deref() == Some("thread") { + "thread" + } else { + "fragment" + }; + let thumb = o + .get("thumb") + .and_then(Value::as_i64) + .filter(|t| *t == 1 || *t == -1); + let hoppers: Vec = o + .get("hoppers") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|h| h.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let obj = |k: &str| o.get(k).filter(|v| v.is_object()).cloned(); + let v = serde_json::json!({ + "subscription_id": sub, + "remote_id": rid, + "subscription_title": s("subscriptionTitle").unwrap_or_default(), + "origin": s("origin").unwrap_or_default(), + "kind": kind, + "state": if withdrawn { "tombstone" } else { "current" }, + "version": n("version").unwrap_or(0), + "created": s("created"), + "updated": s("updated"), + "observed_at": s("observedAt").unwrap_or_default(), + "content_md": s("contentMd").unwrap_or_default(), + "content_html": s("contentHtml").unwrap_or_default(), + "author": obj("author"), + "page": s("page"), + "thumb": thumb, + "hoppers": hoppers, + "pinned_version_retained": n("pinnedVersionRetained"), + "read_version": n("readVersion"), + "stub_of": obj("stubOf"), + "forked_from": obj("forkedFrom"), + "transclusions": o.get("transclusions").filter(|v| v.is_array()), + "lineage_known": !l0 && !withdrawn, + "source_url": s("sourceUrl"), + "generated": o.get("generated").filter(|v| v.is_array()), + }); + serde_json::from_value(v).map_err(|e| e.to_string()) +} + +/// `GET /api/reading`'s read-state flags (studio 0.39+): the server stores +/// read state, and can clear it. Only the flags are read. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ReadingFlags { + #[serde(default, deserialize_with = "crate::model::lenient")] + pub read_state: Option, + #[serde(default, deserialize_with = "crate::model::lenient")] + pub read_state_clear: Option, +} + +impl ReadingFlags { + pub fn read_sync(&self) -> bool { + self.read_state == Some(true) + } + + pub fn read_clear(&self) -> bool { + self.read_sync() && self.read_state_clear == Some(true) + } +} + /// One row's read state as sent to the server: `(sub, remote_id)` is a /// reading row, `version` the highest version read. `read_at` is when it /// was read on this Mac (ISO-8601, UTC). It is sent only to a server that @@ -631,3 +777,88 @@ mod stock_row_tests { ); } } + +#[cfg(test)] +mod reading_page_tests { + use super::*; + + /// A page as blygger-studio 0.46.1's `listImportedReading` serves it + /// (src/imported-reading.ts `entry`), with invented content. + const UPSTREAM: &str = r#"{"items":[ + {"subscriptionId":"S1","subscriptionTitle":"Ada","origin":"https://ada.example.com/", + "remoteId":"R2","kind":"thread","withdrawn":false,"l0":false,"version":3,"readVersion":2, + "created":"2030-01-01T00:00:00Z","updated":"2030-01-02T00:00:00Z","observedAt":"2030-01-02T00:00:05Z", + "contentMd":"Re: ![[X]]","contentHtml":"

Re

","author":{"name":"Ada","url":"https://ada.example.com/"}, + "page":"t/R2/","sourceUrl":"https://ada.example.com/t/R2/","pinnedVersionRetained":null,"thumb":1, + "hoppers":["H1"],"transclusions":[{"id":"X","version":2,"origin":"https://bo.example.com/", + "cited":{"author":"Bo","source":"Bo's blyg"}}], + "stubOf":{"origin":"https://bo.example.com/","id":"X","version":2,"cited":{"author":"Bo"}}, + "forkedFrom":null,"generated":[{"sources":[],"model":"m","at":"2030-01-01T00:00:00Z"}]}, + {"subscriptionId":"S1","subscriptionTitle":"Ada","origin":"https://ada.example.com/", + "remoteId":"R1","kind":"fragment","withdrawn":true,"l0":false,"version":2,"readVersion":null, + "created":null,"updated":null,"observedAt":"2030-01-01T00:00:05Z","contentMd":"","contentHtml":"", + "author":null,"page":null,"sourceUrl":null,"pinnedVersionRetained":1,"thumb":null,"hoppers":[], + "transclusions":null,"stubOf":null,"forkedFrom":null,"generated":null} + ],"next":"WyIyMDMwIiwiUzEiLCJSMSJd","limit":2}"#; + + #[test] + fn an_upstream_page_reads_in_the_apps_shape() { + let p: ReadingPage = serde_json::from_str(UPSTREAM).unwrap(); + assert!(p.upstream); + assert!(!p.read_sync(), "upstream says so on GET /api/reading"); + assert_eq!(p.next.as_deref(), Some("WyIyMDMwIiwiUzEiLCJSMSJd")); + let a = &p.items[0]; + assert_eq!( + (a.subscription_id.as_str(), a.remote_id.as_str()), + ("S1", "R2") + ); + assert_eq!(a.kind, Kind::Thread); + assert_eq!(a.state, "current"); + assert_eq!(a.read_version, Some(2)); + assert_eq!(a.thumb, Some(1)); + assert_eq!(a.hoppers, vec!["H1".to_string()]); + assert_eq!(a.author.as_ref().unwrap().name.as_deref(), Some("Ada")); + assert_eq!(a.stub_of.as_ref().unwrap().id.as_deref(), Some("X")); + assert_eq!(a.transclusions.len(), 1); + assert!(a.lineage_known); + let w = &p.items[1]; + assert_eq!(w.state, "tombstone"); + assert_eq!(w.pinned_version_retained, Some(1)); + assert_eq!(w.read_version, None); + assert!(!w.lineage_known, "a withdrawn row says nothing of lineage"); + } + + #[test] + fn an_empty_upstream_page_is_upstream() { + let p: ReadingPage = + serde_json::from_str(r#"{"items":[],"next":null,"limit":100}"#).unwrap(); + assert!(p.upstream && p.items.is_empty() && p.next.is_none()); + } + + #[test] + fn the_old_patch_page_still_reads() { + let p: ReadingPage = serde_json::from_str( + r#"{"items":[{"subscription_id":"S","remote_id":"R","subscription_title":"N", + "origin":"https://ada.example.com/","kind":"fragment","state":"current","version":1, + "created":null,"updated":null,"observed_at":"2030-01-01T00:00:00Z","content_md":"x", + "content_html":"

x

","author":null,"page":null,"thumb":null,"hoppers":[], + "read_version":1,"stub_of":null,"forked_from":null}], + "next":null,"read_state":true,"read_state_clear":true,"lineage":true}"#, + ) + .unwrap(); + assert!(!p.upstream); + assert!(p.read_sync() && p.read_clear()); + assert_eq!(p.items[0].read_version, Some(1)); + let empty: ReadingPage = + serde_json::from_str(r#"{"items":[],"next":null,"read_state":false}"#).unwrap(); + assert!(!empty.upstream); + } + + #[test] + fn a_row_without_its_identity_fails_the_page() { + let r = serde_json::from_str::( + r#"{"items":[{"subscriptionId":"S"}],"next":null,"limit":1}"#, + ); + assert!(r.is_err()); + } +} diff --git a/crates/blyg-core/src/model.rs b/crates/blyg-core/src/model.rs index 899a4f8..9900b1f 100644 --- a/crates/blyg-core/src/model.rs +++ b/crates/blyg-core/src/model.rs @@ -1093,6 +1093,11 @@ pub enum CoreEvent { /// extensions in docs/SERVER.md (sent once per local database). See /// `Backend::server_extensions`. ServerLimited, + /// The blyg refused the stored credential with a 401 (a revoked or + /// expired token, an ended sign-in, or an owner token the server no + /// longer takes). Sent once per session; nothing syncs on its own until + /// a new sign-in (queued changes stay queued). The UI asks to sign in. + SignedOut, } #[cfg(test)] diff --git a/crates/blyg-core/src/sync/mod.rs b/crates/blyg-core/src/sync/mod.rs index d114a54..2574e8d 100644 --- a/crates/blyg-core/src/sync/mod.rs +++ b/crates/blyg-core/src/sync/mod.rs @@ -167,6 +167,14 @@ struct NetState { /// session (a new sign-in is a new session), and the rest of the outbox /// goes on without them. read_denied: bool, + /// `GET /api/reading/imported` answered in upstream's shape (studio + /// 0.40+): `Some(true)`; the old Worker patch 3's: `Some(false)`. + upstream_feed: Option, + /// Upstream's read-state flags (`GET /api/reading`), once read. + read_flags: Option, + /// The blyg refused the stored credential (401): nothing is sent or + /// pulled on its own until a new sign-in, or a manual sync. + signed_out: bool, } /// What to say, once a session, when the blyg refuses a read-state write. @@ -242,6 +250,9 @@ impl Engine { pushed: HashMap::new(), paced_until: None, read_denied: false, + upstream_feed: None, + read_flags: None, + signed_out: false, }), opts, scratch_dir: None, @@ -361,6 +372,7 @@ impl Engine { st.retry_at = None; st.rate_limited = false; st.scope_blocked = false; + st.signed_out = false; } /// Record a failed network call. Transient failures and auth failures @@ -397,24 +409,29 @@ impl Engine { e if is_transient(e) => Health::Error, _ => return, }; - // Windows: say why, once, when the blyg starts refusing the token; - // the status bar alone only says "sync error". - let newly_refused = cfg!(target_os = "windows") - && matches!(e, CoreError::Unauthorized) - && st.health != Health::Error; st.health = health; st.retry_at = Some(Instant::now() + st.backoff); st.backoff = (st.backoff * 2).min(self.opts.backoff_max); + // A 401 the credential can't recover from (`Api::exec` already + // renewed a browser sign-in, or signed in again with the password): + // the stored token is revoked, expired, or one the server no longer + // takes (an owner token on a blyg that went stock). No retry loop: + // the worker stops sending and pulling until a new sign-in (a new + // session) or a manual sync, queued changes stay queued, and the UI + // is told once, to ask for a sign-in. + let newly_signed_out = + matches!(e, CoreError::Unauthorized) && !std::mem::replace(&mut st.signed_out, true); drop(st); - if newly_refused { - self.emit(CoreEvent::Error( - "Your blyg refused the owner token (401). If the token changed, use \ - Disconnect… from the Menu and connect again with the new one." - .into(), - )); + if newly_signed_out { + self.emit(CoreEvent::SignedOut); } } + /// The blyg refused the stored credential (401) this session. + pub fn signed_out(&self) -> bool { + self.state().signed_out + } + pub fn retry_at(&self) -> Option { self.state().retry_at } @@ -1053,7 +1070,10 @@ impl Engine { // don't watch: with it on, the reading list is always read. // Upstream's own read state (studio's read-state routes, on a stock // server) advances the `reading` revision, so it needs no exception. - let ext5_reads = self.read_sync_on() && self.state().stock_reading != Some(true); + let ext5_reads = { + let st = self.state(); + self.read_sync_on() && st.stock_reading == Some(false) && st.upstream_feed != Some(true) + }; if stale("reading", READING_DEPS) || ext5_reads { self.state().stock_deferred = false; let fetched = self.fetch_reading()?; @@ -1406,12 +1426,25 @@ impl Engine { /// `None` when the server doesn't have `/api/reading` yet. The flag is /// true when the last page had `next: null` (the list is complete). + /// + /// `GET /api/reading/imported` is upstream's `listImportedReading` on a + /// studio 0.40+ (cursor-paged, 100 a page) or the old Worker patch 3 + /// (`before`-paged, 500 a page); the first page tells which. Upstream + /// pages carry no read-state flags: those come from `GET /api/reading`. fn fetch_reading(&self) -> Result, bool)>> { let mut all = Vec::new(); let mut cursor: Option = None; - for i in 0..self.opts.reading_pages.max(1) { - match self.api.reading(500, cursor.as_deref())? { - // No fork extension: upstream's own reading routes. + let mut upstream: Option = None; + let mut pages = self.opts.reading_pages.max(1); + let mut i = 0; + while i < pages { + let limit = if upstream == Some(false) { + 500 + } else { + crate::api::wire::UPSTREAM_READING_MAX + }; + match self.api.reading(limit, cursor.as_deref(), upstream)? { + // No such route: upstream's older reading routes. None if i == 0 => return self.fetch_reading_stock(), None => { self.state().reading_unavailable = true; @@ -1423,23 +1456,52 @@ impl Engine { let mut st = self.state(); st.reading_unavailable = false; st.stock_reading = Some(false); + st.upstream_feed = Some(page.upstream); } if i == 0 { - self.set_read_sync(page.read_sync())?; - self.set_read_clear(page.read_clear())?; + upstream = Some(page.upstream); + if page.upstream { + // A fifth of the patch's page: as many rows. + pages = pages.saturating_mul(5); + let flags = self.upstream_read_flags()?; + self.set_read_sync(flags.read_sync())?; + self.set_read_clear(flags.read_clear())?; + } else { + self.set_read_sync(page.read_sync())?; + self.set_read_clear(page.read_clear())?; + } } - all.extend(page.items); + // Only a server that advertises read state means it. + let keep = self.read_sync_on(); + all.extend(page.items.into_iter().map(|mut it| { + if !keep { + it.read_version = None; + } + it + })); match page.next { - // Opaque cursor: passed back verbatim as `before`. + // Opaque cursor: passed back verbatim. Some(n) => cursor = Some(n), None => return Ok(Some((all, true))), } } } + i += 1; } Ok(Some((all, false))) } + /// Upstream's read-state flags (`GET /api/reading`), read once a session. + /// A server without the route keeps no read state. + fn upstream_read_flags(&self) -> Result { + if let Some(f) = self.state().read_flags.clone() { + return Ok(f); + } + let f = self.api.reading_flags()?.unwrap_or_default(); + self.state().read_flags = Some(f.clone()); + Ok(f) + } + /// The reading list from a stock server: upstream's `GET /api/reading` /// says which posts there are, and each new or changed one is read whole /// from `GET /api/imports/{sub}/{id}`. Thumbs and hopper memberships come diff --git a/crates/blyg-core/src/sync/worker.rs b/crates/blyg-core/src/sync/worker.rs index ed527da..1c845f5 100644 --- a/crates/blyg-core/src/sync/worker.rs +++ b/crates/blyg-core/src/sync/worker.rs @@ -31,6 +31,18 @@ pub(crate) fn spawn(engine: Arc, rx: Receiver) -> JoinHandle<()> { fn run(engine: Arc, rx: Receiver) { let mut next_pull = Instant::now(); loop { + // The blyg refused the credential (401): no retry loop. Sleep until + // told something; a manual sync (`Engine::sync_now`) that gets + // through, or a new sign-in (a new engine), ends it. + if engine.signed_out() { + match rx.recv() { + Ok(Msg::Shutdown) | Err(_) => return, + Ok(_) => { + next_pull = Instant::now(); + continue; + } + } + } let now = Instant::now(); let mut deadline = next_pull; if let Some(due) = engine.store.earliest_due() { diff --git a/crates/blyg-core/tests/e2e.rs b/crates/blyg-core/tests/e2e.rs index 992a3bb..d8d3464 100644 --- a/crates/blyg-core/tests/e2e.rs +++ b/crates/blyg-core/tests/e2e.rs @@ -1639,15 +1639,16 @@ fn reading_items_carry_the_published_html() { #[test] #[ignore = "needs a local Worker: scripts/e2e-local.sh"] fn read_state_syncs_between_two_macs() { - if !needs_extensions("read-state sync") { - return; - } - // Extension 5: a post read on one Mac reads as read on another Mac - // signed in to the same blyg, and the server never lowers it. + // Read state (studio 0.39+, or the old extension 5): a post read on one + // Mac reads as read on another Mac signed in to the same blyg, and the + // server never lowers it. let e = e2e(); let api = Api::new(&e.url, e.cred.clone()); - if !api.reading(1, None).unwrap().is_some_and(|p| p.read_sync()) { - eprintln!("SKIP: this Worker doesn't advertise read_state (extension 5)"); + let advertised = api.reading(1, None, None).unwrap().is_some_and(|p| { + p.read_sync() || (p.upstream && api.reading_flags().unwrap().is_some_and(|f| f.read_sync())) + }); + if !advertised { + eprintln!("SKIP: this server doesn't advertise read_state"); return; } let src_url = url_b(); @@ -1662,8 +1663,8 @@ fn read_state_syncs_between_two_macs() { let t = tag("read"); let (sid, rid) = published(&source, Kind::Fragment, &format!("{t} v1"), None); let sub = mac1.subscribe(&src_url, Some("Read sync")).unwrap(); - mac1.sync_now().unwrap(); - mac2.sync_now().unwrap(); + sync_until_held(&mac1, &[&rid]); + sync_until_held(&mac2, &[&rid]); assert!(reading_item(&mac1, &rid).unwrap().is_unread()); assert!(reading_item(&mac2, &rid).unwrap().is_unread()); @@ -1683,7 +1684,7 @@ fn read_state_syncs_between_two_macs() { api.put_read(&sub.id, &rid, 1, None).unwrap(); mac1.sync_now().unwrap(); assert_eq!(reading_item(&mac1, &rid).unwrap().read_version, Some(2)); - let page = api.reading(500, None).unwrap().unwrap(); + let page = api.reading(100, None, None).unwrap().unwrap(); let row = page.items.iter().find(|i| i.remote_id == rid).unwrap(); assert_eq!(row.read_version, Some(2), "the server kept the max"); @@ -1694,7 +1695,7 @@ fn read_state_syncs_between_two_macs() { assert_eq!(reading_item(&mac3, &rid).unwrap().read_version, Some(2)); mac1.unsubscribe(&sub.id).unwrap(); - let page = api.reading(500, None).unwrap().unwrap(); + let page = api.reading(100, None, None).unwrap().unwrap(); assert!(!page.items.iter().any(|i| i.remote_id == rid)); } @@ -3388,3 +3389,185 @@ fn opml_import_and_export() { } } } + +// ------------------------------- a stock studio's imported feed (0.40+) + +/// Every row of `GET /api/reading/imported`, walked `limit` at a time with +/// the server's own cursor: (sub, remote id) in the order served. +fn walk_feed(api: &Api, limit: u32) -> (bool, Vec<(String, String)>) { + let mut out = Vec::new(); + let mut cursor: Option = None; + let mut upstream = None; + loop { + let page = api + .reading(limit, cursor.as_deref(), upstream) + .unwrap() + .expect("the server has /api/reading/imported"); + upstream.get_or_insert(page.upstream); + out.extend( + page.items + .iter() + .map(|i| (i.subscription_id.clone(), i.remote_id.clone())), + ); + match page.next { + Some(n) => cursor = Some(n), + None => return (upstream.unwrap_or(false), out), + } + } +} + +#[test] +#[ignore = "needs a local Worker: scripts/e2e-local.sh"] +fn the_imported_feed_pages_by_cursor_without_repeats() { + let e = e2e(); + let src_url = url_b(); + let (d1, d2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let me = manual(d1.path()); + let source = backend_at(d2.path(), &src_url, cred_b(), false); + let t = tag("paging"); + let rids: Vec = (0..5) + .map(|i| published(&source, Kind::Fragment, &format!("{t} {i}"), None).1) + .collect(); + let sub = me.subscribe(&src_url, Some("Paged")).unwrap(); + let _unsub = Unsub(&me, sub.id.clone()); + let want: Vec<&str> = rids.iter().map(String::as_str).collect(); + sync_until_held(&me, &want); + + let api = Api::new(&e.url, e.cred.clone()); + let (upstream, whole) = walk_feed(&api, 100); + if e.stock { + assert!( + upstream, + "a stock studio serves upstream's listImportedReading" + ); + } + // One poll stamps one observed_at on the whole backfill: the cursor + // must resume inside that tie. + let (_, small) = walk_feed(&api, 2); + let unique: std::collections::HashSet<_> = small.iter().collect(); + assert_eq!(unique.len(), small.len(), "no repeats across pages"); + assert_eq!(small, whole, "the same rows, in the same order"); + for rid in &rids { + assert!(small.iter().any(|(s, r)| *s == sub.id && r == rid), "{rid}"); + } + if upstream { + // Upstream's bounds: 1..=100, else 400 (never silently clamped). + let (st, _) = owner("GET", &e.url, "/api/reading/imported?limit=101", None); + assert_eq!(st, 400); + } + // The app holds every row, read the same way. + for rid in &rids { + let r = reading_item(&me, rid).unwrap(); + assert_eq!(r.subscription_id, sub.id); + assert_eq!(r.state, "current"); + assert!(r.content_html.contains(&t), "{}", r.content_html); + } +} + +#[test] +#[ignore = "needs a local Worker: scripts/e2e-local.sh"] +fn a_stubs_lineage_comes_with_its_feed_row() { + // `b` replies (a stub) to a post on `a`; `a` subscribes to `b` and holds + // the reply with its stub_of from the feed row itself. + let e = e2e(); + let src_url = url_b(); + let (d1, d2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let me = manual(d1.path()); + let other = backend_at(d2.path(), &src_url, cred_b(), false); + let t = tag("lineage"); + let (_, mine) = published(&me, Kind::Fragment, &format!("{t} the original"), None); + let back = other.subscribe(&e.url, Some("Back")).unwrap(); + let _unsub_back = Unsub(&other, back.id.clone()); + sync_until_held(&other, &[&mine]); + let held = reading_item(&other, &mine).unwrap(); + let of = RemoteRef { + origin: held.origin.clone(), + id: mine.clone(), + version: held.version, + }; + let stub = other + .create_stub(&of, &format!("![[{mine}]]\n\n{t} a reply.")) + .unwrap(); + other.sync_now().unwrap(); + other.publish(&stub, None).expect("publish the stub"); + let reply = sid_of(&other, &stub); + + let sub = me.subscribe(&src_url, Some("Replies")).unwrap(); + let _unsub = Unsub(&me, sub.id.clone()); + sync_until_held(&me, &[&reply]); + let r = reading_item(&me, &reply).unwrap(); + let s = r.stub_of.as_ref().expect("the reply's stub_of"); + assert_eq!(s.id.as_deref(), Some(mine.as_str())); + assert_eq!( + s.origin.as_deref().map(|o| o.trim_end_matches('/')), + Some(e.url.as_str()) + ); + assert!(r.lineage_known, "lineage came with the row"); + assert_eq!(r.kind, Kind::Thread); +} + +#[test] +#[ignore = "needs a local Worker: scripts/e2e-local.sh"] +fn a_refused_token_stops_sync_and_keeps_the_change() { + // An owner token the blyg no longer takes (e.g. the old Worker's + // BLYG_OWNER_TOKEN once the blyg runs stock) is a 401 on everything: + // Burrow says so once (SignedOut), stops sending (no retry loop), and + // keeps the change queued for the next sign-in. + let e = e2e(); + let proxy = Proxy::start(&e.url); + let dir = tempfile::tempdir().unwrap(); + let bad = "blyg_not-a-token-this-blyg-issued"; + let b = backend_at( + dir.path(), + &proxy.url, + Credential::Token(bad.to_string()), + true, + ); + let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + { + let ev = events.clone(); + b.set_event_sink(Box::new(move |e| ev.lock().unwrap().push(e))); + } + let t = tag("refused"); + let id = b.create_draft(Kind::Fragment, &t).unwrap(); + let signed_out = || { + events + .lock() + .unwrap() + .iter() + .filter(|e| matches!(e, CoreEvent::SignedOut)) + .count() + }; + wait_until("the sign-out notice", Duration::from_secs(20), || { + signed_out() > 0 + }); + let _ = proxy.take(); + // The worker's backoff here is 200–800 ms: a retry loop would show. + std::thread::sleep(Duration::from_secs(3)); + assert_eq!( + proxy.take(), + Vec::::new(), + "nothing sent after the 401" + ); + assert_eq!(signed_out(), 1, "said once"); + let item = b.item(&id).unwrap(); + assert!(item.pending_sync, "the change stays queued"); + assert!( + !events + .lock() + .unwrap() + .iter() + .any(|e| format!("{e:?}").contains(bad)), + "the token is never echoed" + ); + drop(b); + + // Signed in again (a new session over the same data): the change lands. + let again = backend_at(dir.path(), &e.url, e.cred.clone(), false); + again.sync_now().unwrap(); + let item = again.item(&id).unwrap(); + assert!(!item.pending_sync, "sent after the new sign-in"); + assert_eq!(server_item(&sid_of(&again, &id))["content_md"], t.as_str()); + again.delete_draft(&id).ok(); + again.sync_now().ok(); +} From 7e1b5b5d43551aef2480ddd65fb24d4aa8d00fd2 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:08:17 -0700 Subject: [PATCH 02/10] blyg-render: re-sync to stock studio 0.46.1; #60 directives in TK output Parity fixtures now come from stock blygger-studio v0.46.1 (was the patched Worker at 0.36.1). Embeds are presentation only upstream since 0.40, so the YouTube facade and image referrerpolicy are no longer baked into the HTML; the preview script still shows the facade on screen. parse_scopes looks for [TK], [=] and [/TK] outside code everywhere in a scope (0.41.1, #68). Decision #60: in a thread, an own-line ![[id]] left in a block scope's output previews as the quote publish bakes, with the prose around it still disclosed as generated. New unrequested_output_directives() (tk.ts unrequestedOutputDirectives) names directives the instruction did not. Parity: 215/219 byte-identical; the 4 known divergences are the #60 block-scope cases, pinned in tests/preview.rs. gen_parity.mjs runs Miniflare from a temp dir so no .wrangler/ lands in the tree. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-render/src/embeds.rs | 19 ++- crates/blyg-render/src/lib.rs | 4 +- crates/blyg-render/src/markdown.rs | 102 ++++++------ crates/blyg-render/src/tk.rs | 150 ++++++++++++++++-- crates/blyg-render/src/transclusion.rs | 6 + crates/blyg-render/tests/fixtures/corpus.json | 9 +- .../blyg-render/tests/fixtures/gen_parity.mjs | 11 +- .../tests/fixtures/parity/_manifest.json | 4 +- .../tests/fixtures/parity/image_in_link.json | 2 +- .../tests/fixtures/parity/images_edge.json | 2 +- .../tests/fixtures/parity/images_remote.json | 2 +- .../tests/fixtures/parity/mixed_document.json | 2 +- .../parity/tk_block_with_image_and_yt.json | 2 +- .../parity/tk_code_close_in_scope.json | 19 +++ .../fixtures/parity/tk_code_eq_in_scope.json | 19 +++ .../parity/tk_code_fence_in_scope.json | 19 +++ .../parity/tk_code_open_in_scope.json | 19 +++ .../tr_block_tk_directive_attached.json | 19 +++ .../parity/tr_block_tk_directive_in_code.json | 17 ++ .../parity/tr_block_tk_directive_split.json | 19 +++ .../fixtures/parity/tr_sanitize_markdown.json | 2 +- .../tests/fixtures/parity/yt_angle.json | 2 +- .../tests/fixtures/parity/yt_bare.json | 2 +- .../fixtures/parity/yt_bare_md_link.json | 2 +- .../tests/fixtures/parity/yt_bare_short.json | 2 +- .../fixtures/parity/yt_in_blockquote.json | 2 +- .../fixtures/parity/yt_in_loose_list.json | 2 +- .../tests/fixtures/parity/yt_live.json | 2 +- .../parity/yt_music_and_short_id.json | 2 +- .../tests/fixtures/parity/yt_params.json | 2 +- crates/blyg-render/tests/parity.rs | 26 ++- crates/blyg-render/tests/preview.rs | 84 +++++++++- docs/RENDER.md | 98 +++++++++--- 33 files changed, 557 insertions(+), 117 deletions(-) create mode 100644 crates/blyg-render/tests/fixtures/parity/tk_code_close_in_scope.json create mode 100644 crates/blyg-render/tests/fixtures/parity/tk_code_eq_in_scope.json create mode 100644 crates/blyg-render/tests/fixtures/parity/tk_code_fence_in_scope.json create mode 100644 crates/blyg-render/tests/fixtures/parity/tk_code_open_in_scope.json create mode 100644 crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_attached.json create mode 100644 crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_in_code.json create mode 100644 crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_split.json diff --git a/crates/blyg-render/src/embeds.rs b/crates/blyg-render/src/embeds.rs index 9b55d22..a7e75fd 100644 --- a/crates/blyg-render/src/embeds.rs +++ b/crates/blyg-render/src/embeds.rs @@ -1,10 +1,13 @@ -//! The embeds extension (the Worker's `embeds.ts`, "local patch 6"): the -//! YouTube click-to-load facade and the off-origin image fallback. +//! Embeds (the studio's `embeds.ts`, studio 0.40): the YouTube click-to-load +//! facade and the off-origin image fallback. //! -//! The baked facade needs no script: it is a poster image linked to the video -//! plus a caption holding the original link. The preview page's own script -//! ([`preview_script`]) swaps the poster for a `youtube-nocookie.com` iframe on -//! click and replaces a failed off-origin image with a visible link. +//! Presentation only, as in the studio: nothing is baked into the HTML. The +//! preview page's own script ([`preview_script`]) turns a bare video link +//! into the facade (a poster image linked to the video plus a caption +//! holding the original link), swaps the poster for a +//! `youtube-nocookie.com` iframe on click, and replaces a failed off-origin +//! image with a visible link. [`youtube_facade_html`] is the same markup, +//! for hosts that show someone else's HTML without the script. use regex::Regex; use std::sync::OnceLock; @@ -120,8 +123,8 @@ const PREVIEW_SCRIPT: &str = r#" return fig; } - // Quoted snapshots baked before the facade existed hold a bare

url

; - // the public page upgrades them in the browser, so the preview does too. + // A bare

url

to a video becomes the facade on screen, as + // the public page's script does (studio 0.40: never baked into the HTML). function upgrade(root) { if (!root.querySelectorAll) return; var ps = root.querySelectorAll("article p"); diff --git a/crates/blyg-render/src/lib.rs b/crates/blyg-render/src/lib.rs index 6b1e3b4..6226cef 100644 --- a/crates/blyg-render/src/lib.rs +++ b/crates/blyg-render/src/lib.rs @@ -39,7 +39,7 @@ pub use shell::{ Attachment, Citation, ShellOpts, article_html, csp, fork_lineage_html, media_html, page_shell, page_shell_with, preview_media, stub_citation_html, studio_css, }; -pub use tk::{TkError, TkScope, parse_scopes}; +pub use tk::{TkError, TkScope, parse_scopes, unrequested_output_directives}; pub use transclusion::{ Found, NoResolver, Quote, Resolution, Resolver, SELECTOR_CONTEXT, TextQuoteSelector, Unresolved, UnresolvedReason, locate_selection, selection_from_quote, @@ -155,7 +155,7 @@ pub fn render_preview( let (scopes, tk_errors) = tk::parse_scopes(md); let src = linemap::Mapped::identity(md); let (stripped, spans) = tk::preview_strip(&src, &scopes); - let annotated = tk::annotate(&stripped, &spans); + let annotated = tk::annotate(&stripped, &spans, kind == Kind::Thread); let mut stats = Stats { ai_spans: scopes.iter().filter(|s| s.output.is_some()).count(), diff --git a/crates/blyg-render/src/markdown.rs b/crates/blyg-render/src/markdown.rs index d2178fa..f7d0f1d 100644 --- a/crates/blyg-render/src/markdown.rs +++ b/crates/blyg-render/src/markdown.rs @@ -1,6 +1,8 @@ //! Markdown → HTML with the reference Worker's markdown-it semantics: -//! `new MarkdownIt({ html: false, linkify: true, typographer: false })` plus -//! its `embedsPlugin` (YouTube facade, `referrerpolicy` on off-origin images). +//! `new MarkdownIt({ html: false, linkify: true, typographer: false })`. +//! Since studio 0.40 embeds are presentation only (embeds.ts): nothing is +//! baked into the HTML, and a page script upgrades a bare YouTube link on +//! screen ([`crate::preview_script`] here). //! //! Built on `markdown-it` (the Rust port of markdown-it.js), with these pieces //! replaced to match the JS library exactly: @@ -98,15 +100,10 @@ pub(crate) fn render(src: &str, line_of: Option<&dyn Fn(usize) -> usize>) -> (St let mut root = engine().parse(src); let mut stats = MdStats::default(); linkify_core(&mut root); - youtube_facades(&mut root); + stats.videos = count_videos(&root); rewrite_images(&mut root, &mut stats); rewrite_code(&mut root, src); rewrite_containers(&mut root); - root.walk(|n, _| { - if n.is::() { - stats.videos += 1; - } - }); if let Some(line_of) = line_of { let starts = line_starts(src); for node in root.children.iter_mut() { @@ -523,9 +520,10 @@ fn split_links( } // --------------------------------------------------------------------------- -// Embeds (the Worker's embeds.ts `embedsPlugin`) +// Embeds (the studio's embeds.ts rules, applied on screen, never baked) -/// The YouTube click-to-load facade, replacing a paragraph. +/// The YouTube click-to-load facade, replacing a paragraph in the tree +/// `native.rs` reads. The HTML render leaves the paragraph as it is. #[derive(Debug)] pub struct YtFacade { pub id: String, @@ -575,56 +573,69 @@ fn bare_link(node: &Node) -> Option<(&str, String)> { .map(|t| (t.content.as_str(), url)) } +/// The video a paragraph shows on screen: its only content is one link whose +/// text is its own URL, pointing at a YouTube video (embeds.ts). +fn facade_of(para: &Node) -> Option { + if !para.is::() { + return None; + } + let kids: Vec<&Node> = para + .children + .iter() + .filter(|k| { + !k.cast::() + .is_some_and(|t| js_trim(&t.content).is_empty()) + }) + .collect(); + if kids.len() != 1 { + return None; + } + let (text, href) = bare_link(kids[0])?; + if js_trim(text) != js_trim(&href) { + return None; + } + let id = youtube_id(&href)?; + Some(YtFacade { id, href }) +} + fn youtube_facades(node: &mut Node) { for child in node.children.iter_mut() { - if child.is::() { - let kids: Vec<&Node> = child - .children - .iter() - .filter(|k| { - !k.cast::() - .is_some_and(|t| js_trim(&t.content).is_empty()) - }) - .collect(); - if kids.len() == 1 - && let Some((text, href)) = bare_link(kids[0]) - && js_trim(text) == js_trim(&href) - && let Some(id) = youtube_id(&href) - { - let srcmap = child.srcmap; - *child = Node::new(YtFacade { id, href }); - child.srcmap = srcmap; - } - } else { + if let Some(facade) = facade_of(child) { + let srcmap = child.srcmap; + *child = Node::new(facade); + child.srcmap = srcmap; + } else if !child.is::() { youtube_facades(child); } } } +fn count_videos(node: &Node) -> usize { + node.children + .iter() + .map(|child| { + if facade_of(child).is_some() { + 1 + } else if child.is::() { + 0 + } else { + count_videos(child) + } + }) + .sum() +} + // --------------------------------------------------------------------------- // Images -/// markdown-it's image token, rendered with its default rule plus the -/// embeds plugin's `referrerpolicy="no-referrer"` for off-origin sources. +/// markdown-it's image token, rendered with its default rule (and the +/// Worker's `altText`). Stock studio bakes no `referrerpolicy`. #[derive(Debug)] pub struct BlygImage { pub url: String, pub title: Option, } -fn is_remote(src: &str) -> bool { - // /^(?:https?:)?\/\//i - let lower = src.get(..8).unwrap_or(src).to_ascii_lowercase(); - let rest = if lower.starts_with("https:") { - &src[6..] - } else if lower.starts_with("http:") { - &src[5..] - } else { - src - }; - rest.starts_with("//") -} - /// The Worker's image `altText` (studio#6): `text`, `text_special` (escapes /// and entities) and inline code count as text, breaks as `\n`, nested /// images recurse; so the alt matches what the same text renders as outside @@ -653,9 +664,6 @@ impl NodeValue for BlygImage { if let Some(title) = &self.title { attrs.push(("title", title.clone())); } - if is_remote(&self.url) { - attrs.push(("referrerpolicy", "no-referrer".to_string())); - } fmt.self_close("img", &attrs); } } diff --git a/crates/blyg-render/src/tk.rs b/crates/blyg-render/src/tk.rs index 781de91..567520e 100644 --- a/crates/blyg-render/src/tk.rs +++ b/crates/blyg-render/src/tk.rs @@ -121,8 +121,8 @@ fn impyrt_instruction() -> &'static Regex { /// Linear token scan, exactly as tk.ts `parseScopes`: no bracket balancing, /// no nesting (a nested `[TK]` skips the whole outer scope), and an -/// unterminated scope stops the scan. A `[TK]` inside code is an example of -/// the grammar, not a scope (studio#3). `[TK]impyrt=…[/TK]` and +/// unterminated scope stops the scan. A `[TK]`, `[=]` or `[/TK]` inside +/// code is an example of the grammar, not a token (studio#3, #68). `[TK]impyrt=…[/TK]` and /// `[TK]impyrt =…[/TK]` (or the same with `[=]`) mark pasted /// generated text (decision #37): its instruction reads `impyrt`, and it /// declares no sources. @@ -134,20 +134,29 @@ pub fn parse_scopes(md: &str) -> (Vec, Vec) { } else { Vec::new() }; - let mut i = 0; - while let Some(tk) = md[i..].find("[TK]").map(|p| p + i) { - if markdown::in_ranges(&code, tk) { - i = tk + 4; - continue; + // Every token is looked for outside code, not only the opening one: a + // `[TK]` or `[/TK]` quoted in code inside a scope is text, not a nested + // scope or an early close (studio 0.41.1, #68). + let find = |token: &str, from: usize| -> Option { + let mut from = from; + loop { + let at = md[from..].find(token).map(|p| p + from)?; + if !markdown::in_ranges(&code, at) { + return Some(at); + } + from = at + token.len(); } - let Some(close) = md[tk + 4..].find("[/TK]").map(|p| p + tk + 4) else { + }; + let mut i = 0; + while let Some(tk) = find("[TK]", i) { + let Some(close) = find("[/TK]", tk + 4) else { errors.push(TkError { at: tk, reason: "unterminated scope (missing [/TK])".into(), }); break; }; - if let Some(nested) = md[tk + 4..].find("[TK]").map(|p| p + tk + 4) + if let Some(nested) = find("[TK]", tk + 4) && nested < close { errors.push(TkError { @@ -157,10 +166,7 @@ pub fn parse_scopes(md: &str) -> (Vec, Vec) { i = close + 5; continue; } - let eq = md[tk + 4..] - .find("[=]") - .map(|p| p + tk + 4) - .filter(|&e| e < close); + let eq = find("[=]", tk + 4).filter(|&e| e < close); let instr_end = eq.unwrap_or(close); let end = close + 5; let mut instruction = js_trim(&md[tk + 4..instr_end]).to_string(); @@ -272,7 +278,64 @@ pub(crate) struct Annotated { pub has_inline: bool, } -pub(crate) fn annotate(stripped: &Mapped, spans: &[Span]) -> Annotated { +/// A piece of a block span's text: generated prose, or an own-line +/// directive (with its attached `>` run) that publish bakes as a quote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Piece { + Generated(std::ops::Range), + Directive(std::ops::Range), +} + +/// Decision #60: an own-line `![[id]]` left in TK output is a transclusion +/// directive once publish consumes the scope. Split a block span's text at +/// such lines (outside code), so the thread walk bakes them as quotes and +/// only the prose around them is disclosed as generated. Whitespace-only +/// prose is dropped. A span with no directive is one `Generated` piece. +pub(crate) fn split_directives(text: &str) -> Vec { + if !text.contains("![[") { + return vec![Piece::Generated(0..text.len())]; + } + let lines: Vec<&str> = text.split('\n').collect(); + let literal = crate::transclusion::literal_lines(text, &lines); + let mut starts = Vec::with_capacity(lines.len()); + let mut at = 0; + for l in &lines { + starts.push(at); + at += l.len() + 1; + } + let push_prose = |from: Option, to: usize, pieces: &mut Vec| { + if let Some(from) = from + && !text[from..to].chars().all(is_js_ws) + { + pieces.push(Piece::Generated(from..to)); + } + }; + let mut pieces = Vec::new(); + let mut prose: Option = None; + let mut k = 0; + while k < lines.len() { + if literal[k] || !crate::transclusion::is_directive_line(lines[k]) { + prose.get_or_insert(starts[k]); + k += 1; + continue; + } + push_prose(prose.take(), starts[k], &mut pieces); + let (_, next) = crate::transclusion::attached_quote(&lines, k); + pieces.push(Piece::Directive( + starts[k]..starts[next - 1] + lines[next - 1].len(), + )); + k = next; + } + push_prose(prose.take(), text.len(), &mut pieces); + pieces +} + +/// With `quotes` (threads), a block span's own-line directives stay in the +/// document as directives ([`split_directives`]), and each run of prose +/// around them becomes a generated block of its own. The studio's preview +/// shows such a directive as literal text inside the block; publish, by +/// decision #60, bakes it (see docs/RENDER.md). +pub(crate) fn annotate(stripped: &Mapped, spans: &[Span], quotes: bool) -> Annotated { let mut b = MapBuilder::new(stripped); let mut blocks = Vec::new(); let mut has_inline = false; @@ -280,10 +343,37 @@ pub(crate) fn annotate(stripped: &Mapped, spans: &[Span]) -> Annotated { for (i, span) in spans.iter().enumerate() { b.copy(last, span.start); let line = stripped.line_at(span.start); - if span.block { + let text = &stripped.text[span.start..span.end]; + let whole = [Piece::Generated(0..text.len())]; + let pieces = if span.block && quotes { + split_directives(text) + } else { + whole.to_vec() + }; + if span.block && pieces != whole { + for (k, piece) in pieces.iter().enumerate() { + let (Piece::Directive(r) | Piece::Generated(r)) = piece; + // Prose after a directive starts on the blank line below it. + let lead = + text[r.clone()].len() - text[r.clone()].trim_start_matches(is_js_ws).len(); + let at = stripped.line_at(span.start + r.start + lead); + if k > 0 { + // The line the separator opens is the next piece's. + b.insert("\n\n", at); + } + match piece { + Piece::Directive(r) => b.copy(span.start + r.start, span.start + r.end), + Piece::Generated(r) => { + let token = format!("{BLOCK_SENTINEL}{i}.{k}{BLOCK_SENTINEL}"); + b.insert(&token, at); + blocks.push((token, js_trim(&text[r.clone()]).to_string(), at)); + } + } + } + } else if span.block { let token = format!("{BLOCK_SENTINEL}{i}{BLOCK_SENTINEL}"); b.insert(&token, line); - blocks.push((token, stripped.text[span.start..span.end].to_string(), line)); + blocks.push((token, text.to_string(), line)); } else { has_inline = true; b.insert(&INLINE_OPEN.to_string(), line); @@ -300,6 +390,34 @@ pub(crate) fn annotate(stripped: &Mapped, spans: &[Span]) -> Annotated { } } +/// Ids an own-line `![[id]]` in a scope's output names without its +/// instruction naming them (studio tk.ts `unrequestedOutputDirectives`, +/// decision #60). Publishing a thread bakes each as a quote and notifies its +/// origin, which is a surprise when a model echoed the directive, so a +/// client warns first. `impyrt` and ungenerated scopes are skipped, and so +/// is a line inside code, which never bakes (the studio does not skip it). +pub fn unrequested_output_directives(md: &str) -> Vec { + let (scopes, _) = parse_scopes(md); + let mut found: Vec = Vec::new(); + for s in &scopes { + if s.imported.is_some() { + continue; + } + let Some(out) = &s.output else { continue }; + for piece in split_directives(out) { + let Piece::Directive(r) = piece else { continue }; + let line = out[r].split('\n').next().unwrap_or_default(); + let Some(id) = crate::transclusion::directive_id(line) else { + continue; + }; + if !s.source_ids.iter().any(|x| x == id) && !found.iter().any(|x| x == id) { + found.push(id.to_string()); + } + } + } + found +} + /// A rendered block span, for [`apply_wrappers`]. pub(crate) struct Block { pub token: String, diff --git a/crates/blyg-render/src/transclusion.rs b/crates/blyg-render/src/transclusion.rs index e181b21..0ca6450 100644 --- a/crates/blyg-render/src/transclusion.rs +++ b/crates/blyg-render/src/transclusion.rs @@ -286,6 +286,12 @@ pub(crate) fn directive_id(line: &str) -> Option<&str> { .map(|m| m.as_str()) } +/// Whether the walk would take `line` as a directive (resolved, or refused +/// as a reserved `@vN` form) rather than prose. +pub(crate) fn is_directive_line(line: &str) -> bool { + directive_re().is_match(line) || reserved_re().is_match(line) +} + fn reserved_re() -> &'static Regex { static RE: OnceLock = OnceLock::new(); RE.get_or_init(|| { diff --git a/crates/blyg-render/tests/fixtures/corpus.json b/crates/blyg-render/tests/fixtures/corpus.json index e8ddcf2..b0e6ccb 100644 --- a/crates/blyg-render/tests/fixtures/corpus.json +++ b/crates/blyg-render/tests/fixtures/corpus.json @@ -210,5 +210,12 @@ {"name": "tk_doc_edge_ungenerated", "kind": "fragment", "md": "Intro.\n\n[TK]write the ending[/TK]\n"}, {"name": "tk_doc_edge_one_newline_inside", "kind": "fragment", "md": "Intro.\n[TK]a[=]stays inline[/TK]\n\n[TK]b[=]block at the end[/TK]\n"}, {"name": "tk_doc_edge_text_after", "kind": "fragment", "md": "\n[TK]a[=]inline, text follows[/TK]\nmore text"}, - {"name": "tk_doc_edge_thread", "kind": "thread", "md": "[TK]open[=]Generated opening.[/TK]\n\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n\n[TK]close[=]Generated closing.[/TK]\n"} + {"name": "tk_doc_edge_thread", "kind": "thread", "md": "[TK]open[=]Generated opening.[/TK]\n\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n\n[TK]close[=]Generated closing.[/TK]\n"}, + {"name": "tk_code_close_in_scope", "kind": "fragment", "md": "Changelog: [TK]impyrt=close a scope with `[/TK]` now[/TK], done."}, + {"name": "tk_code_open_in_scope", "kind": "fragment", "md": "[TK]impyrt=Write `[TK]x[/TK]` in a post.[/TK]"}, + {"name": "tk_code_eq_in_scope", "kind": "fragment", "md": "A [TK]explain `[=]`[=]It splits instruction from output.[/TK] B"}, + {"name": "tk_code_fence_in_scope", "kind": "thread", "md": "Intro\n\n[TK]impyrt=Example:\n\n```\n[TK]a[=]b[/TK]\n```\n[/TK]\n\nEnd"}, + {"name": "tr_block_tk_directive_split", "kind": "thread", "md": "Intro.\n\n[TK]write a bridge[=]bridge line\n\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n\nafter[/TK]\n\nEnd"}, + {"name": "tr_block_tk_directive_attached", "kind": "thread", "md": "[TK]bridge[=]One.\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n> Small tools\n\nTwo.[/TK]"}, + {"name": "tr_block_tk_directive_in_code", "kind": "thread", "md": "[TK]x[=]Look:\n\n```\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n```[/TK]"} ] diff --git a/crates/blyg-render/tests/fixtures/gen_parity.mjs b/crates/blyg-render/tests/fixtures/gen_parity.mjs index e9fd3f0..3226ad5 100644 --- a/crates/blyg-render/tests/fixtures/gen_parity.mjs +++ b/crates/blyg-render/tests/fixtures/gen_parity.mjs @@ -9,7 +9,9 @@ // node crates/blyg-render/tests/fixtures/gen_parity.mjs /path/to/worker // // where /path/to/worker is the Worker package directory (the one holding -// package.json, src/ and node_modules/; run `npm ci` there first). Nothing in +// package.json, src/ and node_modules/; run `npm ci` there first): a clone +// of stock blygger-studio at a release tag, checked out in a scratch +// directory rather than built where you work on it. Nothing in // the Worker directory is modified: gen_parity_worker.js and the Worker's // modules are bundled with the Worker's own esbuild into a temporary file, // which runs inside workerd through the Worker's own Miniflare. (workerd, @@ -81,6 +83,11 @@ const input = { vectors: read("linkify_vectors_input.json"), media: read("media_input.json"), }; +// Miniflare keeps scratch state in ./.wrangler: run it from a temporary +// directory so nothing lands in the caller's working tree. +const cwd = process.cwd(); +const runDir = mkdtempSync(join(tmpdir(), "blyg-parity-run-")); +process.chdir(runDir); const mf = new Miniflare({ modules: true, script, @@ -94,6 +101,8 @@ try { out = await res.json(); } finally { await mf.dispose(); + process.chdir(cwd); + rmSync(runDir, { recursive: true, force: true }); } // --- 3. Write fixtures -------------------------------------------------------------- diff --git a/crates/blyg-render/tests/fixtures/parity/_manifest.json b/crates/blyg-render/tests/fixtures/parity/_manifest.json index c50d66c..13d1f58 100644 --- a/crates/blyg-render/tests/fixtures/parity/_manifest.json +++ b/crates/blyg-render/tests/fixtures/parity/_manifest.json @@ -1,9 +1,9 @@ { "generator": "gen_parity.mjs", - "studio": "0.36.1", + "studio": "0.46.1", "markdown_it": "14.3.2", "linkify_it": "5.0.2", - "cases": 212, + "cases": 219, "commonmark_examples": 652, "linkify_vectors": 206, "media_cases": 4 diff --git a/crates/blyg-render/tests/fixtures/parity/image_in_link.json b/crates/blyg-render/tests/fixtures/parity/image_in_link.json index 7edb828..a209099 100644 --- a/crates/blyg-render/tests/fixtures/parity/image_in_link.json +++ b/crates/blyg-render/tests/fixtures/parity/image_in_link.json @@ -2,7 +2,7 @@ "name": "image_in_link", "kind": "fragment", "md": "[![badge](https://images.example.org/b.svg)](https://example.org/)", - "html": "

\"badge\"

\n", + "html": "

\"badge\"

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/images_edge.json b/crates/blyg-render/tests/fixtures/parity/images_edge.json index dab0a6f..ba8f950 100644 --- a/crates/blyg-render/tests/fixtures/parity/images_edge.json +++ b/crates/blyg-render/tests/fixtures/parity/images_edge.json @@ -2,7 +2,7 @@ "name": "images_edge", "kind": "fragment", "md": "![up](HTTPS://IMAGES.EXAMPLE.ORG/U.PNG) ![amp & alt](/a.png) ![br \nline](/b.png) ![link [in](/x) alt](/c.png)", - "html": "

\"up\" \"amp \"br\nline\" \"link

\n", + "html": "

\"up\" \"amp \"br\nline\" \"link

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/images_remote.json b/crates/blyg-render/tests/fixtures/parity/images_remote.json index c1b2e83..67dc78e 100644 --- a/crates/blyg-render/tests/fixtures/parity/images_remote.json +++ b/crates/blyg-render/tests/fixtures/parity/images_remote.json @@ -2,7 +2,7 @@ "name": "images_remote", "kind": "fragment", "md": "![remote](https://images.example.org/p.jpg) ![proto](//cdn.example.com/x.png) ![http](http://images.example.org/q.png \"Q\")", - "html": "

\"remote\" \"proto\" \"http\"

\n", + "html": "

\"remote\" \"proto\" \"http\"

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/mixed_document.json b/crates/blyg-render/tests/fixtures/parity/mixed_document.json index 59fb004..183f91a 100644 --- a/crates/blyg-render/tests/fixtures/parity/mixed_document.json +++ b/crates/blyg-render/tests/fixtures/parity/mixed_document.json @@ -2,7 +2,7 @@ "name": "mixed_document", "kind": "thread", "md": "# Notes on tools\n\nSmall tools matter. See https://blyg.example.com/notes for more.\n\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n\n[TK]a bridging sentence[=]Which brings us to the *real* point.[/TK]\n\n![a sunset](https://images.example.org/sunset.jpg)\n\nhttps://www.youtube.com/watch?v=Zx9_abc-123\n\n![[03zzzzzzzzzzzzzzzzzzzzzzzz]]\n\n- first\n- second\n\n> A plain quote.\n", - "html": "

Notes on tools

\n

Small tools matter. See https://blyg.example.com/notes for more.

\n\n
\n

Small tools, used daily, beat big tools used rarely.

\n\n\n

fragment ↗ · snapshot of v3

\n

Which brings us to the real point.

\n
\n

\"a

\n
\"\"
https://www.youtube.com/watch?v=Zx9_abc-123
\n\n

⚠ unresolvable: unknown item

\n
    \n
  • first
  • \n
  • second
  • \n
\n
\n

A plain quote.

\n
\n", + "html": "

Notes on tools

\n

Small tools matter. See https://blyg.example.com/notes for more.

\n\n
\n

Small tools, used daily, beat big tools used rarely.

\n\n\n

fragment ↗ · snapshot of v3

\n

Which brings us to the real point.

\n
\n

\"a

\n

https://www.youtube.com/watch?v=Zx9_abc-123

\n\n

⚠ unresolvable: unknown item

\n
    \n
  • first
  • \n
  • second
  • \n
\n
\n

A plain quote.

\n
\n", "tk": { "scopes": [ { diff --git a/crates/blyg-render/tests/fixtures/parity/tk_block_with_image_and_yt.json b/crates/blyg-render/tests/fixtures/parity/tk_block_with_image_and_yt.json index 9c4f1f4..6f24b05 100644 --- a/crates/blyg-render/tests/fixtures/parity/tk_block_with_image_and_yt.json +++ b/crates/blyg-render/tests/fixtures/parity/tk_block_with_image_and_yt.json @@ -2,7 +2,7 @@ "name": "tk_block_with_image_and_yt", "kind": "fragment", "md": "[TK]media[=]![pic](https://images.example.org/p.png)\n\nhttps://youtu.be/Qa1b2C3d4E5[/TK]", - "html": "\n", + "html": "\n", "tk": { "scopes": [ { diff --git a/crates/blyg-render/tests/fixtures/parity/tk_code_close_in_scope.json b/crates/blyg-render/tests/fixtures/parity/tk_code_close_in_scope.json new file mode 100644 index 0000000..db61548 --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tk_code_close_in_scope.json @@ -0,0 +1,19 @@ +{ + "name": "tk_code_close_in_scope", + "kind": "fragment", + "md": "Changelog: [TK]impyrt=close a scope with `[/TK]` now[/TK], done.", + "html": "

Changelog: close a scope with [/TK] now, done.

\n", + "tk": { + "scopes": [ + { + "instruction": "impyrt", + "generated": true, + "block": false, + "source_ids": [] + } + ], + "errors": [] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tk_code_eq_in_scope.json b/crates/blyg-render/tests/fixtures/parity/tk_code_eq_in_scope.json new file mode 100644 index 0000000..0388fa4 --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tk_code_eq_in_scope.json @@ -0,0 +1,19 @@ +{ + "name": "tk_code_eq_in_scope", + "kind": "fragment", + "md": "A [TK]explain `[=]`[=]It splits instruction from output.[/TK] B", + "html": "

A It splits instruction from output. B

\n", + "tk": { + "scopes": [ + { + "instruction": "explain `[=]`", + "generated": true, + "block": false, + "source_ids": [] + } + ], + "errors": [] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tk_code_fence_in_scope.json b/crates/blyg-render/tests/fixtures/parity/tk_code_fence_in_scope.json new file mode 100644 index 0000000..4f71d74 --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tk_code_fence_in_scope.json @@ -0,0 +1,19 @@ +{ + "name": "tk_code_fence_in_scope", + "kind": "thread", + "md": "Intro\n\n[TK]impyrt=Example:\n\n```\n[TK]a[=]b[/TK]\n```\n[/TK]\n\nEnd", + "html": "

Intro

\n

Example:

\n
[TK]a[=]b[/TK]\n
\n
\n

End

\n", + "tk": { + "scopes": [ + { + "instruction": "impyrt", + "generated": true, + "block": true, + "source_ids": [] + } + ], + "errors": [] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tk_code_open_in_scope.json b/crates/blyg-render/tests/fixtures/parity/tk_code_open_in_scope.json new file mode 100644 index 0000000..a025469 --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tk_code_open_in_scope.json @@ -0,0 +1,19 @@ +{ + "name": "tk_code_open_in_scope", + "kind": "fragment", + "md": "[TK]impyrt=Write `[TK]x[/TK]` in a post.[/TK]", + "html": "

Write [TK]x[/TK] in a post.

\n
\n", + "tk": { + "scopes": [ + { + "instruction": "impyrt", + "generated": true, + "block": true, + "source_ids": [] + } + ], + "errors": [] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_attached.json b/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_attached.json new file mode 100644 index 0000000..8f44be6 --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_attached.json @@ -0,0 +1,19 @@ +{ + "name": "tr_block_tk_directive_attached", + "kind": "thread", + "md": "[TK]bridge[=]One.\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n> Small tools\n\nTwo.[/TK]", + "html": "

One.\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]

\n
\n

Small tools

\n
\n

Two.

\n
\n", + "tk": { + "scopes": [ + { + "instruction": "bridge", + "generated": true, + "block": true, + "source_ids": [] + } + ], + "errors": [] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_in_code.json b/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_in_code.json new file mode 100644 index 0000000..92771da --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_in_code.json @@ -0,0 +1,17 @@ +{ + "name": "tr_block_tk_directive_in_code", + "kind": "thread", + "md": "[TK]x[=]Look:\n\n```\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n```[/TK]", + "html": "

[TK]x[=]Look:

\n
![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n```[/TK]
\n", + "tk": { + "scopes": [], + "errors": [ + { + "at": 0, + "reason": "unterminated scope (missing [/TK])" + } + ] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_split.json b/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_split.json new file mode 100644 index 0000000..ca68af0 --- /dev/null +++ b/crates/blyg-render/tests/fixtures/parity/tr_block_tk_directive_split.json @@ -0,0 +1,19 @@ +{ + "name": "tr_block_tk_directive_split", + "kind": "thread", + "md": "Intro.\n\n[TK]write a bridge[=]bridge line\n\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]\n\nafter[/TK]\n\nEnd", + "html": "

Intro.

\n

bridge line

\n

![[01j9zq3k4m5n6p7q8r9s0t1v2w]]

\n

after

\n
\n

End

\n", + "tk": { + "scopes": [ + { + "instruction": "write a bridge", + "generated": true, + "block": true, + "source_ids": [] + } + ], + "errors": [] + }, + "transclusions": [], + "errors": [] +} diff --git a/crates/blyg-render/tests/fixtures/parity/tr_sanitize_markdown.json b/crates/blyg-render/tests/fixtures/parity/tr_sanitize_markdown.json index 8235ce0..82424bb 100644 --- a/crates/blyg-render/tests/fixtures/parity/tr_sanitize_markdown.json +++ b/crates/blyg-render/tests/fixtures/parity/tr_sanitize_markdown.json @@ -2,7 +2,7 @@ "name": "tr_sanitize_markdown", "kind": "thread", "md": "| a | b |\n|--:|:-:|\n| 1 | 2 |\n\n3. three\n4. four\n\n![pic](https://images.example.org/p.png) and ![local](/blyg/media/x.png)\n\nhttps://www.youtube.com/watch?v=Zx9_abc-123\n\n[mail](mailto:a@example.org) [tel](tel:+15550100) [rel](/blyg/f/x/)\n\n![[01j9zq3k4m5n6p7q8r9s0t1v2w]]", - "html": "\n\n\n\n\n\n\n\n\n\n\n\n\n
ab
12
\n
    \n
  1. three
  2. \n
  3. four
  4. \n
\n

\"pic\" and \"local\"

\n
\"\"
https://www.youtube.com/watch?v=Zx9_abc-123
\n

mail tel rel

\n\n
\n

Small tools, used daily, beat big tools used rarely.

\n\n\n

fragment ↗ · snapshot of v3

", + "html": "\n\n\n\n\n\n\n\n\n\n\n\n\n
ab
12
\n
    \n
  1. three
  2. \n
  3. four
  4. \n
\n

\"pic\" and \"local\"

\n

https://www.youtube.com/watch?v=Zx9_abc-123

\n

mail tel rel

\n\n
\n

Small tools, used daily, beat big tools used rarely.

\n\n\n

fragment ↗ · snapshot of v3

", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_angle.json b/crates/blyg-render/tests/fixtures/parity/yt_angle.json index 59163d5..0883159 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_angle.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_angle.json @@ -2,7 +2,7 @@ "name": "yt_angle", "kind": "fragment", "md": "", - "html": "
\"\"
https://youtube.com/shorts/Pq7Rs8Tu9Vw
\n", + "html": "

https://youtube.com/shorts/Pq7Rs8Tu9Vw

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_bare.json b/crates/blyg-render/tests/fixtures/parity/yt_bare.json index 5a2de54..a070a22 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_bare.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_bare.json @@ -2,7 +2,7 @@ "name": "yt_bare", "kind": "fragment", "md": "https://www.youtube.com/watch?v=Zx9_abc-123", - "html": "
\"\"
https://www.youtube.com/watch?v=Zx9_abc-123
\n", + "html": "

https://www.youtube.com/watch?v=Zx9_abc-123

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_bare_md_link.json b/crates/blyg-render/tests/fixtures/parity/yt_bare_md_link.json index e94fb46..666bb50 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_bare_md_link.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_bare_md_link.json @@ -2,7 +2,7 @@ "name": "yt_bare_md_link", "kind": "fragment", "md": "[https://youtu.be/Qa1b2C3d4E5](https://youtu.be/Qa1b2C3d4E5)", - "html": "
\"\"
https://youtu.be/Qa1b2C3d4E5
\n", + "html": "

https://youtu.be/Qa1b2C3d4E5

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_bare_short.json b/crates/blyg-render/tests/fixtures/parity/yt_bare_short.json index 17449cd..eb6f714 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_bare_short.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_bare_short.json @@ -2,7 +2,7 @@ "name": "yt_bare_short", "kind": "fragment", "md": "Before.\n\nhttps://youtu.be/Qa1b2C3d4E5\n\nAfter.", - "html": "

Before.

\n
\"\"
https://youtu.be/Qa1b2C3d4E5
\n

After.

\n", + "html": "

Before.

\n

https://youtu.be/Qa1b2C3d4E5

\n

After.

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_in_blockquote.json b/crates/blyg-render/tests/fixtures/parity/yt_in_blockquote.json index 2fdc081..fc79d52 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_in_blockquote.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_in_blockquote.json @@ -2,7 +2,7 @@ "name": "yt_in_blockquote", "kind": "fragment", "md": "> https://youtu.be/Qa1b2C3d4E5", - "html": "
\n
\"\"
https://youtu.be/Qa1b2C3d4E5
\n
\n", + "html": "
\n

https://youtu.be/Qa1b2C3d4E5

\n
\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_in_loose_list.json b/crates/blyg-render/tests/fixtures/parity/yt_in_loose_list.json index f1a73cf..ecf69ab 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_in_loose_list.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_in_loose_list.json @@ -2,7 +2,7 @@ "name": "yt_in_loose_list", "kind": "fragment", "md": "- https://youtu.be/Qa1b2C3d4E5\n\n- second", - "html": "\n", + "html": "\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_live.json b/crates/blyg-render/tests/fixtures/parity/yt_live.json index 98e3266..2381566 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_live.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_live.json @@ -2,7 +2,7 @@ "name": "yt_live", "kind": "fragment", "md": " https://www.youtube.com/live/Lm3No4Pq5Rs ", - "html": "
\"\"
https://www.youtube.com/live/Lm3No4Pq5Rs
\n", + "html": "

https://www.youtube.com/live/Lm3No4Pq5Rs

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_music_and_short_id.json b/crates/blyg-render/tests/fixtures/parity/yt_music_and_short_id.json index a222317..d519961 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_music_and_short_id.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_music_and_short_id.json @@ -2,7 +2,7 @@ "name": "yt_music_and_short_id", "kind": "fragment", "md": "https://music.youtube.com/watch?v=Ab3De6\n\nhttps://youtu.be/short", - "html": "
\"\"
https://music.youtube.com/watch?v=Ab3De6
\n

https://youtu.be/short

\n", + "html": "

https://music.youtube.com/watch?v=Ab3De6

\n

https://youtu.be/short

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/fixtures/parity/yt_params.json b/crates/blyg-render/tests/fixtures/parity/yt_params.json index dc62a7d..260a37d 100644 --- a/crates/blyg-render/tests/fixtures/parity/yt_params.json +++ b/crates/blyg-render/tests/fixtures/parity/yt_params.json @@ -2,7 +2,7 @@ "name": "yt_params", "kind": "fragment", "md": "https://m.youtube.com/watch?feature=share&v=Zx9_abc-123&t=42", - "html": "
\"\"
https://m.youtube.com/watch?feature=share&v=Zx9_abc-123&t=42
\n", + "html": "

https://m.youtube.com/watch?feature=share&v=Zx9_abc-123&t=42

\n", "tk": { "scopes": [], "errors": [] diff --git a/crates/blyg-render/tests/parity.rs b/crates/blyg-render/tests/parity.rs index dca98cc..0815341 100644 --- a/crates/blyg-render/tests/parity.rs +++ b/crates/blyg-render/tests/parity.rs @@ -10,7 +10,29 @@ use serde_json::Value; /// Fixtures whose output is known to differ, with the reason. Keep this /// list honest and short; `docs/RENDER.md` explains each entry. -const KNOWN_DIVERGENCES: &[(&str, &str)] = &[]; +const KNOWN_DIVERGENCES: &[(&str, &str)] = &[ + // Decision #60: an own-line `![[id]]` in a block scope's output is a + // quote at publish. The studio's preview (and its publish, for a scope + // with recorded provenance) renders the block on its own and shows the + // directive as text; this preview bakes it, as the decision says publish + // does. `tests/preview.rs` pins the output. + ( + "tr_block_tk_directive_output", + "decision #60: directive in block TK output is a quote", + ), + ( + "tr_block_tk_directive_split", + "decision #60: directive in block TK output is a quote", + ), + ( + "tr_block_tk_directive_attached", + "decision #60: directive in block TK output is a quote", + ), + ( + "tr_in_tk_block", + "decision #60: directive in block TK output is a quote", + ), +]; fn load_fixtures() -> Vec { let dir = fixtures_dir().join("parity"); @@ -96,7 +118,7 @@ fn parity_with_worker() { "--- {name}: unresolved\n expected: {errors:?}\n got: {got_errors:?}" )); } - if kind == Kind::Thread { + if kind == Kind::Thread && !known { let ids: Vec = f["transclusions"] .as_array() .unwrap() diff --git a/crates/blyg-render/tests/preview.rs b/crates/blyg-render/tests/preview.rs index 652ce7e..22dc126 100644 --- a/crates/blyg-render/tests/preview.rs +++ b/crates/blyg-render/tests/preview.rs @@ -60,12 +60,13 @@ fn data_line_through_quotes_and_tk_blocks() { "{h}" ); assert!(h.contains("

⚠ unresolvable: unknown item

")); - // The thread preview is sanitized, as the public thread page is: the - // facade keeps its class and poster link, not `data-ytid`. + // A bare video link stays a link (stock studio upgrades it on screen, + // never in the HTML), and still counts as a video. assert!( - h.contains("
"), + h.contains("

"), "{h}" ); + assert_eq!(out.stats.videos, 1); assert!(h.contains("

End

")); assert_eq!( out.line_map, @@ -321,3 +322,80 @@ fn fuzz_no_panics() { } } } + +/// Decision #60: an own-line directive left in a block scope's output is a +/// quote, as publish makes it; the prose around it stays disclosed as +/// generated, and the quote sits between the generated blocks. +#[test] +fn directive_in_block_output_is_a_quote() { + let store = FakeStore::load(); + let md = format!("Intro.\n\n[TK]write a bridge[=]bridge line\n\n![[{A}]]\n\nafter[/TK]\n\nEnd"); + let out = render_preview(&md, Kind::Thread, &store, &opts()); + let h = strip_lines(&out.html); + let quote = format!( + "
" + ); + let gen1 = h.find("

bridge line

\n
"); + let q = h.find("e); + let gen2 = h.find("

after

\n
"); + assert!(gen1.is_some() && gen1 < q && q < gen2, "{h}"); + assert!(!h.contains("![["), "{h}"); + assert_eq!(out.stats.transclusions.len(), 1); + assert_eq!(out.stats.ai_spans, 1); + // Every top-level block keeps its source line. + for at in [ + "
", + "data-blyg-version=\"3\" data-line=\"4\">", + "
", + ] { + assert!(out.html.contains(at), "{at}: {}", out.html); + } + + // An output that is only the directive leaves no empty generated block. + let md = format!("Text.\n\n[TK]summarise ![[{A}]][=]![[{A}]][/TK]"); + let h = render_preview(&md, Kind::Thread, &store, &opts()).html; + assert!(!h.contains("blyg-tk-gen"), "{h}"); + assert!(h.contains("data-blyg-version=\"3\""), "{h}"); + + // An attached `>` run is a partial quote, as anywhere else in a thread. + let md = format!("[TK]bridge[=]One.\n![[{A}]]\n> Small tools\n\nTwo.[/TK]"); + let out = render_preview(&md, Kind::Thread, &store, &opts()); + assert!(out.html.contains("blyg-partial"), "{}", out.html); + assert!(out.stats.transclusions[0].selector.is_some()); + + // In code it stays text, and a fragment never quotes. + let md = format!("[TK]x[=]Look:\n\n```\n![[{A}]]\n```[/TK]"); + let out = render_preview(&md, Kind::Thread, &store, &opts()); + assert!(out.stats.transclusions.is_empty()); + let md = format!("[TK]x[=]bridge\n\n![[{A}]][/TK]"); + let out = render_preview(&md, Kind::Fragment, &store, &opts()); + assert!( + out.html.contains(&format!("

![[{A}]]

")), + "{}", + out.html + ); +} + +#[test] +fn unrequested_output_directives_are_named() { + use blyg_render::unrequested_output_directives as unrequested; + // Echoed by the output, not named by the instruction. + assert_eq!( + unrequested(&format!( + "Intro.\n\n[TK]write a bridge[=]line\n![[{A}]]\nafter[/TK]" + )), + vec![A.to_string()] + ); + // Named by the instruction: a source, no warning. + assert!(unrequested(&format!("[TK]open with ![[{A}]][=]x\n![[{A}]]\ny[/TK]")).is_empty()); + // Inside a sentence, in code, ungenerated or impyrt: never a quote. + assert!(unrequested(&format!("[TK]x[=]see ![[{A}]] here[/TK]")).is_empty()); + assert!(unrequested(&format!("[TK]x[=]```\n![[{A}]]\n```[/TK]")).is_empty()); + assert!(unrequested(&format!("[TK]x ![[{U}]][/TK]")).is_empty()); + assert!(unrequested(&format!("[TK]impyrt=a\n![[{A}]]\nb[/TK]")).is_empty()); + // Each id once, in order. + assert_eq!( + unrequested(&format!("[TK]x[=]![[{U}]]\n![[{A}]]\n![[{U}]][/TK]")), + vec![U.to_string(), A.to_string()] + ); +} diff --git a/docs/RENDER.md b/docs/RENDER.md index d1d8005..45b2a42 100644 --- a/docs/RENDER.md +++ b/docs/RENDER.md @@ -11,6 +11,8 @@ what readers get. working copy ─┬─ parse TK scopes (tk.rs, as tk.ts parseScopes), except inside code ├─ preview strip: scope → output, or "⚠ ungenerated — " ├─ annotate: block spans → one-line placeholder token (rendered on its own), + │ except, in a thread, an own-line ![[id]] in the output stays a + │ directive between tokens (decision #60); │ inline spans → U+E001…U+E002 sentinels (Markdown parses across them) ├─ [[id]] links → U+E003 tokens, except in code (links.rs) ├─ fragment: Markdown thread: line walker (transclusion.rs) @@ -36,12 +38,34 @@ spliced in literally (a `$&` in generated text stays `$&`). "Code" is the Worker's `codeRanges` (`markdown::code_ranges`): code blocks (fenced or indented, at any depth) as markdown-it's block parser maps them, plus code spans (a run of N backticks to the next run of exactly N, never -across a blank line). Inside code, `[TK]`, `![[id]]` and `[[id]]` are -inert text (studio#3, #4). The thread walker leaves a line as prose when -its start lies in code. A directive on its own line inside TK output is a -real quote: upstream took that reading provisionally (v0.4-plan §9.2), so a -multi-line inline scope holding `![[id]]` splits its `span` around the -quote, as the Worker's does (`tr_inline_tk_multiline_directive`). +across a blank line). Inside code, `[TK]`, `[=]`, `[/TK]`, `![[id]]` and +`[[id]]` are inert text (studio#3, #4; since studio 0.41.1, #68, a `[/TK]` +or `[TK]` quoted in code *inside* a scope no longer closes or nests it: +`tk_code_*`). The thread walker leaves a line as prose when its start lies +in code. + +**Directives in TK output** (decision #60, ruled 2026-10-06). A scope's +sources are the `![[id]]`s in its instruction only. Its output is content: +once publish consumes the scope, an own-line `![[id]]` left there is an +ordinary transclusion directive, which bakes a quote and notifies the +quoted blyg. A multi-line inline scope holding one splits its `span` around +the quote, as the Worker's does (`tr_inline_tk_multiline_directive`). A +block scope is rendered on its own, so here the preview splits its output +at each such line (outside code, with any attached `>` run): the prose on +either side becomes its own `div.blyg-tk-gen`, the directive stays in the +document for the thread walk, and whitespace-only prose is dropped. That +is what the decision says publish produces. The studio's preview does not +do it (it shows the directive as text inside the block, and so does its +publish for a scope with recorded provenance, an upstream inconsistency), +so four fixtures are known divergences (see "Parity"). Fragments never +quote, so there the directive stays text. + +`unrequested_output_directives(md)` (tk.ts `unrequestedOutputDirectives`) +lists the ids such a line names when the scope's instruction does not, +skipping `impyrt` and ungenerated scopes. The app warns in the publish +sheet before publishing a thread that has any; the studio's publish +returns the same warning afterwards. Unlike the studio's, it skips a line +inside code, which never bakes. A scope is a **block** (its output, or the ungenerated marker, renders as a `div` of its own) when a blank line, or the document's edge, sits on both @@ -66,9 +90,8 @@ scope datetime open dir lang`), `data-blyg-*`, an `
` that is http(s), mailto or tel, and an `` that is http(s). Scripts, styles, iframes, SVG, forms and the like go with their content; other unknown tags are unwrapped. So a thread preview loses table alignment, -`
    `, `referrerpolicy`/`loading` on images, and the YouTube -facade's `data-ytid` and `aria-label` (the poster link still works). -Fragments are not sanitized, in the Worker or here. +`
      ` and `loading` on images. Fragments are not sanitized, in the +Worker or here. **Partial quotes** (spec §16.4, ported from blygger-studio 0.8.3's `transclusion.ts`). A `![[id]]` line followed directly (no blank line) by a @@ -126,7 +149,8 @@ carry `data-line="N"` (0-based source line) for source ↔ preview jumping. ## Markdown engine: markdown-it.rs, not comrak or pulldown-cmark The Worker runs markdown-it 14 with `{ html: false, linkify: true, -typographer: false }` plus its embeds plugin. I rendered the first corpus +typographer: false }` (and, until the stock studio replaced it in 0.40, an +embeds plugin). I rendered the first corpus (56 plain-Markdown cases) through each candidate, stock: | crate | matches the Worker | why it misses | @@ -151,8 +175,13 @@ replaced to match the JavaScript library exactly: text, escapes, entities and inline code), line breaks around code blocks, list items and empty blockquotes, and no final `\n` in a fence left open at the end of a document without one. -- **Embeds**: the YouTube facade (`figure.blyg-yt`, byte-identical to - embeds.ts) and `referrerpolicy="no-referrer"` on off-origin images. +- **Embeds**: none in the HTML. Stock studio (0.40, embeds.ts) keeps + embeds out of `content_html` and upgrades a bare YouTube link to the + click-to-play facade with a page script; the preview's own script + (`preview_script`) does the same, so the preview looks as before. A + bare video link still counts in `Stats::videos`. (The facade and + `referrerpolicy` used to be baked, emulating the maintainer's patched + Worker; both are gone.) Raw HTML is never passed through: `html: false` escapes it. @@ -176,6 +205,7 @@ pub fn selection_from_quote(quote_md: &str) -> String; // transclusion.ts select pub fn locate_selection(target_html: &str, selection: &str) -> Option; pub fn render_markdown(md: &str) -> String; // the Worker's renderMarkdown +pub fn unrequested_output_directives(md: &str) -> Vec; // tk.ts unrequestedOutputDirectives (#60) pub fn studio_css() -> String; // tint, unresolved marker, facade styles pub fn article_html(kind, content_html, stub, fork) -> String; // pages.ts
      + citations pub struct Attachment { key: String /* media r2_key */, alt: Option, inline: bool } @@ -226,16 +256,17 @@ nothing is written unless every case rendered. ```sh # after editing corpus.json, fake_store.json or media_input.json, or when -# the Worker changes (the Worker package needs `npm ci` first) -node crates/blyg-render/tests/fixtures/gen_parity.mjs /path/to/worker/package +# the studio changes: clone stock blygger-studio into a scratch directory, +# check out the release tag and `npm ci` there, then +node crates/blyg-render/tests/fixtures/gen_parity.mjs /path/to/studio/clone cargo test -p blyg-render ``` -**Reference version.** The fixtures come from the reference Worker on -blygger-studio 0.36.1, re-generated 2026-10-08 +**Reference version.** The fixtures come from stock blygger-studio +0.46.1 (tag `v0.46.1`, unpatched), re-generated 2026-10-13 (`parity/_manifest.json` records the studio, markdown-it and linkify-it -versions). From 0.32.2 to 0.36.1, `markdown.ts`, `preview.ts`, `embeds.ts` -and `authoring.ts` are unchanged; see "Studio 0.36.1" below for the rest. The Worker's preview helpers moved again in 0.32 +versions). Earlier fixtures came from the maintainer's patched Worker; see +"Studio 0.46.1" below for what changed. The Worker's preview helpers moved again in 0.32 (`previewLinkDocs`/`spliceLinkDocs` became `previewInternalLinks`, `resolveBlockLinks` and `applyInternalLinks`), so the generator now calls the handler's own sequence instead of a copy of it. @@ -265,11 +296,15 @@ attributes, a sanitized TK block) and TK or links in code, plus a media case for placed and `inline` attachments. Results. Tests compare after collapsing whitespace between tags, but every -case is also byte-identical: +matching case is also byte-identical. The four known divergences are the +block-scope directive cases of decision #60 (`tr_in_tk_block`, +`tr_block_tk_directive_output`, `tr_block_tk_directive_split`, +`tr_block_tk_directive_attached`); `tests/preview.rs` pins this crate's +output for them: | suite | cases | normalised | byte-identical | |---|---|---|---| -| corpus (paragraphs, emphasis, links, linkify edges, headings, lists, code, quotes, images, raw HTML, YouTube, TK, `impyrt`, transclusion, partial quotes, `[[id]]` links, sanitizing) | 212 | 212 | 212 | +| corpus (paragraphs, emphasis, links, linkify edges, headings, lists, code, quotes, images, raw HTML, YouTube, TK, `impyrt`, transclusion, partial quotes, `[[id]]` links, sanitizing) | 219 | 215 + 4 known | 215 | | CommonMark 0.31.2 spec examples | 652 | 652 | 652 | | linkify-it + markdown-it linkify test vectors | 206 | 206 | 206 | | attachments (`mediaHtml`) | 4 | 4 | 4 | @@ -279,6 +314,27 @@ instruction only: a `![[id]]` that only the output names is not a source. Both `blyg-render`'s `parse_scopes` and `blyg-core`'s `tk::parse` follow it, and the regenerated fixtures (two `tr_inline_tk_*` cases changed) match. +**Studio 0.46.1** (re-synced 2026-10-13 from 0.36.1). Between the two, +`markdown.ts` is unchanged; `tk.ts`, `authoring.ts`, `transclusion.ts` +and `pages.ts` changed, and `embeds.ts` became presentation only. Against +stock 0.46.1 the old fixtures matched 197 of 212: all 15 misses were the +baked YouTube facade and image `referrerpolicy`, which came from the +patched Worker, not the studio. This crate no longer bakes either, and +matches all 212. Seven cases were added: + +- `[TK]`, `[=]` and `[/TK]` quoted in code inside a scope are text + (0.41.1, #68: `tk_code_close_in_scope`, `tk_code_open_in_scope`, + `tk_code_eq_in_scope`, `tk_code_fence_in_scope`). `parse_scopes` now + looks for every token outside code, not only the opening one. +- Own-line directives in block TK output (decision #60: + `tr_block_tk_directive_split`, `_attached`, `_in_code`); the first two + are known divergences, as above. + +Remote generation sources (0.41, `GenerationSource {id, version, origin?, +cited?}`) and the preview's per-scope `sources` live in the studio's +authoring API, not in the rendered HTML, so nothing here changed for them; +`blyg-core` owns the wire side. + **Studio 0.36.1** changed three things the preview shows, and this crate follows each. None of the 202 existing cases changed; 10 were added, with five local items and one imported item in `fake_store.json`: @@ -352,6 +408,8 @@ them too: also punycodes the host, which this crate does not. - **Citation dates.** They use the ISO date part, where the Worker uses `toLocaleDateString` in the server's time zone. +- **Unrequested output directives.** `unrequested_output_directives` + skips a directive line inside code; the studio's warns about it too. - **Preview script.** It gives the iframe a `referrerpolicy`, since YouTube embeds need a referrer. It resolves image URLs against `document.baseURI`, so the fallback works on a non-http preview origin. It also re-checks From eb0592f2d7d6b3a54c96426506bc1e02c0174293 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:08:17 -0700 Subject: [PATCH 03/10] Publish warns about quotes generated text echoed; reader notes the mark is unverified The publish sheet now also warns when a thread's generated text has an own-line ![[id]] its instruction never named (decision #60): publishing quotes it and notifies that author. The server's own publish warning was already shown in the toast. Reading view (spec 5.7, #59): text marked blyg-tk-gen gets a tooltip and the post ends with a small note that the mark is the publisher's own disclosure, not verified. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-app/src/studio/mod.rs | 30 ++++++++++++++-- crates/blyg-app/src/studio/reader.rs | 50 ++++++++++++++++++++++++-- crates/blyg-app/src/studio/ui_tests.rs | 30 ++++++++++++++-- 3 files changed, 103 insertions(+), 7 deletions(-) diff --git a/crates/blyg-app/src/studio/mod.rs b/crates/blyg-app/src/studio/mod.rs index 5855ef9..4c6ff9e 100644 --- a/crates/blyg-app/src/studio/mod.rs +++ b/crates/blyg-app/src/studio/mod.rs @@ -407,6 +407,24 @@ pub fn unresolved_warning(s: &Stats) -> Option { (!parts.is_empty()).then(|| format!("⚠ {} can't be resolved", parts.join(" and "))) } +/// Decision #60: an own-line `![[id]]` left in generated text is a real +/// quote once published, and notifies the quoted blyg. Say so before +/// publishing when the scope's instruction never named it (usually a model +/// echoing a directive). +pub fn echoed_quote_warning(md: &str) -> Option { + let ids = blyg_render::unrequested_output_directives(md); + let n = ids.len(); + (n > 0).then(|| { + let list: Vec = ids.iter().map(|id| format!("![[{id}]]")).collect(); + format!( + "⚠ Generated text has {} on its own line, which its instruction didn't ask for. \ + Publishing quotes {} and notifies the author.", + list.join(", "), + if n == 1 { "it" } else { "them" } + ) + }) +} + fn render_opts(base_url: Option<&str>, item: Option<&Item>) -> RenderOpts { RenderOpts { mount: base_url @@ -841,14 +859,22 @@ impl MainView { ) } - /// The publish sheet's warning about quotes and links publish would refuse. + /// The publish sheet's warning about quotes and links publish would + /// refuse, and about quotes generated text would add (decision #60). pub(super) fn studio_publish_warning(&self, cx: &App) -> Option { let item = self.current.as_ref()?; let md = self.editor.read(cx).value(); if !md.contains("[[") { return None; } - unresolved_warning(&render_doc(&*self.backend, Some(item), &md).stats) + let unresolved = unresolved_warning(&render_doc(&*self.backend, Some(item), &md).stats); + let echoed = (item.kind == blyg_core::Kind::Thread) + .then(|| echoed_quote_warning(&md)) + .flatten(); + match (unresolved, echoed) { + (Some(a), Some(b)) => Some(format!("{a}\n{b}")), + (a, b) => a.or(b), + } } /// `BLYGGER_DEMO=studio`: the sample thread in the full editor. diff --git a/crates/blyg-app/src/studio/reader.rs b/crates/blyg-app/src/studio/reader.rs index bea354d..50ebfb7 100644 --- a/crates/blyg-app/src/studio/reader.rs +++ b/crates/blyg-app/src/studio/reader.rs @@ -273,8 +273,10 @@ pub fn text_font_css(font: Option) -> String { /// The reader's additions on top of the app theme (`BUILTIN_CSS`, the Tufte /// palette in light and dark): the fonts, the text size, a left-aligned -/// column under the native header, and generated spans left unstyled as the -/// public page leaves them (the tint is a studio authoring aid). Quote +/// column under the native header, and generated spans. Their reset here +/// is overridden by the shell's `studio_css`, which comes later, so the +/// reader shows the studio tint; [`generated_note`] says the mark is the +/// publisher's own. Quote /// boxes, selection, highlights and diff marks take the theme's colours /// (`theme::reader_vars`), with the plain look's as fallbacks. pub fn reader_css(font_px: f32) -> String { @@ -289,7 +291,11 @@ ins {{ background: var(--ins, rgba(58, 166, 85, 0.18)); text-decoration: none; } del {{ background: var(--del, rgba(210, 60, 42, 0.16)); text-decoration-color: var(--over, #d23c2a); }} hr {{ border-top-color: var(--rule); }} article {{ max-width: 38em; margin: 0; }} -.blyg-tk-gen {{ background: none; box-shadow: none; padding: 0; border-radius: 0; }} +.blyg-tk-gen {{ background: none; box-shadow: none; padding: 0; border-radius: 0; cursor: help; }} +.blyg-gen-note {{ + margin-top: 1.6em; color: var(--muted, #888); + font: 11.5px Inter, -apple-system, system-ui, sans-serif; +}} blockquote.blyg-transclusion {{ font-family: inherit; }} blockquote.blyg-transclusion[data-blyg-id] {{ cursor: pointer; }} .blyg-qfoot {{ @@ -331,6 +337,7 @@ pub fn reader_page_named( Some(o) => quote_footers(&clean, &o, name_of), None => clean, }; + let clean = generated_note(&clean); let body = blyg_render::article_html(doc.kind, &clean, None, None); // --- themes --- the theme's colours over the fallback stylesheet, and // its quote-box shape last, so it wins over the reader's defaults. @@ -350,6 +357,24 @@ pub fn reader_page_named( ) } +/// What the reader says about text a post marks as generated. +pub const GENERATED_NOTE: &str = + "Some text here is marked as generated. That is the publisher's own disclosure, not verified."; + +/// Spec §5.7 (decision #59): `blyg-tk-gen` is the publisher's own claim, and +/// a client that points it out says so. The reader tints marked text (see +/// [`reader_css`]); each marked span or block gets the note as a tooltip, +/// and the post ends with it in the quote footers' small type. `html` is +/// already sanitized, so the class is written exactly this way. +pub fn generated_note(html: &str) -> String { + const MARK: &str = "class=\"blyg-tk-gen\""; + if !html.contains(MARK) { + return html.to_string(); + } + let titled = html.replace(MARK, &format!("{MARK} title=\"{GENERATED_NOTE}\"")); + format!("{titled}

      {GENERATED_NOTE}

      \n") +} + // --- quote targets --- fn attr<'a>(tag: &'a str, name: &str) -> Option<&'a str> { @@ -795,6 +820,25 @@ mod tests { ); } + /// Spec §5.7 (#59): marked text says the mark is the publisher's own. + #[test] + fn generated_text_says_the_mark_is_unverified() { + let html = "

      Mine, theirs.

      \n\ +

      More.

      "; + let page = reader_page(html, &doc(html, vec![]), 16.); + assert_eq!( + page.matches(&format!("title=\"{}\"", super::GENERATED_NOTE)) + .count(), + 2, + "{page}" + ); + assert_eq!(page.matches("class=\"blyg-gen-note\"").count(), 1); + assert!(super::GENERATED_NOTE.contains("not verified")); + // No marked text: no note. + let page = reader_page("

      x

      ", &doc("

      x

      ", vec![]), 16.); + assert!(!page.contains("class=\"blyg-gen-note\"")); + } + fn doc(html: &str, media: Vec) -> Doc { Doc { body: Body::Html(html.into()), diff --git a/crates/blyg-app/src/studio/ui_tests.rs b/crates/blyg-app/src/studio/ui_tests.rs index 2178c59..5ce7512 100644 --- a/crates/blyg-app/src/studio/ui_tests.rs +++ b/crates/blyg-app/src/studio/ui_tests.rs @@ -258,9 +258,10 @@ fn renders_the_thread_and_patches_after_a_pause(cx: &mut TestAppContext) { ); assert!(page.contains("A tide pool is a small ocean")); assert!(page.contains("class=\"blyg-tk-gen\""), "the TK tint"); + // A bare video link, which the page script shows as the facade. assert!( - page.contains("figure class=\"blyg-yt\""), - "the YouTube facade" + page.contains("\">
      "), + "the YouTube link: {page}" ); assert!(page.contains("blyg-transclusion unresolved")); assert!(page.contains("
      ")); @@ -438,3 +439,28 @@ fn publish_warns_about_unresolved_quotes(cx: &mut TestAppContext) { let w = view.read_with(cx, |v, cx| v.studio_publish_warning(cx)); assert_eq!(w, None); } + +#[gpui_kit::test] +fn publish_warns_about_a_quote_generated_text_echoed(cx: &mut TestAppContext) { + let (view, fake, _, cx) = setup(cx, true); + open_sample(&view, &fake, cx); + // Decision #60: the output's own-line directive is a quote at publish. + view.update_in(cx, |v, window, cx| { + let text = + format!("Tides\n\n[TK]write a bridge[=]A bridge.\n\n![[{SAMPLE_QUOTE_ID}]][/TK]\n"); + v.editor.update(cx, |s, cx| s.set_value(text, window, cx)); + }); + let w = view.read_with(cx, |v, cx| v.studio_publish_warning(cx)); + let w = w.expect("a warning"); + assert!(w.contains(&format!("![[{SAMPLE_QUOTE_ID}]]")), "{w}"); + assert!(w.contains("notifies the author"), "{w}"); + // Named by the instruction, it is a source the author asked for. + view.update_in(cx, |v, window, cx| { + let text = format!( + "Tides\n\n[TK]open with ![[{SAMPLE_QUOTE_ID}]][=]A bridge.\n\n![[{SAMPLE_QUOTE_ID}]][/TK]\n" + ); + v.editor.update(cx, |s, cx| s.set_value(text, window, cx)); + }); + let w = view.read_with(cx, |v, cx| v.studio_publish_warning(cx)); + assert_eq!(w, None); +} From d4848e0cd881948d40537ed9ee60360e6c31593b Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:09:10 -0700 Subject: [PATCH 04/10] Keep what a stock studio sends: full author, cited, sourceUrl, generated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - model::Author keeps every member it doesn't name (extra, flattened); wire::stock_row no longer narrows author_json to {name, url} (conformance §5.5). - StubOf and RemoteRef keep cited (protocol 0.3 §16.1; studio 0.41+). A stub_of sent to the server drops a cited that upstream's strict Citation would refuse (source, url and retrieved are required). - ReadingItem gains source_url and generated from listImportedReading. - Client-recorded provenance sends a remote source's origin (studio 0.41+). - Mentions with relation "source" (0.41) read as "drew on this". - e2e: saving settings leaves the keys Burrow doesn't own (links_*, auto_change_notes, …); a feed row's author comes through. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-ai/tests/prompts.rs | 3 + crates/blyg-app/src/ai/generate.rs | 20 ++++ crates/blyg-app/src/ai/view.rs | 1 + crates/blyg-app/src/composer/mention.rs | 4 + crates/blyg-app/src/fake.rs | 3 + crates/blyg-app/src/fake_reading.rs | 11 +++ crates/blyg-app/src/profiles/vm.rs | 5 + crates/blyg-app/src/reading/lineage.rs | 1 + crates/blyg-app/src/reading/lineage_vm.rs | 3 + crates/blyg-app/src/reading/list.rs | 2 + crates/blyg-app/src/reading/responses.rs | 3 + crates/blyg-app/src/reading/vm.rs | 8 ++ crates/blyg-app/src/studio/resolver.rs | 3 + crates/blyg-app/src/vm.rs | 1 + crates/blyg-core/src/api/mod.rs | 7 +- crates/blyg-core/src/api/public.rs | 2 + crates/blyg-core/src/api/wire.rs | 51 +++++++++- crates/blyg-core/src/lineage.rs | 3 + crates/blyg-core/src/model.rs | 54 ++++++++++- crates/blyg-core/src/profile.rs | 3 + crates/blyg-core/src/store/pick_tests.rs | 2 + .../blyg-core/src/store/read_state_tests.rs | 2 + crates/blyg-core/src/store/reading_tests.rs | 5 + crates/blyg-core/src/store/tests.rs | 1 + crates/blyg-core/src/sync/read_held_tests.rs | 2 + crates/blyg-core/src/tk.rs | 1 + crates/blyg-core/tests/e2e.rs | 97 +++++++++++++++++++ crates/blyg-core/tests/sync.rs | 3 + crates/blyg-core/tests/versions.rs | 3 +- 29 files changed, 299 insertions(+), 5 deletions(-) diff --git a/crates/blyg-ai/tests/prompts.rs b/crates/blyg-ai/tests/prompts.rs index 6d9aa20..ee43b67 100644 --- a/crates/blyg-ai/tests/prompts.rs +++ b/crates/blyg-ai/tests/prompts.rs @@ -224,6 +224,7 @@ fn continue_outline_and_reply_are_tk_scopes() { content_html: String::new(), author: Some(Author { name: Some("Sam".into()), + extra: Default::default(), url: None, }), page: Some("https://them.example/f/R1/".into()), @@ -235,6 +236,8 @@ fn continue_outline_and_reply_are_tk_scopes() { transclusions: vec![], pinned_version_retained: None, lineage_known: false, + source_url: None, + generated: None, }; let p = Scripted::new(&["Agreed, and…"]); let out = reply_draft(&p, &item, None, &CancelFlag::new(), &mut |_| {}).unwrap(); diff --git a/crates/blyg-app/src/ai/generate.rs b/crates/blyg-app/src/ai/generate.rs index 96f724f..5168132 100644 --- a/crates/blyg-app/src/ai/generate.rs +++ b/crates/blyg-app/src/ai/generate.rs @@ -73,6 +73,23 @@ pub fn resolve_source( }) } +/// A source's blyg when it is someone else's post (an imported one, not +/// this blyg's own): upstream records remote sources with their origin +/// (studio 0.41+). The same lookup order as `resolve_source`. +fn remote_origin(items: &[Item], reading: &[ReadingItem], id: &str) -> Option { + if items + .iter() + .any(|i| i.server_id.as_ref().is_some_and(|s| s.0 == id)) + { + return None; + } + reading + .iter() + .find(|r| r.remote_id == id) + .map(|r| r.origin.clone()) + .filter(|o| !o.is_empty()) +} + /// A fill/regenerate request, built on the UI thread and run elsewhere. #[derive(Debug, Clone)] pub struct FillJob { @@ -107,6 +124,7 @@ pub fn prepare_fill( .map(|id| ProvenanceSource { id: id.clone(), version: versions.iter().find(|(v, _)| v == id).and_then(|(_, v)| *v), + origin: remote_origin(items, reading, id), }) .collect(); Ok(FillJob { @@ -301,10 +319,12 @@ mod tests { ProvenanceSource { id: ID.into(), version: Some(2), + origin: None, }, ProvenanceSource { id: "zzzzzzzzzzzzzzzzzzzzzzzzzz".into(), version: None, + origin: None, }, ], ..prov("m2") diff --git a/crates/blyg-app/src/ai/view.rs b/crates/blyg-app/src/ai/view.rs index cf06f2b..f77944b 100644 --- a/crates/blyg-app/src/ai/view.rs +++ b/crates/blyg-app/src/ai/view.rs @@ -320,6 +320,7 @@ impl MainView { origin: item.origin.clone(), id: item.remote_id.clone(), version: item.version, + cited: None, }; let body = super::reading::vm::stub_body(&item.remote_id); let stub = match self.backend.create_stub(&of, &body) { diff --git a/crates/blyg-app/src/composer/mention.rs b/crates/blyg-app/src/composer/mention.rs index 63477ec..e6f8e93 100644 --- a/crates/blyg-app/src/composer/mention.rs +++ b/crates/blyg-app/src/composer/mention.rs @@ -549,6 +549,7 @@ mod tests { content_html: String::new(), author: Some(Author { name: Some(author.into()), + extra: Default::default(), url: None, }), page: None, @@ -560,6 +561,8 @@ mod tests { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } @@ -705,6 +708,7 @@ mod tests { origin: ADA.into(), id: "a1".into(), version: 1, + cited: None, }); let linked = own("As [Rue](https://rue.blyg.example.org/f/x) said."); let items = vec![reply, linked]; diff --git a/crates/blyg-app/src/fake.rs b/crates/blyg-app/src/fake.rs index e90ffb9..83ac6ab 100644 --- a/crates/blyg-app/src/fake.rs +++ b/crates/blyg-app/src/fake.rs @@ -49,6 +49,7 @@ fn sample_reading() -> ReadingItem { content_html: "

      A tide pool is a small ocean that forgets, twice a day, that it belongs to a larger one.

      \n".into(), author: Some(Author { name: Some("Shoreline Notes".into()), + extra: Default::default(), url: None, }), page: None, @@ -60,6 +61,8 @@ fn sample_reading() -> ReadingItem { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } diff --git a/crates/blyg-app/src/fake_reading.rs b/crates/blyg-app/src/fake_reading.rs index 6df9117..2b1c1c6 100644 --- a/crates/blyg-app/src/fake_reading.rs +++ b/crates/blyg-app/src/fake_reading.rs @@ -122,6 +122,7 @@ fn post( content_html: blyg_render::render_markdown(content), author: Some(Author { name: Some(author.into()), + extra: Default::default(), url: Some(sub.origin.clone()), }), page: Some(format!( @@ -136,6 +137,8 @@ fn post( forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } @@ -279,6 +282,7 @@ pub fn seed(now: DateTime) -> Seed { id: Some(LIN_BENCH.into()), version: Some(1), url: None, + cited: None, }); reply.transclusions = vec![TransclusionRef { id: LIN_BENCH.into(), @@ -330,6 +334,7 @@ pub fn seed(now: DateTime) -> Seed { origin: ADA.into(), id: ADA_TIDES.into(), version: 1, + cited: None, }); fork.transclusions = vec![TransclusionRef { id: KIT_TIDES.into(), @@ -412,11 +417,13 @@ pub fn seed(now: DateTime) -> Seed { id: Some(ADA_TIDES.into()), version: Some(2), url: None, + cited: None, }), forked_from: Some(RemoteRef { origin: RUE.into(), id: RUE_TRUST.into(), version: 3, + cited: None, }), transclusions: vec![TransclusionRef { id: ADA_TIDES.into(), @@ -498,6 +505,7 @@ pub fn seed(now: DateTime) -> Seed { source_version: Some(1), source_author: Some(Author { name: Some(name.into()), + extra: Default::default(), url: Some(origin.into()), }), first_seen: (now - ago).to_rfc3339(), @@ -585,6 +593,7 @@ pub fn seed(now: DateTime) -> Seed { content_html: blyg_render::render_markdown(kit_text), author: Some(Author { name: Some("Kit".into()), + extra: Default::default(), url: Some(KIT.into()), }), page: Some(format!("f/{KIT_TIDES}")), @@ -596,6 +605,8 @@ pub fn seed(now: DateTime) -> Seed { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, }; let public = vec![PublicItem { item: kit_item, diff --git a/crates/blyg-app/src/profiles/vm.rs b/crates/blyg-app/src/profiles/vm.rs index 175356b..27394bb 100644 --- a/crates/blyg-app/src/profiles/vm.rs +++ b/crates/blyg-app/src/profiles/vm.rs @@ -541,14 +541,18 @@ mod tests { id: Some("A".into()), version: Some(1), url: None, + cited: None, }), forked_from: Some(blyg_core::RemoteRef { origin: "https://rue.example.com/".into(), id: "R".into(), version: 3, + cited: None, }), transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, }; let mut held = item.clone(); held.origin = "https://ada.example.net/".into(); @@ -575,6 +579,7 @@ mod tests { origin: "https://lin.example.org/".into(), id: "L".into(), version: 2, + cited: None, }), transclusions: vec![], }; diff --git a/crates/blyg-app/src/reading/lineage.rs b/crates/blyg-app/src/reading/lineage.rs index 46147f2..4de1238 100644 --- a/crates/blyg-app/src/reading/lineage.rs +++ b/crates/blyg-app/src/reading/lineage.rs @@ -561,6 +561,7 @@ impl MainView { origin: item.origin.clone(), id: item.remote_id.clone(), version, + cited: None, }; let backend = self.backend.clone(); let task = cx.background_spawn(async move { backend.fork(&of) }); diff --git a/crates/blyg-app/src/reading/lineage_vm.rs b/crates/blyg-app/src/reading/lineage_vm.rs index 6cd27cc..aea94b2 100644 --- a/crates/blyg-app/src/reading/lineage_vm.rs +++ b/crates/blyg-app/src/reading/lineage_vm.rs @@ -877,12 +877,14 @@ mod tests { id: Some("T".into()), version: Some(1), url: None, + cited: None, }); let mut fork = mine("F1", "Forked"); fork.forked_from = Some(RemoteRef { origin: ADA.into(), id: "T".into(), version: 1, + cited: None, }); let m = Mention { id: "m".into(), @@ -921,6 +923,7 @@ mod tests { origin: BO.into(), id: "K".into(), version: 2, + cited: None, }); centre.transclusions = vec![TransclusionRef { id: "Z".into(), diff --git a/crates/blyg-app/src/reading/list.rs b/crates/blyg-app/src/reading/list.rs index 3fe2a16..4a214a5 100644 --- a/crates/blyg-app/src/reading/list.rs +++ b/crates/blyg-app/src/reading/list.rs @@ -457,6 +457,7 @@ impl MainView { origin: item.origin.clone(), id: item.remote_id.clone(), version: v.version, + cited: None, }; let backend = self.backend.clone(); let task = cx.background_spawn(async move { backend.fork(&of) }); @@ -562,6 +563,7 @@ impl MainView { origin: item.origin.clone(), id: item.remote_id.clone(), version: item.version, + cited: None, }; match self.backend.create_stub(&of, &body) { Ok(id) => { diff --git a/crates/blyg-app/src/reading/responses.rs b/crates/blyg-app/src/reading/responses.rs index 91a75a9..a7d7855 100644 --- a/crates/blyg-app/src/reading/responses.rs +++ b/crates/blyg-app/src/reading/responses.rs @@ -61,6 +61,8 @@ fn mention_what(rel: Option<&str>) -> &'static str { Some("stub") => "stubbed this", Some("fork") => "forked this", Some("transclusion") => "quoted this", + // Studio 0.41+: generated text drew on this post. + Some("source") => "drew on this", _ => "mentioned this", } } @@ -258,6 +260,7 @@ mod tests { source_version: Some(1), source_author: Some(blyg_core::Author { name: Some("Rue".into()), + extra: Default::default(), url: None, }), first_seen: t.clone(), diff --git a/crates/blyg-app/src/reading/vm.rs b/crates/blyg-app/src/reading/vm.rs index 1065d12..bd7c57d 100644 --- a/crates/blyg-app/src/reading/vm.rs +++ b/crates/blyg-app/src/reading/vm.rs @@ -618,6 +618,8 @@ pub fn relation_label(r: Option<&str>) -> &'static str { Some("stub") => "replied", Some("transclusion") => "quoted", Some("fork") => "forked", + // Studio 0.41+: the post drew on yours for generated text. + Some("source") => "drew on it", _ => "mentioned", } } @@ -1017,6 +1019,7 @@ mod tests { content_html: "

      On kites

      Wind & string.

      ".into(), author: Some(blyg_core::Author { name: Some("Kit Moreno".into()), + extra: Default::default(), url: None, }), page: None, @@ -1028,6 +1031,8 @@ mod tests { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, }; for q in [ "", @@ -1087,6 +1092,8 @@ mod tests { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, }; let all = blyg_core::PickQuery::default(); let q = blyg_core::pick::search_in( @@ -1183,6 +1190,7 @@ mod tests { id: Some(ID.into()), version: Some(1), url: None, + cited: None, }); assert_eq!(post_title(&r), "In response to rue.blyg.example.com"); r.stub_of = None; diff --git a/crates/blyg-app/src/studio/resolver.rs b/crates/blyg-app/src/studio/resolver.rs index cbfd8fb..e6bb249 100644 --- a/crates/blyg-app/src/studio/resolver.rs +++ b/crates/blyg-app/src/studio/resolver.rs @@ -258,6 +258,7 @@ mod tests { content_html: "

      Tides keep time.

      \n".into(), author: Some(Author { name: Some("Field Notes".into()), + extra: Default::default(), url: None, }), page: None, @@ -269,6 +270,8 @@ mod tests { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } diff --git a/crates/blyg-app/src/vm.rs b/crates/blyg-app/src/vm.rs index 078b7c3..05c97ed 100644 --- a/crates/blyg-app/src/vm.rs +++ b/crates/blyg-app/src/vm.rs @@ -905,6 +905,7 @@ mod tests { origin: "https://rue.blyg.example.com/".into(), id: id.into(), version: 2, + cited: None, }); assert_eq!(item_title(&it), "In response to rue.blyg.example.com"); } diff --git a/crates/blyg-core/src/api/mod.rs b/crates/blyg-core/src/api/mod.rs index 092eada..ccc2af1 100644 --- a/crates/blyg-core/src/api/mod.rs +++ b/crates/blyg-core/src/api/mod.rs @@ -319,7 +319,12 @@ impl Api { ) -> Result { let mut body = json!({ "mode": "blank", "content_md": content_md, "kind": kind_str(kind) }); if let Some(s) = stub_of { - body["stub_of"] = serde_json::to_value(s).unwrap_or(Value::Null); + let mut s = s.clone(); + // Upstream's `Citation` needs source, url and retrieved (strict). + if s.cited.as_ref().is_some_and(|c| !c.complete()) { + s.cited = None; + } + body["stub_of"] = serde_json::to_value(&s).unwrap_or(Value::Null); } if let Some(p) = provenance { body["provenance"] = provenance_body(p); diff --git a/crates/blyg-core/src/api/public.rs b/crates/blyg-core/src/api/public.rs index 30da47b..c211418 100644 --- a/crates/blyg-core/src/api/public.rs +++ b/crates/blyg-core/src/api/public.rs @@ -186,6 +186,8 @@ impl ItemDoc { transclusions: self.transclusions.clone(), // The post's own document: what it lacks, it doesn't have. lineage_known: true, + source_url: None, + generated: None, } } diff --git a/crates/blyg-core/src/api/wire.rs b/crates/blyg-core/src/api/wire.rs index f5472f4..9d7ec0c 100644 --- a/crates/blyg-core/src/api/wire.rs +++ b/crates/blyg-core/src/api/wire.rs @@ -630,7 +630,8 @@ pub fn stock_row( .unwrap_or(Value::Null) }; let author = match parse("author_json") { - a @ Value::Object(_) => serde_json::json!({ "name": a.get("name"), "url": a.get("url") }), + // The whole object (protocol §5.5): `Author` keeps what it doesn't name. + a @ Value::Object(_) => a, _ => Value::Null, }; let transclusions = match parse("transclusions_json") { @@ -821,6 +822,11 @@ mod reading_page_tests { assert_eq!(a.stub_of.as_ref().unwrap().id.as_deref(), Some("X")); assert_eq!(a.transclusions.len(), 1); assert!(a.lineage_known); + assert_eq!( + a.source_url.as_deref(), + Some("https://ada.example.com/t/R2/") + ); + assert_eq!(a.generated.as_ref().map(Vec::len), Some(1)); let w = &p.items[1]; assert_eq!(w.state, "tombstone"); assert_eq!(w.pinned_version_retained, Some(1)); @@ -862,3 +868,46 @@ mod reading_page_tests { assert!(r.is_err()); } } + +#[cfg(test)] +mod author_tests { + use super::*; + + #[test] + fn a_stock_row_keeps_the_whole_author() { + let raw = serde_json::json!({ + "subscription_id": "S", "remote_id": "R", "kind": "fragment", "state": "current", + "version": 1, "created": null, "updated": null, "observed_at": "2030-01-01T00:00:00Z", + "content_md": "x", "content_html": "

      x

      ", + "author_json": r#"{"name":"Ada","url":"https://ada.example.com/","avatar":"https://ada.example.com/a.png","bio":"Writes."}"#, + "transclusions_json": null, "l0": false, "pinned_version_retained": null, "page": null, + }); + let r = stock_row(&raw, "N", "https://ada.example.com/", None, &[], None).unwrap(); + let a = r.author.unwrap(); + assert_eq!(a.name.as_deref(), Some("Ada")); + assert_eq!(a.extra["avatar"], "https://ada.example.com/a.png"); + assert_eq!(a.extra["bio"], "Writes."); + // …and it survives the store's JSON round trip. + let back: Author = serde_json::from_value(serde_json::to_value(&a).unwrap()).unwrap(); + assert_eq!(back, a); + } + + #[test] + fn a_stub_of_keeps_its_citation() { + let p: ReadingPage = serde_json::from_str( + r#"{"items":[{"subscriptionId":"S","remoteId":"R","kind":"thread", + "stubOf":{"origin":"https://bo.example.com/","id":"X","version":2, + "cited":{"source":"Bo","url":"https://bo.example.com/t/X/","retrieved":"2030-01-01T00:00:00Z"}}, + "forkedFrom":{"origin":"https://bo.example.com/","id":"Y","version":1, + "cited":{"source":"Bo","url":"https://bo.example.com/f/Y/","retrieved":"2030-01-01T00:00:00Z"}}}], + "next":null,"limit":1}"#, + ) + .unwrap(); + let it = &p.items[0]; + let c = it.stub_of.as_ref().unwrap().cited.as_ref().unwrap(); + assert!(c.complete()); + assert_eq!(c.source.as_deref(), Some("Bo")); + let f = it.forked_from.as_ref().unwrap().cited.as_ref().unwrap(); + assert_eq!(f.url.as_deref(), Some("https://bo.example.com/f/Y/")); + } +} diff --git a/crates/blyg-core/src/lineage.rs b/crates/blyg-core/src/lineage.rs index d38291d..36b8f1c 100644 --- a/crates/blyg-core/src/lineage.rs +++ b/crates/blyg-core/src/lineage.rs @@ -603,6 +603,7 @@ mod tests { id: Some(FRAG.into()), version: Some(1), url: None, + cited: None, }); a.transclusions = vec![quote(FRAG, Some(OURS), true)]; let own = [mine(FRAG, "Answer me this.")]; @@ -632,6 +633,7 @@ mod tests { origin: OURS.into(), id: FRAG.into(), version: 1, + cited: None, }); d.transclusions = vec![quote(FRAG, Some(OURS), false)]; d.stub_of = Some(StubOf { @@ -656,6 +658,7 @@ mod tests { id: Some(FRAG.into()), version: Some(1), url: None, + cited: None, }); let mentions = [ mention(THEM, E, "verified", false), diff --git a/crates/blyg-core/src/model.rs b/crates/blyg-core/src/model.rs index 9900b1f..034dca9 100644 --- a/crates/blyg-core/src/model.rs +++ b/crates/blyg-core/src/model.rs @@ -73,6 +73,14 @@ pub struct RemoteRef { pub origin: String, pub id: String, pub version: u32, + /// The citation frozen with the reference (protocol 0.3 §16.1; remote + /// references only, studio 0.41+). Remote data: shown as plain text. + #[serde( + default, + deserialize_with = "lenient", + skip_serializing_if = "Option::is_none" + )] + pub cited: Option, } /// What the UI renders. Content is the local working copy. @@ -250,10 +258,17 @@ pub struct SubscribePreview { pub site_mismatch: Option, } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +/// A post's `author` object (protocol §5.5). `name` and `url` are what the +/// app reads; every other member (`avatar`, `bio`, `links`, …) is kept as +/// served in `extra`, so nothing the origin said is lost on the way through. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Author { + #[serde(default)] pub name: Option, + #[serde(default)] pub url: Option, + #[serde(flatten)] + pub extra: serde_json::Map, } /// One item from a subscription (imported_items), for the reading view. @@ -325,6 +340,22 @@ pub struct ReadingItem { /// for them (`Engine::fill_lineage`). #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub lineage_known: bool, + /// The page the post came from, as the server resolved it (`sourceUrl`, + /// studio 0.40+): the canonical page, else the item's own URL. + #[serde( + default, + deserialize_with = "lenient", + skip_serializing_if = "Option::is_none" + )] + pub source_url: Option, + /// The origin's generation provenance (`generated`, studio 0.40+): one + /// `{sources, model, at}` per generated scope. Remote data, kept as served. + #[serde( + default, + deserialize_with = "lenient", + skip_serializing_if = "Option::is_none" + )] + pub generated: Option>, } // --- profiles --- @@ -342,6 +373,13 @@ pub struct StubOf { pub version: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, + /// The citation frozen with the reply (protocol 0.3 §16.1). + #[serde( + default, + deserialize_with = "lenient", + skip_serializing_if = "Option::is_none" + )] + pub cited: Option, } impl StubOf { @@ -421,6 +459,11 @@ impl Cited { .map(|s| s.trim()) .find(|s| !s.is_empty()) } + + /// Has what upstream's strict `Citation` requires: source, url, retrieved. + pub fn complete(&self) -> bool { + self.source.is_some() && self.url.is_some() && self.retrieved.is_some() + } } /// A post's lineage: what it stubs, forks and quotes. On a reading item the @@ -785,6 +828,8 @@ pub fn own_responses(items: &[Item], own_origin: &str, origin: &str, id: &str) - forked_from: it.forked_from.clone(), transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, }; out.push(( it.updated.clone(), @@ -1028,12 +1073,16 @@ pub struct Folder { // --- end reader folders --- /// A published fragment a generated TK scope drew on (`![[id]]` inside it). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct ProvenanceSource { pub id: String, /// The version used; `None` lets the server record the current one. #[serde(default, skip_serializing_if = "Option::is_none")] pub version: Option, + /// The source's blyg, for a remote source (an imported post; studio + /// 0.41+, protocol §16.3). `None` for this blyg's own. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub origin: Option, } /// Who generated one TK scope's output, recorded when the app generates it @@ -1390,6 +1439,7 @@ mod lineage_tests { origin: "https://ada.example/".into(), id: target.into(), version: 4, + cited: None, }); let draft = item("D", &format!("![[{target}]]"), Status::Draft, 0); let other = item("O", "nothing here", Status::Public, 3); diff --git a/crates/blyg-core/src/profile.rs b/crates/blyg-core/src/profile.rs index b2dc583..21a73b5 100644 --- a/crates/blyg-core/src/profile.rs +++ b/crates/blyg-core/src/profile.rs @@ -1105,6 +1105,7 @@ mod tests { origin: "https://rue.example.com/".into(), id: "R".into(), version: 3, + cited: None, }); // Someone else's post: not theirs, not counted. let mut c = held("C", "https://other.example.com/"); @@ -1155,12 +1156,14 @@ mod tests { origin: "https://ada.example.net/".into(), id: q.into(), version: 1, + cited: None, }); let mut b = item("b", Status::Public, "fork"); b.forked_from = Some(RemoteRef { origin: "https://rue.example.com/".into(), id: "R".into(), version: 3, + cited: None, }); let mut c = item("c", Status::Draft, &format!("![[{q}]]")); c.stub_of = a.stub_of.clone(); diff --git a/crates/blyg-core/src/store/pick_tests.rs b/crates/blyg-core/src/store/pick_tests.rs index 1dbcc7e..4fe3804 100644 --- a/crates/blyg-core/src/store/pick_tests.rs +++ b/crates/blyg-core/src/store/pick_tests.rs @@ -48,6 +48,8 @@ fn ri(sub: &str, rid: &str, updated: &str, body: &str) -> ReadingItem { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } diff --git a/crates/blyg-core/src/store/read_state_tests.rs b/crates/blyg-core/src/store/read_state_tests.rs index 2e8a2b2..f6df265 100644 --- a/crates/blyg-core/src/store/read_state_tests.rs +++ b/crates/blyg-core/src/store/read_state_tests.rs @@ -36,6 +36,8 @@ fn ri(sub: &str, rid: &str, page: Option<&str>, version: u32, observed: &str) -> forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } diff --git a/crates/blyg-core/src/store/reading_tests.rs b/crates/blyg-core/src/store/reading_tests.rs index 1baf29c..efd516a 100644 --- a/crates/blyg-core/src/store/reading_tests.rs +++ b/crates/blyg-core/src/store/reading_tests.rs @@ -39,6 +39,8 @@ fn ri( forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } @@ -663,12 +665,14 @@ fn responses_are_found_by_origin_and_id() { id: Some("T1".into()), version: Some(1), url: None, + cited: None, }); let mut fork = responder("sc", "F1", "https://cy.example/", "2030-01-02T00:00:00Z"); fork.forked_from = Some(RemoteRef { origin: "https://ada.example/".into(), id: "T1".into(), version: 1, + cited: None, }); // Same id on another origin: not a response. let mut other = responder("sd", "O1", "https://dee.example/", "2030-01-05T00:00:00Z"); @@ -677,6 +681,7 @@ fn responses_are_found_by_origin_and_id() { id: Some("T1".into()), version: None, url: None, + cited: None, }); // A quote without an origin is from its own blyg. let mut own = responder("sa", "A2", "https://ada.example/", "2030-01-06T00:00:00Z"); diff --git a/crates/blyg-core/src/store/tests.rs b/crates/blyg-core/src/store/tests.rs index 929ad12..5f928df 100644 --- a/crates/blyg-core/src/store/tests.rs +++ b/crates/blyg-core/src/store/tests.rs @@ -309,6 +309,7 @@ fn a_stub_is_a_draft_thread_with_stub_of() { origin: "https://rue.blyg.example.com/".into(), id: "01K2RUE0TRUST0000000000001".into(), version: 5, + cited: None, }; let id = s .create_stub(&of, "![[01K2RUE0TRUST0000000000001]]\n\n", 0) diff --git a/crates/blyg-core/src/sync/read_held_tests.rs b/crates/blyg-core/src/sync/read_held_tests.rs index 8d43436..626f0cf 100644 --- a/crates/blyg-core/src/sync/read_held_tests.rs +++ b/crates/blyg-core/src/sync/read_held_tests.rs @@ -32,6 +32,8 @@ fn row(rid: &str) -> ReadingItem { forked_from: None, transclusions: vec![], lineage_known: false, + source_url: None, + generated: None, } } diff --git a/crates/blyg-core/src/tk.rs b/crates/blyg-core/src/tk.rs index bd45ffc..3914c33 100644 --- a/crates/blyg-core/src/tk.rs +++ b/crates/blyg-core/src/tk.rs @@ -266,6 +266,7 @@ mod tests { sources: vec![ProvenanceSource { id: "0123456789abcdefghjkmnpqrs".into(), version: None, + origin: None, }], at: None, }); diff --git a/crates/blyg-core/tests/e2e.rs b/crates/blyg-core/tests/e2e.rs index d8d3464..78c796d 100644 --- a/crates/blyg-core/tests/e2e.rs +++ b/crates/blyg-core/tests/e2e.rs @@ -1262,6 +1262,7 @@ fn prov(model: &str, sources: &[&str]) -> Option { .map(|s| ProvenanceSource { id: s.to_string(), version: None, + origin: None, }) .collect(), at: None, @@ -2787,6 +2788,7 @@ fn a_stub_made_in_the_app_publishes_with_the_right_quote() { origin: it.origin.clone(), id: rid.clone(), version: it.version, + cited: None, }; // Whole post, as Reply opens it (the server's own prefill since 0.31). @@ -3484,6 +3486,7 @@ fn a_stubs_lineage_comes_with_its_feed_row() { origin: held.origin.clone(), id: mine.clone(), version: held.version, + cited: None, }; let stub = other .create_stub(&of, &format!("![[{mine}]]\n\n{t} a reply.")) @@ -3571,3 +3574,97 @@ fn a_refused_token_stops_sync_and_keeps_the_change() { again.delete_draft(&id).ok(); again.sync_now().ok(); } + +#[test] +#[ignore = "needs a local Worker: scripts/e2e-local.sh"] +fn saving_settings_leaves_the_keys_burrow_doesnt_own() { + // A studio 0.46 has many settings Burrow doesn't know (links_*, font, + // feed_order, …). Burrow's PATCH sends only its own, so the rest stay. + let e = e2e(); + let dir = tempfile::tempdir().unwrap(); + let b = manual(dir.path()); + let before = b.settings().unwrap(); + let _restore = RestoreSettings(&b, before.clone()); + let (st, raw0) = owner("GET", &e.url, "/api/settings", None); + assert_eq!(st, 200, "{raw0}"); + // Set a few of them the way the web studio would, where the server has them. + let mut theirs = serde_json::Map::new(); + if raw0.get("links_position").is_some() { + let flip = if raw0["links_position"] == "right" { + "bottom" + } else { + "right" + }; + theirs.insert("links_position".into(), json!(flip)); + } + if let Some(on) = raw0.get("links_blygger_org").and_then(Value::as_bool) { + theirs.insert("links_blygger_org".into(), json!(!on)); + } + if let Some(on) = raw0.get("auto_change_notes").and_then(Value::as_bool) { + theirs.insert("auto_change_notes".into(), json!(!on)); + } + if theirs.is_empty() { + eprintln!("skipped: this server has no settings beyond Burrow's"); + return; + } + let (st, v) = owner( + "PATCH", + &e.url, + "/api/settings", + Some(Value::Object(theirs.clone())), + ); + assert_eq!(st, 200, "{v}"); + let (_, raw1) = owner("GET", &e.url, "/api/settings", None); + + // Parsing tolerates every extra key, and a save changes only its own. + let mine = b.settings().unwrap(); + b.save_settings(&Settings { + highlight_generated_default: Some(!mine.highlight_generated_default.unwrap_or(false)), + ..mine.clone() + }) + .unwrap(); + let (_, raw2) = owner("GET", &e.url, "/api/settings", None); + for (k, v) in raw1.as_object().unwrap() { + if k == "highlight_generated_default" { + continue; + } + assert_eq!(&raw2[k], v, "{k} was clobbered"); + } + assert_ne!( + raw2["highlight_generated_default"], + raw1["highlight_generated_default"] + ); + // Put the server's own keys back. + let undo: serde_json::Map = theirs + .keys() + .map(|k| (k.clone(), raw0[k].clone())) + .collect(); + let (st, v) = owner("PATCH", &e.url, "/api/settings", Some(Value::Object(undo))); + assert_eq!(st, 200, "{v}"); +} + +#[test] +#[ignore = "needs a local Worker: scripts/e2e-local.sh"] +fn a_feed_rows_author_comes_through() { + // The author the source blyg names reaches the reading row. + let src_url = url_b(); + let (d1, d2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()); + let me = manual(d1.path()); + let source = backend_at(d2.path(), &src_url, cred_b(), false); + let was = source.settings().unwrap(); + let _restore = RestoreSettings(&source, was.clone()); + let t = tag("author"); + source + .save_settings(&Settings { + author_name: Some(format!("Writer {t}")), + ..was.clone() + }) + .unwrap(); + let (_, rid) = published(&source, Kind::Fragment, &format!("{t} by me"), None); + let sub = me.subscribe(&src_url, Some("Authored")).unwrap(); + let _unsub = Unsub(&me, sub.id.clone()); + sync_until_held(&me, &[&rid]); + let r = reading_item(&me, &rid).unwrap(); + let a = r.author.expect("the row's author"); + assert_eq!(a.name.as_deref(), Some(format!("Writer {t}").as_str())); +} diff --git a/crates/blyg-core/tests/sync.rs b/crates/blyg-core/tests/sync.rs index a5e0d55..d665b80 100644 --- a/crates/blyg-core/tests/sync.rs +++ b/crates/blyg-core/tests/sync.rs @@ -612,6 +612,7 @@ fn fork_and_show_responses() { origin: "https://else.example/".into(), id: "X".repeat(26), version: 2, + cited: None, }; let id = b.fork(&of).unwrap(); let it = b.item(&id).unwrap(); @@ -641,6 +642,7 @@ fn responses_follow_the_site_default() { origin: "https://else.example/".into(), id: "X".repeat(26), version: 1, + cited: None, }) .unwrap(); let sid = b.item(&id).unwrap().server_id.unwrap().0; @@ -682,6 +684,7 @@ fn a_site_default_change_reaches_items_on_the_next_pull() { origin: "https://else.example/".into(), id: "X".repeat(26), version: 1, + cited: None, }) .unwrap(); assert!(!b.item(&id).unwrap().show_responses); diff --git a/crates/blyg-core/tests/versions.rs b/crates/blyg-core/tests/versions.rs index 75e1a17..8cb48c7 100644 --- a/crates/blyg-core/tests/versions.rs +++ b/crates/blyg-core/tests/versions.rs @@ -297,7 +297,8 @@ fn lineage_comes_from_the_item_document_leniently() { Some(RemoteRef { origin: "https://rue.example.com/".into(), id: "R".into(), - version: 3 + version: 3, + cited: None, }) ); let ids: Vec<(&str, Option<&str>)> = l From fd8dfd6ec7cac2faef6748aecda0e8788caa6702 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:14:00 -0700 Subject: [PATCH 05/10] tk::parse: TK tokens in code are text anywhere in a scope (studio 0.41.1) Like the studio's parseScopes since #68, every token ([TK], [=], [/TK]) is looked for outside code, inline or fenced, not only the opening one: a [/TK] quoted in code inside a scope is no early close, and a [TK] there is no nesting. blyg-core now uses blyg-render's code ranges (exposed as blyg_render::code_ranges), so it becomes a normal dependency. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-core/Cargo.toml | 4 +-- crates/blyg-core/src/tk.rs | 56 ++++++++++++++++++++++++++++------- crates/blyg-render/src/lib.rs | 7 +++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/crates/blyg-core/Cargo.toml b/crates/blyg-core/Cargo.toml index fe101e7..fe7d128 100644 --- a/crates/blyg-core/Cargo.toml +++ b/crates/blyg-core/Cargo.toml @@ -22,8 +22,8 @@ rusqlite = { version = "0.40", features = ["bundled"] } toml = "1" url = "2" keyring = { version = "3.6", features = ["apple-native", "windows-native"], optional = true } +# The studio's code ranges (`tk::parse`: TK tokens in code are text). +blyg-render = { path = "../blyg-render" } [dev-dependencies] tempfile = "3" -# The stub e2e builds its quotes with the stub editor's own functions. -blyg-render = { path = "../blyg-render" } diff --git a/crates/blyg-core/src/tk.rs b/crates/blyg-core/src/tk.rs index 3914c33..e6973de 100644 --- a/crates/blyg-core/src/tk.rs +++ b/crates/blyg-core/src/tk.rs @@ -26,25 +26,40 @@ pub struct Scope { } /// The scopes of `md`, or `None` when the grammar is malformed (unterminated -/// or nested), exactly where the Worker's `tk.ts::parseScopes` reports errors. +/// or nested), exactly where the studio's `tk.ts::parseScopes` reports errors. +/// Every token (`[TK]`, `[=]`, `[/TK]`) is looked for outside code (studio +/// 0.41.1, #68): one written in code, inline or fenced, is an example of the +/// grammar, not grammar, wherever it sits, inside a scope included. pub fn parse(md: &str) -> Option> { + let code = if md.contains("[TK]") { + blyg_render::code_ranges(md) + } else { + Vec::new() + }; + let in_code = |at: usize| code.iter().any(|&(s, e)| at >= s && at < e); + let find = |token: &str, from: usize| -> Option { + let mut from = from; + loop { + let at = from + md.get(from..)?.find(token)?; + if !in_code(at) { + return Some(at); + } + from = at + token.len(); + } + }; let mut scopes = Vec::new(); let mut i = 0; let mut ok = true; - while let Some(rel) = md[i..].find("[TK]") { - let tk = i + rel; - let close_rel = md[tk + 4..].find("[/TK]")?; // unterminated - let close = tk + 4 + close_rel; - if md[tk + 4..close].contains("[TK]") { + while let Some(tk) = find("[TK]", i) { + let close = find("[/TK]", tk + 4)?; // unterminated + if find("[TK]", tk + 4).is_some_and(|n| n < close) { ok = false; i = close + 5; continue; } - let body = &md[tk + 4..close]; - let (instruction, output) = match body.find("[=]") { - Some(eq) => (&body[..eq], Some(body[eq + 3..].to_string())), - None => (body, None), - }; + let eq = find("[=]", tk + 4).filter(|&e| e < close); + let instruction = &md[tk + 4..eq.unwrap_or(close)]; + let output = eq.map(|e| md[e + 3..close].to_string()); let end = close + 5; scopes.push(Scope { start: tk, @@ -189,6 +204,25 @@ fn shares_a_word(a: &str, b: &str) -> bool { mod tests { use super::*; + #[test] + fn tokens_in_code_are_text_even_inside_a_scope() { + // Studio 0.41.1 (#68): `[TK]`, `[=]` and `[/TK]` in code are text. + let s = parse("[TK]explain `[/TK]` and `[=]`[=]out `[TK]` here[/TK]").unwrap(); + assert_eq!(s.len(), 1); + assert_eq!(s[0].instruction, "explain `[/TK]` and `[=]`"); + assert_eq!(s[0].output.as_deref(), Some("out `[TK]` here")); + // A fenced block inside a scope, holding a whole example scope. + let md = "[TK]summarise\n\n```\n[TK]x[=]y[/TK]\n```\n\n[=]done[/TK]"; + let s = parse(md).unwrap(); + assert_eq!(s.len(), 1); + assert_eq!(s[0].output.as_deref(), Some("done")); + // Outside any scope, an inline example is no scope at all. + assert_eq!(parse("write `[TK]…[/TK]` to ask").unwrap().len(), 0); + // Still malformed outside code. + assert!(parse("[TK]a [TK]b[/TK]").is_none(), "nested"); + assert!(parse("[TK]open `[/TK]`").is_none(), "unterminated"); + } + fn p(model: &str) -> Option { Some(ScopeProvenance { model: model.into(), diff --git a/crates/blyg-render/src/lib.rs b/crates/blyg-render/src/lib.rs index 6b1e3b4..80de997 100644 --- a/crates/blyg-render/src/lib.rs +++ b/crates/blyg-render/src/lib.rs @@ -49,6 +49,13 @@ pub use util::escape_html; /// Crockford base32 as blyg ids use it: lowercase, no i/l/o/u. pub const ID_ALPHABET: &str = "0123456789abcdefghjkmnpqrstvwxyz"; +/// The studio's `codeRanges`: `[start, end)` byte offsets of `text` that are +/// code (fenced, indented or inline), where `![[id]]`, `[[id]]` and the TK +/// tokens are inert text. +pub fn code_ranges(text: &str) -> Vec<(usize, usize)> { + markdown::code_ranges(text) +} + /// The kind of item being rendered. Only threads resolve quotes; in a /// fragment a `![[id]]` line is literal text, as the Worker publishes it. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] From b29bd5fd28589313871b0f64dd5d7f6ae47b020d Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:20:11 -0700 Subject: [PATCH 06/10] Docs and app text: a stock studio is fully supported - docs/SERVER.md rewritten around stock blygger-studio: what each version brings, 0.40+'s listImportedReading shapes, signing in (no owner token on a stock studio; a refused credential stops sync and asks to sign in), read state as upstream's (reading:state is final), and the old fork's extensions as legacy. - SPEC.md and CORE.md: the reading feed, read state and 401 handling as they are now; e2e last green against stock 0.46.1. - The sign-in sheet no longer points at a server's owner token. The limited-server sheet and About now speak of a studio older than 0.40 ("Full reading feed" replaces "Server extensions"). - reading:state is no longer called provisional. - Site FAQ and AI page: stock is fully supported; disclosure needs 0.28. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-app/src/about/info.rs | 10 +- crates/blyg-app/src/about/mod.rs | 4 +- crates/blyg-app/src/app.rs | 12 +- crates/blyg-app/src/server_notice.rs | 15 +- crates/blyg-core/src/api/auth.rs | 5 +- crates/blyg-core/src/api/oauth.rs | 2 +- docs/CORE.md | 11 +- docs/SERVER.md | 249 ++++++++++++++------------- docs/SPEC.md | 57 +++--- site/src/ai.md | 4 +- site/src/faq.md | 14 +- 11 files changed, 202 insertions(+), 181 deletions(-) diff --git a/crates/blyg-app/src/about/info.rs b/crates/blyg-app/src/about/info.rs index bf66f55..1b82816 100644 --- a/crates/blyg-app/src/about/info.rs +++ b/crates/blyg-app/src/about/info.rs @@ -158,18 +158,18 @@ impl ConnectionInfo { } } - /// Connected to a server without Burrow's extensions (a stock - /// blygger-studio): the About window says what that limits. + /// Connected to a server without `GET /api/reading/imported` (a studio + /// older than 0.40): the About window says what that limits. pub fn limited(&self) -> bool { matches!(self, ConnectionInfo::Live { caps, .. } if caps.iter().any(|c| c.label == EXTENSIONS && !c.on)) } } -const EXTENSIONS: &str = "Server extensions"; +const EXTENSIONS: &str = "Full reading feed"; /// The capabilities, in display order, from what the backend recorded: -/// the docs/SERVER.md extensions at all, read state across Macs, and AI +/// the full reading feed (studio 0.40+, or the old fork), read state across Macs, and AI /// disclosure for text generated in the app. pub fn capabilities(extensions: bool, read_sync: bool, provenance: bool) -> Vec { vec![ @@ -363,7 +363,7 @@ Auto-update: notify Last check: 2026-09-21 15:13 UTC Update: up to date Blyg: blyg.example.com -Server extensions: yes +Full reading feed: yes Read-state sync: no AI disclosure: yes "; diff --git a/crates/blyg-app/src/about/mod.rs b/crates/blyg-app/src/about/mod.rs index 8488f49..a521ee4 100644 --- a/crates/blyg-app/src/about/mod.rs +++ b/crates/blyg-app/src/about/mod.rs @@ -496,7 +496,7 @@ impl Render for AboutView { .child(section("CONNECTION")) .child(row("Blyg", text(r.connection.summary()))) .children(caps) - // A stock server: what that limits (as the sheet said). + // A studio older than 0.40: what that limits (as the sheet said). .when(r.connection.limited(), |d| { d.child( div() @@ -504,7 +504,7 @@ impl Render for AboutView { .pt(px(4.)) .text_color(p.muted) .line_height(relative(1.4)) - .child("Stock blygger-studio. Limited here:") + .child("Older blygger-studio. Limited here:") .children( crate::app::server_notice::LIMITS .iter() diff --git a/crates/blyg-app/src/app.rs b/crates/blyg-app/src/app.rs index c38f8f2..51f872a 100644 --- a/crates/blyg-app/src/app.rs +++ b/crates/blyg-app/src/app.rs @@ -761,10 +761,9 @@ impl MainView { }; match (secret.is_empty(), by_password) { (true, true) => Err("Type your blyg's studio password".to_string()), - (true, false) => Err( - "Paste an API token: make one in Studio → More → Client access, or use your server's owner token" - .to_string(), - ), + (true, false) => { + Err("Paste an API token: make one in Studio → More → Client access".to_string()) + } (false, true) => Ok((u, Credential::Password(secret))), (false, false) => Ok((u, Credential::Token(secret))), } @@ -844,7 +843,7 @@ impl MainView { Ok(_) => { // A studio older than 0.28: the password works. v.set_connect_way(ConnectWay::Password, window, cx); - "This blyg has no browser sign-in (its studio is older than 0.28). Use its studio password, or an owner token.".to_string() + "This blyg has no browser sign-in (its studio is older than 0.28). Use its studio password.".to_string() } Err(CoreError::Offline) => { "Couldn't reach that address. Check the URL and your connection." @@ -3121,8 +3120,7 @@ impl MainView { "Make a token in your blyg's Studio → More → Client access: choose REST \ API and every permission (read, draft, publish, manage, and reading:state \ where offered). It lasts \ - 30 days. A server with Burrow's extensions can use its owner token \ - here instead. Either is kept in your macOS Keychain." + 30 days. It is kept in your macOS Keychain." } ConnectWay::Password => { "The password you sign in to the studio with. It works with any \ diff --git a/crates/blyg-app/src/server_notice.rs b/crates/blyg-app/src/server_notice.rs index 25815c6..feed93a 100644 --- a/crates/blyg-app/src/server_notice.rs +++ b/crates/blyg-app/src/server_notice.rs @@ -5,9 +5,11 @@ //! (see `CoreError::ServerOutdated`). A toast when sync first notices, then a //! status-bar notice for as long as it stays true. //! -//! The "this blyg runs stock blygger-studio" sheet: once per local database, -//! when a reading pull first finds a server without the extensions in -//! docs/SERVER.md. It says what works and what's limited, and why. +//! The "this blyg's studio is older than 0.40" sheet: once per local +//! database, when a reading pull first finds a server without +//! `GET /api/reading/imported` (studio 0.40+, or the old Worker fork's +//! extension 3). It says what works and what's limited, and why. A stock +//! studio 0.40 or later is fully supported and never sees it. //! //! A child module of `app` (like the update notice) so it can use //! `MainView`'s toast and sheet. @@ -29,12 +31,13 @@ pub const OUTDATED_TOAST_SUB: &str = pub const OUTDATED_NOTICE: &str = "Server needs updating · nothing syncs"; /// The stock-server sheet. -pub const LIMITS_TITLE: &str = "Your blyg runs stock blygger-studio"; +pub const LIMITS_TITLE: &str = "Your blyg runs an older blygger-studio"; pub const LIMITS_INTRO: &str = "Writing, publishing, versions, subscriptions and your reading \ - list all work. A few things need a newer studio, or extensions this server doesn't have:"; + list all work. A few things need a newer studio (0.40 or later has them all):"; /// What's limited, and how Burrow copes. Also in the About window. pub const LIMITS: [&str; 4] = [ - "Read state stays on this Mac. Posts you read here still show as unread on your other Macs.", + "Before studio 0.39: read state stays on this Mac. Posts you read here still show as \ + unread on your other Macs.", "Before studio 0.28: AI disclosure for text generated in Burrow. Before you publish such \ text, Burrow says it will go out without the disclosure, and lets you cancel.", "Before studio 0.26: an image you paste into a draft and then delete stays on the server.", diff --git a/crates/blyg-core/src/api/auth.rs b/crates/blyg-core/src/api/auth.rs index 9bd3c37..71b01ea 100644 --- a/crates/blyg-core/src/api/auth.rs +++ b/crates/blyg-core/src/api/auth.rs @@ -4,8 +4,9 @@ //! access token and a rotating refresh token, sent as //! `Authorization: Bearer` and renewed as it goes (see `oauth`). //! - **API token**: a manual token the owner makes in Studio → More → -//! Client access (studio 0.28+; 30 days, not renewable), or an older -//! Worker fork's `BLYG_OWNER_TOKEN`. Sent as `Authorization: Bearer`. +//! Client access (studio 0.28+; 30 days, not renewable), or an old +//! Worker fork's `BLYG_OWNER_TOKEN` (a stock studio has none, and answers +//! such a token with 401). Sent as `Authorization: Bearer`. //! - **Password**, which works on any blygger-studio: the studio's own //! sign-in, `POST {blyg-url}/studio/login` with the form field `password`, //! answers with a `blyg_session` cookie (valid 30 days, not renewed). The diff --git a/crates/blyg-core/src/api/oauth.rs b/crates/blyg-core/src/api/oauth.rs index 607a586..3cf2c26 100644 --- a/crates/blyg-core/src/api/oauth.rs +++ b/crates/blyg-core/src/api/oauth.rs @@ -51,7 +51,7 @@ macro_rules! read_state_scope { "reading:state" }; } -/// The scope read-state writes need (studio 0.39+). Provisional name. +/// The scope read-state writes need (studio 0.39+). pub const READ_STATE_SCOPE: &str = read_state_scope!(); /// The owner scopes (upstream `src/auth-scopes.ts` `OWNER_SCOPES`, in its /// order, which is also the consent page's). diff --git a/docs/CORE.md b/docs/CORE.md index 3529dd8..d25d6e8 100644 --- a/docs/CORE.md +++ b/docs/CORE.md @@ -7,9 +7,9 @@ How `crates/blyg-core` works, for whoever touches it next. The spec is | Module | Role | |---|---| -| `api/` | Blocking `ureq` 2 (rustls) client, one method per owner endpoint. Errors: transport → `Offline`, 401 → `Unauthorized`, 404 → `NotFound`, other non-2xx → `Rejected{status, error, errors}` (5xx too; `api::is_transient` treats ≥ 500 as retryable). Patch-3 reads return `Ok(None)` on 404. `Debug` redacts the token. | +| `api/` | Blocking `ureq` 2 (rustls) client, one method per owner endpoint. Errors: transport → `Offline`, 401 → `Unauthorized`, 404 → `NotFound`, other non-2xx → `Rejected{status, error, errors}` (5xx too; `api::is_transient` treats ≥ 500 as retryable). Optional reads (`/api/reading/imported` on a studio before 0.40, …) return `Ok(None)` on 404. `Debug` redacts the token. | | `store/` | One `rusqlite` connection behind a mutex, WAL, `user_version` migrations (`schema.rs`, append-only). Items + FTS5 trigram index, outbox, versions cache, reading list, subscriptions. | -| `sync/` | `Engine` (flush, pull, health/status, events) plus the worker thread. | +| `sync/` | `Engine` (flush, pull, health/status, events) plus the worker thread. A `401` the credential can't recover from stops the worker (no retry loop) and emits `SignedOut` once; queued ops stay. | | `live.rs` | `LiveBackend: Backend`. | | `tk.rs` | The Worker's TK scope grammar, and provenance validation/remapping (see § Provenance). | | `config/` | the Ghostty-style config file (`keys.rs` is the single key table; `parse`, `edit` preserves comments on write-back, `paths` XDG → Application Support, `migrate` from the old TOML files, `show` for `+show-config`); `tokens.rs`: `TokenStore` trait (`KeychainTokenStore` behind the default `keychain` feature, `MemoryTokenStore` for tests). | @@ -149,8 +149,8 @@ cd && npm install && npm run build # studio 0.10+ builds its SPA BLYG_WORKER_DIR= scripts/e2e-local.sh ``` -The Worker must be blygger-studio 0.9 or later with docs/SERVER.md's -extensions. The script derives a local-only wrangler config (no routes, no +The Worker is a stock blygger-studio 0.9 or later (set `BLYG_E2E_STOCK=1`; the +few tests of the old fork's extensions then skip), or a fork carrying them. The script derives a local-only wrangler config (no routes, no Workers AI binding, so nothing billed), random dev secrets including a throwaway `BLYG_OWNER_TOKEN`, and scratch D1/R2 state. It starts two instances (the second is a blyg to subscribe to) and stops only what it started. The @@ -159,7 +159,8 @@ connect, reads, autosave, publish, withdraw, pin and restore, kind changes, uploads and their dedupe and removal, a 409 conflict from a direct API edit, offline queueing, subscriptions and reading, read-state sync between two clients, and AI provenance disclosure. Raw replies are checked against -`openapi.json`. Last green: 22/22 against the fork on studio 0.10 (2026-10-02). +`openapi.json`. Last green: 44/44 (plus the markdown-notes extension's 2) against +stock studio 0.46.1 (2026-10-13). ## Reading list diff --git a/docs/SERVER.md b/docs/SERVER.md index 0f2ca8f..038671e 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -2,118 +2,123 @@ Burrow talks to a blyg's **owner API**. Since studio 0.9, the upstream reference Worker (https://github.com/blygger/blygger-studio) documents that API as an OpenAPI contract -(`openapi.json`; route guide in its `docs/api.md`). The app follows that contract (studio -0.32 is current) and needs **0.9 or later**. Every route the app shares with upstream is -checked against `openapi.json` in its tests (`crates/blyg-core/tests/fixtures/`). - -**A stock blygger-studio works.** On studio 0.28 or later, **sign in with the browser** -(the default): Burrow registers itself as an OAuth client of your blyg, opens the -studio's consent page, and keeps the grant (a one-hour access token, renewed with a -rotating refresh token until the grant ends, at most 30 days after your studio -sign-in) in your Keychain. Signing out revokes it. Or make an **API token** in Studio → -More → Client access (REST API, every permission: read, draft, publish, manage, and -on studio 0.39+ `reading:state`; it lasts 30 days) and paste it. A token missing a permission gets a 403 that names -it. On an older studio, or as a fallback, sign in with the **studio password**: Burrow -signs in at `{blyg-url}/studio/login` like the browser does, keeps the session cookie -in memory only (the password is in your Keychain), and signs in again when the -session ends (studio 0.28 also ended every earlier session once). Writing, publishing, versions, pins, subscriptions, mentions, settings and the -reading list all work. The reading list is built from upstream's own routes: -`GET /api/reading` lists the posts, `GET /api/imports/{sub}/{id}` reads each new or -changed one, and thumbs and hoppers come from `/api/signals` and each hopper. - -### What a stock server limits - -| Feature | On a stock server | +(`openapi.json`; route guide in its `docs/api.md`). The app follows that contract and +needs **0.9 or later**; it is tested end to end against **studio 0.46.1**. Every route +the app shares with upstream is checked against `openapi.json` in its tests +(`crates/blyg-core/tests/fixtures/`). + +**A stock blygger-studio is fully supported.** On studio 0.40 or later nothing in this +app needs a patched server: writing, publishing, versions, pins, subscriptions, mentions, +settings, the reading list, read state across Macs, lineage and AI disclosure all run on +upstream's own routes. + +## Signing in + +- **Sign in with the browser** (studio 0.28+, the default): Burrow registers itself as + an OAuth client of your blyg, opens the studio's consent page, and keeps the grant (a + one-hour access token, renewed with a rotating refresh token until the grant ends, at + most 30 days after your studio sign-in) in your Keychain. Signing out revokes it. +- Or make an **API token** in Studio → More → Client access (REST API, every permission: read, draft, + publish, manage, and on studio 0.39+ `reading:state`; it lasts 30 days) and paste it. + A token missing a permission gets a 403 that names it. +- On an older studio, or as a fallback, the **studio password**: Burrow signs in at + `{blyg-url}/studio/login` like the browser does, keeps the session cookie in memory + only (the password is in your Keychain), and signs in again when the session ends. + +A stock studio has no owner token in its environment: that was the old Worker fork's +extension 1 (`BLYG_OWNER_TOKEN`). When a blyg stops taking a saved credential (a revoked +or expired token, an ended sign-in, or such an owner token after the blyg moved to a +stock studio), every call answers `401`. Burrow then stops syncing (no retries), keeps +every queued change, and opens the sign-in sheet once, browser first. Signing in again +sends what waited. + +## What each studio version brings + +| Feature | Stock studio | |---|---| -| AI disclosure for text generated **in Burrow** | Recorded on studio 0.28 or later: Burrow sends each TK scope's provenance with the text (`PATCH /api/items/:id {content_md, provenance}`, and on `POST /api/items`). On an older one it isn't, so before publishing such text Burrow says it will go out without the disclosure, and lets you cancel. Generation on the blyg's own server records its disclosure itself. | -| Read state across Macs | Stays on each Mac. | -| Removing an image you pasted, then deleted | Removed on studio 0.26 or later; on an older one it stays on the server. | -| Who a post replies to, or forks | Studio 0.18 or later sends it with each post. An older one doesn't, so Burrow fetches it from the author's blyg, a few posts per sync. | - -Burrow says this once, the first time it meets a stock server, and lists it in the -About window. +| Reading list | **0.40+:** `GET /api/reading/imported` (`listImportedReading`): every imported post with its Markdown, author, thumb, hoppers, read state, transclusions and lineage, keyset-paged by an opaque `cursor`, 100 a page. **0.9–0.39:** built from `GET /api/reading` (which posts there are) plus `GET /api/imports/{sub}/{id}` for each new or changed one, with thumbs and hoppers from `/api/signals` and each hopper; slower. | +| Read state across Macs | **0.39+** (`reading:state` scope), unread too; see [Read-state sync](#read-state-sync). Older: stays on each Mac. | +| Who a post replies to, or forks | **0.18+** sends it with each post (on 0.40+ with its `cited`). Older: Burrow fetches it from the author's blyg, a few posts per sync. | +| AI disclosure for text generated **in Burrow** | **0.28+:** each TK scope's provenance goes with the text (`PATCH /api/items/:id {content_md, provenance}`, and on `POST /api/items`); on 0.41+ a remote source carries its `origin`. Older: before publishing such text Burrow says it will go out without the disclosure, and lets you cancel. Generation on the blyg's own server records its disclosure itself. | +| Removing an image you pasted, then deleted | **0.26+.** Older: it stays on the server. | +| Syncing every 15 s on change counters | **0.32+** (`GET /api/changes`). Older: read whole every 60 s. | + +On a studio older than 0.40 Burrow says once that the server is limited, and lists the +limits in the About window. + +### Shapes worth knowing (studio 0.40+) + +- `GET /api/reading/imported?limit=1..100&cursor=…` → `{items, next, limit}`. Rows are + camelCase (`subscriptionId`, `remoteId`, `withdrawn`, `l0`, `version`, `readVersion`, + `observedAt`, `contentMd`, `contentHtml` (sanitized), `author` `{name, url}`, `page`, + `sourceUrl`, `thumb`, `hoppers`, `transclusions`, `stubOf`, `forkedFrom`, `generated`), + newest `observedAt` first, with no drops or repeats across pages. A `limit` out of range + or a cursor the server didn't issue is `400`; `before` is not accepted. There are no + page-level flags: read state is advertised on `GET /api/reading` (`read_state`, + `read_state_clear`), and each row's lineage is the origin's own (null when none). +- `GET /api/settings` carries many keys Burrow doesn't use (`custom_theme*`, `font`, + `feed_order`, `front_page`, `posts_path`, `links_*`, `setup`, `defaults`, + `feed_orders`, …). Burrow ignores them, and its `PATCH` sends only the keys it owns. +- Mentions may have `relation: "source"` (0.41: generated text drew on your post). +- Hopper routes take `~own` as the subscription with your own item's id (0.45). + +## Legacy: the old Worker fork's extensions + +Before upstream had all of the above, the maintainer's Worker fork carried extensions. +Burrow still speaks them where it is cheap, so a fork keeps working, but **a stock studio +needs none of them**. + +| # | Extension | Now | +|---|---|---| +| 1 | Bearer-token owner auth with the Worker secret `BLYG_OWNER_TOKEN`. | Superseded by studio 0.28's OAuth and manual tokens. Such a token still goes in the "API token" field on a fork; a stock studio refuses it (`401`, see above). | +| 2 | Owner JSON reads. | Upstream since 0.9; retired. | +| 3 | `GET /api/reading/imported?limit&before=` → `{items, next, read_state, read_state_clear, lineage}` with snake_case rows, at most 500 a page. | Upstream since 0.40 under the same path with its own shape (above). Burrow tells them apart on the walk's first page (which sends only `limit=100`, taken by both): a page with the fork's `read_state` flag is the fork's, and later pages use `before`; otherwise `cursor`. | +| 4 | `PUT /api/items/:id/tk-provenance`. | Retired by studio 0.28's `provenance` on `PATCH /api/items/:id`; used only when a server refuses that field. | +| 5 | Read-state sync. | Upstream since 0.39 (below), same write routes and bodies. | -### Extensions for everything +Upstream's scoped tokens may not reach a fork's own extension routes. -A Worker that carries these extensions gets all of the above: +## Read-state sync -| # | Extension | Why the app needs it | -|---|---|---| -| 1 | *Optional.* **Bearer-token owner auth**: `Authorization: Bearer ` is accepted wherever the owner session cookie is, when the Worker secret `BLYG_OWNER_TOKEN` is set. | On studio 0.28+ upstream's manual tokens and OAuth supersede it; a fork may still offer it (its owner token goes in the same "API token" field). Upstream's scoped tokens may not reach a fork's own extension routes, so such a fork keeps it for those. | -| 3 | **Reading rows for the app** (without it, Burrow builds the same rows from upstream's routes, more slowly): `GET /api/reading/imported?limit&before=` → `{items, next, read_state, lineage}`. Imported items only (tombstones too), newest `observed_at` first, in the app's row shape: raw `content_md`, `origin`, `author`, `page`, `thumb`, `hoppers`, `read_version`, `pinned_version_retained`, `transclusions[]` with `cited`, and `stub_of`/`forked_from` as imported (null when none). `lineage: true` says the rows carry those two; without it the app fetches them from each post's public document. `limit` defaults to 100, max 500; `next` is an opaque cursor passed back as `before`; a bad cursor is `400`. `content_html` is raw, as stored, so the app sanitizes it before display. | Upstream's `/api/reading` is a different resource: own and imported posts, rendered and sanitized, offset-paged (≤ 50), with no Markdown, author, thumb or hoppers. The reader can't use it as-is. | -| 4 | **Client-recorded TK provenance**: `PUT /api/items/:id/tk-provenance {content_md?, scopes:[{index, model, sources?, at?} \| null]}` and `GET` of the same. Validated first, atomic with the text, never stores the instruction. | So text generated **in the app** is disclosed (`generated` + `blyg-tk-gen`) exactly like text the Worker generates itself. **Retired for studio 0.28+**, whose `PATCH /api/items/:id` takes `provenance` itself; Burrow uses this only when a server refuses that field (a strict 400 on the unknown key). A 403 here (a Worker that closes its extension routes to scoped tokens) reads as "disclosure not recordable". | -| 5 | *Optional.* **Read-state sync**: `read_state: true` and a per-item `read_version` on `GET /api/reading/imported`; `PUT /api/reading/:sub/:remoteId/read` and `POST /api/reading/read`. See [Extension 5](#extension-5-read-state-sync). | So a post you read on one Mac reads as read on your others, and a fresh install doesn't show everything unread. | - -Extension 2 (owner JSON reads) is upstream now, so its number is retired. Extension 1 -is superseded upstream (studio 0.28's manual tokens and OAuth) but not retired: forks -may still carry it. -Field-level contracts: `docs/SPEC.md` § API and § Client-recorded provenance. - -## Extension 5: read-state sync - -**Becoming upstream.** blygger-studio is taking this extension in, as two PRs agreed -with this app: PR 1 (read, built: the same write routes and bodies, plus read state on -upstream's own `GET /api/reading`) and PR 2 (unread: clearing read state, with a -tombstone against stale reads). Once they ship, a stock studio needs nothing from this -section. The app speaks both: the fork's extension below, and upstream's routes. - -The server keeps, per reading row, the highest version the owner has read. It never -lowers that value on a read, so every read write is idempotent and replay-safe; only a -clear (PR 2) resets it. - -**Migration.** One new table (in the reference Worker: `migrations/0012_read_state.sql`; -upstream: `0025_read_state.sql`, rebuilt by `0026_read_state_clear.sql` with a nullable -`read_version` and an `unread_at` column): - -```sql -CREATE TABLE read_state ( - subscription_id TEXT NOT NULL, - remote_id TEXT NOT NULL, - read_version INTEGER NOT NULL, - updated TEXT NOT NULL, - PRIMARY KEY (subscription_id, remote_id) -); -``` - -It also adds two triggers, so a row goes away when its imported item or its subscription -is deleted. Upstream's change triggers also advance the `reading` revision of -`GET /api/changes`, so a mark on one device shows on another's next pull. +Upstream since studio 0.39, read and unread (clearing read state leaves a tombstone +against stale reads); before that it was the fork's extension 5, with the same write +routes and bodies. The server keeps, per reading row, the highest version +the owner has read. It never lowers that value on a read, so every read write is +idempotent and replay-safe; only a clear resets it. **Capability.** +- Upstream: `GET /api/reading` answers with top-level `read_state: true` (and + `read_state_clear: true`), and each imported entry carries `readVersion` (an integer, + or `null` when unread) and `version`, the version held. On 0.40+ the same `readVersion` + is on every `GET /api/reading/imported` row; Burrow reads the flags from + `GET /api/reading?limit=1` once a session. - The fork: `GET /api/reading/imported` answers `{items, next, read_state: true}`, and - each item carries `read_version` (an integer, or `null` when unread). -- Upstream (PR 1): `GET /api/reading` answers with top-level `read_state: true`, and each - imported entry carries `imported.readVersion` (camelCase; an integer, or `null`), and - (with PR 2's studio) `imported.version`, the version held. PR 2 adds - `read_state_clear: true`. - -The app reads the read version only when `read_state` is `true`. It checks the flags on -every pull and remembers the last answer. - -**Writes** (owner auth, like every `/api` call). **Scope:** since blygger-studio 0.39 -(migration `0026_read_state.sql`) these four writes (`markRead`, `markUnread`, -`markReadBatch`, `markUnreadBatch`) need the bearer scope **`reading:state`**, and -`owner:manage` alone is refused with `403 insufficient_scope`. The name is -**provisional** (upstream may rename it); the app spells it in one place, -`READ_STATE_SCOPE` in `crates/blyg-core/src/api/oauth.rs`. The browser sign-in asks for -it when the studio lists it in `scopes_supported` (an older studio would refuse the whole -request for a scope it doesn't know). A client registered before (Burrow 0.10) is reused; -the studio grants it the new scope. The wire shapes are unchanged: `version` must be ≥ 1 there, and `read_at` is still -accepted (for offline queues), though the studio's own client no longer sends it. + each item carries `read_version`. + +The app reads the read version only when `read_state` is `true`. + +**Writes** (owner auth, like every `/api` call). **Scope:** these four writes +(`markRead`, `markUnread`, `markReadBatch`, `markUnreadBatch`) need the bearer scope +**`reading:state`**; `owner:manage` alone is refused with `403 insufficient_scope`. The +app spells the name in one place, `READ_STATE_SCOPE` in +`crates/blyg-core/src/api/oauth.rs`. The browser sign-in asks for it when the studio +lists it in `scopes_supported` (an older studio would refuse the whole request for a +scope it doesn't know). A client registered before (Burrow 0.10) is reused; the studio +grants it the new scope. | Call | Body | Success | Errors | |---|---|---|---| -| `PUT /api/reading/:sub/:remoteId/read` | `{version: integer ≥ 0, read_at?}` | `200 {ok: true, stored: bool, read_version: n \| null}`. Stores `max(existing, version)`. For an unknown subscription or item, or a read older than the row's last clear: `stored: false`, nothing written. | `400` bad body, `401`, `403` | +| `PUT /api/reading/:sub/:remoteId/read` | `{version: integer ≥ 1, read_at?}` | `200 {ok: true, stored: bool, read_version: n \| null}`. Stores `max(existing, version)`. For an unknown subscription or item, or a read older than the row's last clear: `stored: false`, nothing written. | `400` bad body, `401`, `403` | | `POST /api/reading/read` | `{items: [{sub, remote_id, version, read_at?}]}`, at most 500 | `200 {ok: true, received: n}`. The same max-merge per row. Unknown or stale rows are skipped. | `400` for any malformed entry or more than 500 entries (nothing is written), `401`, `403` | -| `DELETE /api/reading/:sub/:remoteId/read` (PR 2) | none | `200 {ok: true, stored: false, read_version: null}`. Clears the row and records `unread_at` (the server's time). Idempotent. | `401`, `403` | -| `POST /api/reading/unread` (PR 2) | `{items: [{sub, remote_id}]}`, at most 500 | `200 {ok: true, received: n}`, all or nothing. | `400`, `401`, `403` | +| `DELETE /api/reading/:sub/:remoteId/read` | none | `200 {ok: true, stored: false, read_version: null}`. Clears the row and records `unread_at` (the server's time). Idempotent. | `401`, `403` | +| `POST /api/reading/unread` | `{items: [{sub, remote_id}]}`, at most 500 | `200 {ok: true, received: n}`, all or nothing. | `400`, `401`, `403` | -`read_at` (ISO-8601 with an offset, e.g. `2026-10-06T12:00:00Z`) is accepted only by a +`read_at` (ISO-8601 with an offset, e.g. `2026-10-06T12:00:00Z`) is sent only to a server that advertises `read_state_clear`: a read earlier than the row's `unread_at` is ignored, so a queued read can't undo a later "mark unread" from another device. Upstream -bodies are strict, so the app **never** sends `read_at` to a server that doesn't advertise -the flag. Upstream's `400` body is `{error, issues: [{path, message}]}`. +bodies are strict, so the app **never** sends `read_at` to a server that doesn't +advertise the flag. Upstream's `400` body is `{error, issues: [{path, message}]}`. Unknown rows are acknowledged, never `404`, because the app reads a `404` from these endpoints as "this server doesn't keep read state". @@ -138,27 +143,26 @@ endpoints as "this server doesn't keep read state". a server with `read_state_clear`, a row this Mac had confirmed but the server now reports lower or unread was cleared on another device, and it becomes unread here; a row read here that the server hasn't got is queued again (with its `read_at`). -- **Unread without `read_state_clear`** (the fork's extension 5, or upstream PR 1 alone) - stays on this Mac: no unread op is sent, and the floor keeps the server's old read - version from re-marking the row read on every pull, so there's no loop of requests. - Reading the post again (here, or a newer version on another device) ends it. If the - server later gains `read_state_clear`, those local unreads are sent then. +- **Unread without `read_state_clear`** (the fork's extension 5) stays on + this Mac: no unread op is sent, and the floor keeps the server's old read version from + re-marking the row read on every pull. Reading the post again ends it. If the server + later gains `read_state_clear`, those local unreads are sent then. - The first time a database sees the capability, the app batch-uploads every read version it holds (500 per call) and records that in its `meta` table. - A `403` on a write (a sign-in or token without `reading:state`, such as one made before - studio 0.39, or a Worker that keeps these writes to the owner's own token) holds read - state for the rest of the session: the ops stay queued (nothing is dropped), new marks - keep queueing, and the rest of the outbox syncs as usual. The app says so once, with the - server's reason: sign in with the browser again (or make a new token with - `reading:state`). It doesn't retry until the next sign-in or launch, which sends what - waited. + studio 0.39) holds read state for the rest of the session: the ops stay queued (nothing + is dropped), new marks keep queueing, and the rest of the outbox syncs as usual. The app + says so once, with the server's reason: sign in with the browser again (or make a new + token with `reading:state`). It doesn't retry until the next sign-in or launch, which + sends what waited. - **Without the capability**, read state stays on this Mac. The app makes no requests to these endpoints and shows no errors. A `404` from them turns sync off and drops anything queued. -**Privacy.** The table is owner-only. No public page, feed, `blyg.json`, item document or -export reads it. It holds only numbers (and, upstream, the time of a clear) keyed by -subscription and item, never text. +**Privacy.** Read state is owner-only. No public page, feed, `blyg.json`, item document +or export reads it. It holds only numbers (and the time of a clear) keyed by subscription +and item, never text. A change, including a clear, advances the `reading` revision of +`GET /api/changes`, so a mark on one device shows on another's next pull. ## Also used when present @@ -192,7 +196,8 @@ mattered here: ## Degrading gracefully -- **No extension 1:** sign in with the studio password instead. +- **A saved credential the blyg refuses (`401`):** sync stops (no retries), queued + changes stay queued, and the sign-in sheet opens once, browser first. - **A server older than studio 0.9:** the connect sheet says it needs updating. A blyg already connected gets a notice when Burrow starts (and a status-bar notice with a **How to update** link), and Burrow pushes nothing to it: on an @@ -200,8 +205,9 @@ mattered here: Your edits wait in the outbox and go up once the server is updated. Burrow recognises an old server by `GET /api/items` answering without `total` (or 404). -- **No extension 3:** the reading list comes from upstream's own routes (see above). -- **No extension 4, and older than studio 0.28:** before publishing text that was generated +- **Studio older than 0.40:** the reading list is built from upstream's older routes + (see above). +- **Older than studio 0.28:** before publishing text that was generated in the app, the app warns that it will go out **without** AI disclosure, and lets you cancel. - **Studio 0.32+ (`GET /api/changes`):** each sync first reads the change counters and fetches only the collections whose domains moved (everything, every 10 minutes), so the @@ -209,16 +215,15 @@ mattered here: - **Work budgets (studio 0.28+):** a `429` keeps the change queued and retries after `Retry-After`; the app says "The blyg asked Burrow to slow down". Autosave pushes for one post are at least 3 s apart, about 20 writes a minute at most. -- **No extension 5:** read state stays on each Mac, as before. +- **Older than studio 0.39:** read state stays on each Mac. ## Updating an older server Follow upstream's guide, [Upgrading to Blygger Studio 0.11](https://github.com/blygger/blygger-studio/blob/main/docs/upgrading-to-0.11.md). It covers source and Worker-archive installs, the one database migration, and -path-mounted blygs. A server that carries the extensions above needs them -re-applied on top of the new version, so update it from wherever its extensions -come from. Once it's updated, Burrow picks it up on its next sync; there's +path-mounted blygs. A fork that carries the legacy extensions doesn't need +them on studio 0.40 or later: a stock studio is fully supported. Once it's updated, Burrow picks it up on its next sync; there's nothing to do in the app. ## Optional: server-side generation with Gemma 4 @@ -229,7 +234,7 @@ uses whatever the Worker is configured to use. ## Status -Upstream's plan (its `docs/migration.md` §3) is for this app to run on the documented -API alone, with OAuth in place of extension 1. The remaining extensions are proposed in -upstream issues #11 (provenance, read state, reading rows), #12 (lineage in reading -rows) and #7 (attachments). +Upstream's plan (its `docs/migration.md` §3) was for this app to run on the documented +API alone, with OAuth in place of extension 1. As of studio 0.40 it does: reading rows +(#11, #61), lineage in them (#12) and read state are upstream. Only the media +`duplicate` reply (#7) is still a fork's. diff --git a/docs/SPEC.md b/docs/SPEC.md index 7000136..645ddad 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -18,7 +18,7 @@ disagree on *feel*, follow the mock. On *API behaviour*, this document wins. | UI | **GPUI** (Zed's framework) + `gpui-component` where it helps (text input/editor). | | Local data | **SQLite** via `rusqlite` (`bundled`, FTS5). Local-first: UI reads only from SQLite. DuckDB was considered and declined because this is an OLTP/tiny-write workload, and DuckDB can read the SQLite file later for analytics. | | Network | Blocking HTTP (`ureq`, rustls) on background threads. No tokio in the app. | -| Auth | Base URL + a bearer: **Sign in with browser** (OAuth, PKCE, loopback redirect; studio 0.28+; the default), else an API token (Studio → More → Client access, or a fork's `BLYG_OWNER_TOKEN`), else the studio password. Credentials live in the **macOS Keychain** (Windows Credential Manager on Windows; `keyring` crate), never in a file. See `crates/blyg-core/src/api/oauth.rs`. | +| Auth | Base URL + a bearer: **Sign in with browser** (OAuth, PKCE, loopback redirect; studio 0.28+; the default), else an API token (Studio → More → Client access; an old Worker fork's `BLYG_OWNER_TOKEN` also works there, a stock studio has none), else the studio password. Credentials live in the **macOS Keychain** (Windows Credential Manager on Windows; `keyring` crate), never in a file. See `crates/blyg-core/src/api/oauth.rs`. | | Platform | macOS first; Windows (x64) is supported too, from the same code: platform code sits behind `cfg(target_os = …)`, mostly in `crates/blyg-app/src/platform.rs`, and the Windows build must keep building and passing CI (see `site/src/dev/building.md`, "Keeping Windows building"). | | Layout | **Side by side**: item list on the left (~38%), editor on the right. | | Theme | **Tufte colours** (paper `#fffff8`, ink `#111`, accent `#a4271b`; dark: `#161513` / `#e4dfd3` / `#e0775a`), follows system light/dark. Default in the mock was dark. | @@ -147,7 +147,7 @@ media) lives in `~/Library/Application Support/org.blygger.desktop/`. Base: the configured `blyg-url` for public pages and the studio; `/api` is host-rooted (a blyg at `https://host/blyg` has its API at `https://host/api`). Every `/api` call carries -either `Authorization: Bearer ` (a browser sign-in's access token, a manual API token, or a fork's extension-1 owner token) or the studio session +either `Authorization: Bearer ` (a browser sign-in's access token, a manual API token, or an old fork's extension-1 owner token) or the studio session cookie `blyg_session`, from `POST {blyg-url}/studio/login` (form field `password`; a 302 with `Set-Cookie`; 30 days). JSON bodies are sent with `content-type: application/json`. Failure `401 {"error":"unauthorized"}`. Error bodies are `{error, errors?, issues?}` @@ -182,25 +182,38 @@ uses are in `docs/SERVER.md`. The key points: - `GET /api/mentions?direction=inbound` (`source_author_json` is a JSON string) - `GET /api/hoppers/:id?preview=true` → `{hopper, memberships, items, total, source_count}` -**Reading** (extension 3, see `docs/SERVER.md`; not upstream): - -- `GET /api/reading/imported?limit=N&before=` → `{items: ReadingItem[], next: string|null, read_state}`, - imported items newest `observed_at` first; the default limit is 100 and the max is 500. `next` is an **opaque cursor**: - pass it back as `before` verbatim. Pages are ≤ limit. `page` is origin-relative (resolve it against `origin`). - ReadingItem = - `{subscription_id, remote_id, subscription_title, origin, kind, state, version, - created, updated, observed_at, content_md, content_html, author: {name,url}|null, - page, thumb: 1|-1|null, hoppers: string[], read_version, pinned_version_retained, - transclusions}`. `content_html` is raw: sanitize before display. Upstream's own - `/api/reading` returns rendered `ReadingEntry` rows with `offset`/`limit` paging - (at most 50) instead. - -**Read-state sync** (optional extension 5, see `docs/SERVER.md`): `GET /api/reading/imported` adds -`read_state: true` and a per-item `read_version: number|null`; -`PUT /api/reading/:sub/:remoteId/read {version}` and -`POST /api/reading/read {items: [{sub, remote_id, version}]}` (≤ 500) store -`max(existing, version)`. On blygger-studio 0.39+ the read-state writes need the scope -`reading:state` (a provisional name; `oauth::READ_STATE_SCOPE`), not `owner:manage`. +**Reading** (studio 0.40+ `listImportedReading`; see `docs/SERVER.md`): + +- `GET /api/reading/imported?limit=1..100&cursor=` → `{items, next, limit}`: + every imported item, newest `observedAt` first (then subscription and remote id), no + drops or repeats across pages. `next` is passed back as `cursor` verbatim; a bad cursor + or a limit out of range is 400. Rows are camelCase (`subscriptionId`, `remoteId`, + `subscriptionTitle`, `origin`, `kind`, `withdrawn`, `l0`, `version`, `readVersion`, + `created`, `updated`, `observedAt`, `contentMd`, `contentHtml` (sanitized), `author`, + `page` (origin-relative), `sourceUrl`, `thumb`, `hoppers`, `pinnedVersionRetained`, + `transclusions`, `stubOf`, `forkedFrom`, `generated`); `wire::upstream_row` maps + them onto `ReadingItem` (`withdrawn` → `state: "tombstone"`). Each row's lineage is + the origin's own, so it is known for every row but an L0 or withdrawn one. No page + flags: read state is advertised on `GET /api/reading` (`read_state`, + `read_state_clear`), read once a session. +- The old Worker fork's extension 3 serves the same path with snake_case rows, + `before=` paging (≤ 500) and page flags (`read_state`, `read_state_clear`, + `lineage`). The walk's first page sends only `limit=100`, which both take; a page + carrying `read_state` is the fork's. +- A studio older than 0.40 (404 here): upstream's `GET /api/reading` (offset-paged, + ≤ 50) lists the posts and `GET /api/imports/{sub}/{id}` reads each new or changed one. + +**Read-state sync** (studio 0.39+, see `docs/SERVER.md`): each row's +`readVersion` (`read_version` on the fork); `PUT /api/reading/:sub/:remoteId/read +{version, read_at?}`, `POST /api/reading/read {items: [{sub, remote_id, version, +read_at?}]}` (≤ 500), `DELETE …/read` and `POST /api/reading/unread {items: [{sub, +remote_id}]}`. Reads store `max(existing, version)`. The writes need the scope +`reading:state` (`oauth::READ_STATE_SCOPE`), not `owner:manage`. + +**A refused credential.** A `401` the credential can't recover from (after the +password sign-in or the browser grant's renewal is retried once) stops the sync worker: +no retries until a new sign-in or a manual sync, queued ops stay queued, and +`CoreEvent::SignedOut` (once a session) makes the app open the sign-in sheet. Transclusion syntax is `![[<26-char id>]]` alone on its own line, and is only valid in threads. See upstream `worker/src/transclusion.ts` for which ids resolve @@ -231,7 +244,7 @@ valid in threads. See upstream `worker/src/transclusion.ts` for which ids resolv **Posts are dated by their author, not by the import.** The list sorts newest first by the post's own `updated` (else `created`), so an author's edit moves the post up; a row says "edited 3d ago" when `updated` is more than ten minutes after `created`, else shows when it was published. `observed_at` (when your blyg imported it) is only the fallback for a post with no usable date. Dates parse as RFC 3339 or RFC 2822 (`ReadingItem::post_time`); the store keeps the normalized sort key in `reading.sort_at` (indexed). -**Read state syncs through your blyg when the server supports it.** Read state is a number per reading row: the highest version you've read. Marking a post read is always instant and local, and it marks every duplicate of the same post. When the blyg advertises read-state sync (extension 5, `docs/SERVER.md`), the same mark also queues a `read` op per row in the outbox, which is sent like any other change (offline-safe, retried, coalesced to the highest version per row). A pull merges `max(local, server)`, so read state never goes backwards. The first time a database sees the capability, it uploads everything it has read in batches, once, and records that in `meta`. So a post read on one Mac reads as read on your others, and a fresh install isn't all unread. Without the extension, read state stays on this Mac, and nothing is sent. Nothing new is shown in the UI. The writes need the bearer scope `reading:state` (studio 0.39+; the name is provisional, kept in `oauth::READ_STATE_SCOPE`), which the browser sign-in asks for. A sign-in or token without it (one made before 0.39) gets a 403: the read ops stay queued, the rest of the outbox syncs, and Burrow says once that read state is waiting and to sign in again; the next sign-in sends what waited. +**Read state syncs through your blyg when the server supports it.** Read state is a number per reading row: the highest version you've read. Marking a post read is always instant and local, and it marks every duplicate of the same post. When the blyg advertises read-state sync (studio 0.39+, or the old fork's extension 5; `docs/SERVER.md`), the same mark also queues a `read` op per row in the outbox, which is sent like any other change (offline-safe, retried, coalesced to the highest version per row). A pull merges `max(local, server)`, so read state never goes backwards. The first time a database sees the capability, it uploads everything it has read in batches, once, and records that in `meta`. So a post read on one Mac reads as read on your others, and a fresh install isn't all unread. Without the extension, read state stays on this Mac, and nothing is sent. Nothing new is shown in the UI. The writes need the bearer scope `reading:state` (studio 0.39+; kept in `oauth::READ_STATE_SCOPE`), which the browser sign-in asks for. A sign-in or token without it (one made before 0.39) gets a 403: the read ops stay queued, the rest of the outbox syncs, and Burrow says once that read state is waiting and to sign in again; the next sign-in sends what waited. **Search, Notational Velocity style.** A search field sits above the reading list (the Posts omnibar isn't on this screen). ⌘F or `/` (with the list focused) puts the caret in it; typing filters the list live: a case-insensitive substring over the title, the author and blyg (subscription, origin) names, and the post's text (`content_md`, or the published HTML's text when an item has no Markdown), over posts **already held locally**. Nothing is fetched to search. Title matches are highlighted as in the Posts list. ↑/↓ move through the matches from the field, ⏎ goes to the list (opening the first match when nothing is open), and esc clears the search (esc on an empty field returns to the list; esc in the list with a search clears it before it leaves the screen). An empty result shows "No posts match “…”". The open post stays open while it still matches; otherwise the selection and the reader clear, so typing never opens (or marks read) anything. diff --git a/site/src/ai.md b/site/src/ai.md index be270e6..59ef171 100644 --- a/site/src/ai.md +++ b/site/src/ai.md @@ -67,8 +67,8 @@ and it's disclosed like any gap. Generated text is always disclosed as generated when you publish. Burrow records which model wrote each gap and sends it to your blyg with the text -(the provenance extension in [Server requirements](server.md)). If your blyg -doesn't have that extension, Burrow warns you before publishing generated +(blygger-studio 0.28 or later; see [Server requirements](server.md)). If +your blyg's studio is older, Burrow warns you before publishing generated text, and Cancel is the default. Proofreading isn't generated prose and isn't disclosed. diff --git a/site/src/faq.md b/site/src/faq.md index 4d9332d..1c8441e 100644 --- a/site/src/faq.md +++ b/site/src/faq.md @@ -19,13 +19,13 @@ rename it while it isn't running. See ### Can I use Burrow with a stock Blygger blog? -Yes, if it runs blygger-studio 0.9 or later. On 0.28 or later, sign in with -your browser (or an API token from the studio's Client access page); on an -older one, with your studio password. On a current studio only one thing -needs an extension upstream doesn't have yet: read state across your Macs. -An older studio limits a little more (AI disclosure for text generated in -Burrow, removing images you pasted and then deleted), and Burrow tells you -which the first time it connects. See [Server requirements](server.md). +Yes. A stock blygger-studio 0.40 or later is fully supported: nothing +needs a patched server. On 0.28 or later, sign in with your browser (or an +API token from the studio's Client access page); on an older one, with your +studio password. Burrow works with any studio from 0.9, but an older one +limits a little (read state across your Macs before 0.39, AI disclosure for +text generated in Burrow before 0.28), and Burrow tells you which the first +time it connects. See [Server requirements](server.md). ### Is my data safe? Where is it? From ae3b26ed8e3f53bfd22f9916eb366d75fc51b279 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:21:29 -0700 Subject: [PATCH 07/10] Release 0.12.0: stock blyg support; mark #60 split cases provisional Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 20 ++++++++++++++++++++ Cargo.toml | 2 +- crates/blyg-render/tests/preview.rs | 3 ++- docs/RENDER.md | 4 +++- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56dc9e1..f1d4daf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,26 @@ teaches something new in the tour, set the newest entry's `version` in ## [Unreleased] +## [0.12.0] - 2026-10-13 + +### Changed + +- **Works with a stock blyg.** Burrow now runs fully against an unmodified Blygger Studio (0.40 and later, checked against 0.46.1), with no server patches. Reading syncs from the studio's own imported feed (`GET /api/reading/imported`, paged by cursor), read marks sync through its read-state routes, and lineage, citations, the full author and disclosed generation sources come with each post. Servers running the older owner-API extensions keep working. [Server requirements](https://aneeshsathe.github.io/blygger-desktop/server.html) and `docs/SERVER.md` describe what's needed. +- **Sign in with a studio token or the browser.** The server owner token from the old extensions is gone from sign-in. Use Sign in with browser, or a token made in Studio › Access (allow `reading:state` so read marks sync). +- **Preview matches the studio 0.46.1 renderer.** Embeds are no longer baked into the HTML (the poster still shows), and `[TK]`, `[=]` and `[/TK]` inside code anywhere in a scope are plain text (studio 0.41.1). +- **"Drew on this".** A mention whose relation is `source` (a post that used yours as a generation source) shows as "drew on this". + +### Added + +- **Publish warns about quotes in generated text.** If a TK block's output holds an own-line `![[id]]` that its instruction didn't name, the publish sheet says so before you publish: publishing turns it into a quote and notifies that blyg. Preview now shows it as the quote publish will make (decision #60). +- **Generated text is marked as the publisher's own claim.** In the Reader, text a blyg marks as generated gets a tooltip, and the post ends with a short note that the disclosure is the publisher's own and isn't verified. + +### Fixed + +- **Reading sync against studio 0.40 and later.** Burrow asked for 500 rows a page in the old shape, which a stock studio refuses, so reading never synced there. +- **A refused sign-in stops syncing cleanly.** When the blyg rejects Burrow's token (for example after a server change), Burrow stops sending and pulling, keeps every queued change, and opens the sign-in sheet with an explanation, instead of retrying. +- **Authors and citations come through whole.** The full author object is kept, and `cited` survives on replies (`stub_of`) and remote references. + ## [0.11.0] - 2026-10-09 ### Added diff --git a/Cargo.toml b/Cargo.toml index d3ad5aa..b587a33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/blyg-core", "crates/blyg-ai", "crates/blyg-render", "crates/b [workspace.package] edition = "2024" -version = "0.11.0" +version = "0.12.0" license = "MIT" description = "A fast, local-first macOS studio for Blygger blogs" # Placeholder repo; set it with scripts/set-repo.sh (see packaging/config.env). diff --git a/crates/blyg-render/tests/preview.rs b/crates/blyg-render/tests/preview.rs index 22dc126..56922ab 100644 --- a/crates/blyg-render/tests/preview.rs +++ b/crates/blyg-render/tests/preview.rs @@ -325,7 +325,8 @@ fn fuzz_no_panics() { /// Decision #60: an own-line directive left in a block scope's output is a /// quote, as publish makes it; the prose around it stays disclosed as -/// generated, and the quote sits between the generated blocks. +/// generated, and the quote sits between the generated blocks. Provisional: +/// the exact split shape isn't ruled yet (see docs/RENDER.md, "Parity"). #[test] fn directive_in_block_output_is_a_quote() { let store = FakeStore::load(); diff --git a/docs/RENDER.md b/docs/RENDER.md index 45b2a42..9695586 100644 --- a/docs/RENDER.md +++ b/docs/RENDER.md @@ -300,7 +300,9 @@ matching case is also byte-identical. The four known divergences are the block-scope directive cases of decision #60 (`tr_in_tk_block`, `tr_block_tk_directive_output`, `tr_block_tk_directive_split`, `tr_block_tk_directive_attached`); `tests/preview.rs` pins this crate's -output for them: +output for them. The split shape (generated | quote | generated, and how +`generated[]` pairs with the pieces) is not ruled yet, so these four are +provisional: revisit them when upstream fixes its preview and publish. | suite | cases | normalised | byte-identical | |---|---|---|---| From 7ce3437f6244477b734fca7bef7857ebc6cd81b9 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:42:25 -0700 Subject: [PATCH 08/10] Tour for 0.12.0: the echoed-quote warning and the generated-text note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - What's new 0.12.0: stock blyg, sign-in (browser or a Client access token with reading:state), refused sign-in, the publish warning, the Reader's unverified note, "drew on this". Show me what's new starts at echoed-quote. - echoed-quote step: a sample thread whose TK output echoed ![[id]], in the full editor (the preview shows the quote); ⌘⏎ opens the sheet's warning. - generated step: Lin's fork now has a sentence marked blyg-tk-gen; ⏎ opens it in the stream's side pane with the tooltip and the note. - The tour no longer hides the reading WebView where it is clear of the tour card (stream side pane, Reader post pane), so posts show in the tour's reading steps. - Tests: the 0.11 -> 0.12 launch, both steps' warning and note. - Cargo.lock catches up with the 0.12.0 version bump. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 18 +-- crates/blyg-app/src/fake.rs | 12 ++ crates/blyg-app/src/fake_reading.rs | 10 ++ crates/blyg-app/src/onboarding/steps.rs | 42 +++++++ crates/blyg-app/src/onboarding/tests.rs | 116 +++++++++++++++++++- crates/blyg-app/src/onboarding/tutorial.rs | 58 +++++++++- crates/blyg-app/src/onboarding/whats_new.rs | 32 ++++++ crates/blyg-app/src/studio/reader.rs | 3 +- 8 files changed, 276 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0e26469..d8ee74b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -772,7 +772,7 @@ dependencies = [ [[package]] name = "blyg-ai" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "blyg-core", @@ -788,7 +788,7 @@ dependencies = [ [[package]] name = "blyg-app" -version = "0.11.0" +version = "0.12.0" dependencies = [ "adblock", "ammonia", @@ -825,7 +825,7 @@ dependencies = [ [[package]] name = "blyg-core" -version = "0.11.0" +version = "0.12.0" dependencies = [ "base64 0.22.1", "blyg-render", @@ -844,7 +844,7 @@ dependencies = [ [[package]] name = "blyg-ext" -version = "0.11.0" +version = "0.12.0" dependencies = [ "blyg-core", "serde", @@ -855,7 +855,7 @@ dependencies = [ [[package]] name = "blyg-ext-crosspost" -version = "0.11.0" +version = "0.12.0" dependencies = [ "blyg-ext", "serde_json", @@ -864,7 +864,7 @@ dependencies = [ [[package]] name = "blyg-ext-inspect" -version = "0.11.0" +version = "0.12.0" dependencies = [ "blyg-core", "blyg-ext", @@ -873,7 +873,7 @@ dependencies = [ [[package]] name = "blyg-ext-notes" -version = "0.11.0" +version = "0.12.0" dependencies = [ "blyg-core", "blyg-ext", @@ -884,7 +884,7 @@ dependencies = [ [[package]] name = "blyg-ext-reading-time" -version = "0.11.0" +version = "0.12.0" dependencies = [ "blyg-ext", "blyg-render", @@ -894,7 +894,7 @@ dependencies = [ [[package]] name = "blyg-render" -version = "0.11.0" +version = "0.12.0" dependencies = [ "fancy-regex", "html-escape", diff --git a/crates/blyg-app/src/fake.rs b/crates/blyg-app/src/fake.rs index 83ac6ab..13d873b 100644 --- a/crates/blyg-app/src/fake.rs +++ b/crates/blyg-app/src/fake.rs @@ -66,6 +66,18 @@ fn sample_reading() -> ReadingItem { } } +/// The tour's thread whose generated text echoed a quote its instruction +/// never asked for (decision #60): the publish sheet warns about it, and +/// the preview shows the quote publishing would make. +pub fn echoed_quote_sample() -> String { + format!( + "Rock pools at dusk\n\n\ + The light leaves the shelf before the water does.\n\n\ + [TK]a closing line about the tide coming back[=]The tide comes back for everything it lent.\n\n\ + ![[{SAMPLE_QUOTE_ID}]][/TK]\n" + ) +} + /// The full editor's sample thread (`BLYGGER_DEMO=studio`): a quote, a /// generated TK span, a YouTube link and a quote that can't be resolved. pub fn studio_sample() -> String { diff --git a/crates/blyg-app/src/fake_reading.rs b/crates/blyg-app/src/fake_reading.rs index 2b1c1c6..e8a8df3 100644 --- a/crates/blyg-app/src/fake_reading.rs +++ b/crates/blyg-app/src/fake_reading.rs @@ -26,6 +26,9 @@ pub const ADA_TIDES: &str = "01K2ADA0TIDES0000000000001"; pub const ADA_REPLY: &str = "01K2ADA0REPLY0000000000001"; /// Lin's thread forked from Ada's pinned first draft of the tides post. pub const LIN_FORK: &str = "01K2LIN0FORK00000000000001"; +/// The sentence of Lin's fork that Lin's blyg marks as generated. +pub const LIN_FORK_GENERATED: &str = + "And yet we plan the day around them, as if the moon owed us something."; /// A fragment of Lin's with emphasis, code and a bare link. pub const LIN_BENCH: &str = "01K2LIN0BENCH0000000000001"; pub const LIN_REREAD: &str = "01K2LIN0REREAD000000000001"; @@ -343,6 +346,13 @@ pub fn seed(now: DateTime) -> Seed { cited: None, selector: None, }]; + // Lin's blyg marks one sentence as generated (spec §5.7): the Reader + // tints it and notes that the mark is Lin's own, unverified disclosure. + fork.content_html = fork.content_html.replacen( + LIN_FORK_GENERATED, + &format!("{LIN_FORK_GENERATED}"), + 1, + ); reading.push(fork); reading.push(post( &lin, diff --git a/crates/blyg-app/src/onboarding/steps.rs b/crates/blyg-app/src/onboarding/steps.rs index 80811cd..ea5f42d 100644 --- a/crates/blyg-app/src/onboarding/steps.rs +++ b/crates/blyg-app/src/onboarding/steps.rs @@ -121,6 +121,9 @@ pub enum Setup { StudioSample, /// The draft with a TK scope appended, caret inside it. Tk(&'static str), + /// A thread draft whose generated text echoed a quote its instruction + /// didn't name, in the full editor (⌘3). + EchoedQuote, /// The draft pushed over 1000 characters. Long(&'static str), /// A published post with a few versions, opened. @@ -137,6 +140,9 @@ pub enum Setup { Stream, /// Reading, in the Stream, the post with a quote selected. StreamQuote, + /// Reading, in the Stream, the post with text marked as generated + /// selected, the side pane closed. + StreamGenerated, /// Reading, in the Stream, the quoting post's lineage open (⌘J). Lineage, /// Reading, in Reader mode, a post open. @@ -201,6 +207,8 @@ pub const THREAD: &str = "01J9H4C"; pub const QUOTING: &str = crate::fake::reading_seed::ADA_REPLY; /// The post it quotes (it lists the quoting post as a response). pub const QUOTED: &str = crate::fake::reading_seed::LIN_BENCH; +/// The sample reading post with a sentence its blyg marks as generated. +pub const GENERATED: &str = crate::fake::reading_seed::LIN_FORK; pub const STEPS: &[Step] = &[ Step { @@ -420,6 +428,24 @@ pub const STEPS: &[Step] = &[ settle: true, stay: false, }, + Step { + id: "echoed-quote", + since: "0.12.0", + title: "A quote the AI added", + caption: "This gap's text ends with ![[…]] on its own line, which its instruction \ + never asked for. Publishing makes that a real quote and notifies its \ + author, so the preview already shows the quote, and ⌘⏎'s sheet warns \ + before you publish. Delete the line, or name the post in the \ + instruction.", + keys: &[Key::Publish], + key_label: "⌘⏎", + region: Region::Whole, + button: Some("Publish"), + setup: Setup::EchoedQuote, + pause_ms: 1600, + settle: false, + stay: true, + }, Step { id: "mentions", since: "0.4.0", @@ -469,6 +495,22 @@ pub const STEPS: &[Step] = &[ settle: false, stay: true, }, + Step { + id: "generated", + since: "0.12.0", + title: "“Generated” is the publisher's word", + caption: "Lin's blyg marks a sentence of this post as AI-generated. Open it (⏎): \ + the marked text is tinted, hovering it says so, and the post ends with a \ + note that the mark is the publisher's own disclosure, not verified.", + keys: &[Key::ReadMore], + key_label: "⏎", + region: Region::StreamPost, + button: None, + setup: Setup::StreamGenerated, + pause_ms: 1800, + settle: false, + stay: true, + }, Step { id: "original", since: "0.4.0", diff --git a/crates/blyg-app/src/onboarding/tests.rs b/crates/blyg-app/src/onboarding/tests.rs index 7dec482..f794ae0 100644 --- a/crates/blyg-app/src/onboarding/tests.rs +++ b/crates/blyg-app/src/onboarding/tests.rs @@ -2,6 +2,8 @@ //! the test platform, the app's `SwitchBackend` in place, and a recording //! backend standing in for the user's real blyg. +use std::cell::RefCell; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -11,6 +13,7 @@ use gpui_kit::{Entity, TestAppContext, VisualTestContext}; use super::FlowStep; use super::steps::{self, Region, STEPS}; +use crate::app::studio::webview::{FactoryGlobal, PreviewSurface}; use crate::app::{MainView, Sheet}; use crate::connection::{Connection, Mode, SwitchBackend}; use crate::fake::{FakeBackend, Timing}; @@ -635,7 +638,8 @@ fn do_step(e: &mut Env, id: &str) { "versions" => e.cx.simulate_keystrokes(&crate::keymap::keys("cmd-y")), "quotes" => e.cx.simulate_keystrokes(&crate::keymap::keys("cmd-k")), "mentions" => e.cx.simulate_input("@"), - "stream" => e.cx.simulate_keystrokes("enter"), + "stream" | "generated" => e.cx.simulate_keystrokes("enter"), + "echoed-quote" => e.cx.simulate_keystrokes(&crate::keymap::keys("cmd-enter")), "original" => e.view.update_in(e.cx, |v, window, cx| { let origin = v .reading @@ -1288,3 +1292,113 @@ fn a_launch_after_updating_from_0_10_starts_at_0_11s_first_step(cx: &mut TestApp assert_eq!(tour_step(&mut e), first_new); assert_eq!(tour_step(&mut e), Some("slots")); } + +/// The first launch after updating from 0.11.0 to 0.12.0: "Show me what's +/// new" lands on the publish warning's step. +#[gpui_kit::test] +fn a_launch_after_updating_from_0_11_starts_at_0_12s_first_step(cx: &mut TestAppContext) { + super::whats_new::TEST_VERSION.with(|v| *v.borrow_mut() = Some("0.12.0")); + let mut e = setup_full(cx, CONNECTED, Some("0.11.0"), Mode::Fake, false); + super::whats_new::TEST_VERSION.with(|v| *v.borrow_mut() = None); + assert!(news_up(&e), "the card is up"); + let versions: Vec<_> = e.view.read_with(e.cx, |v, _| { + let t = v.onboarding.tutorial.as_ref().unwrap(); + t.news.iter().map(|r| r.version).collect() + }); + assert_eq!(versions, ["0.12.0"]); + e.cx.simulate_keystrokes("enter"); + e.cx.run_until_parked(); + assert!(!news_up(&e)); + let first_new = STEPS.iter().find(|s| s.since == "0.12.0").map(|s| s.id); + assert_eq!(tour_step(&mut e), first_new); + assert_eq!(tour_step(&mut e), Some("echoed-quote")); +} + +/// 0.12.0's steps land on what they teach: the publish sheet warns about +/// the quote generated text echoed, and the post opened in the stream ends +/// with the note that its generated mark is the publisher's own. +#[gpui_kit::test] +fn the_0_12_steps_show_the_warning_and_the_note(cx: &mut TestAppContext) { + let mut e = setup(cx, CONNECTED, true); + let log = Rc::new(RefCell::new(Vec::new())); + let surfaces = Rc::new(RefCell::new(0usize)); + let (l2, n2) = (log.clone(), surfaces.clone()); + e.cx.update(|_, cx| { + cx.set_global(FactoryGlobal(Rc::new(move |_, _| { + let id = { + let mut n = n2.borrow_mut(); + *n += 1; + *n - 1 + }; + Ok(Box::new(Surface(id, l2.clone())) as Box) + }))) + }); + e.cx.dispatch_action(super::ShowTutorial); + e.cx.run_until_parked(); + e.real.take(); + + enter(&mut e, "echoed-quote"); + let preview_view = e.view.read_with(e.cx, |v, _| v.studio.view); + assert_eq!(preview_view, crate::app::studio::ViewMode::Studio); + do_step(&mut e, "echoed-quote"); + assert!(tour_done(&mut e)); + let warning = e.view.update_in(e.cx, |v, _, cx| { + assert!( + matches!(v.sheet, Some(Sheet::Publish { .. })), + "the sheet is up" + ); + v.studio_publish_warning(cx) + }); + let warning = warning.expect("the sheet warns"); + assert!( + warning.contains(&format!("![[{}]]", crate::fake::SAMPLE_QUOTE_ID)), + "{warning}" + ); + assert!(warning.contains("notifies the author"), "{warning}"); + + enter(&mut e, "generated"); + let selected = e.view.read_with(e.cx, |v, _| v.reading.sel.clone()); + assert_eq!(selected.map(|k| k.1).as_deref(), Some(steps::GENERATED)); + do_step(&mut e, "generated"); + settle(&mut e); + assert!(tour_done(&mut e)); + let page = e.view.read_with(e.cx, |v, _| { + v.studio.reader.pages.last().cloned().unwrap_or_default() + }); + let note = crate::app::studio::reader::GENERATED_NOTE; + assert!(page.contains(&format!("title=\"{note}\"")), "{page}"); + assert!(page.contains("class=\"blyg-gen-note\""), "{page}"); + // The side pane is clear of the tour card: the post shows. + let log = log.borrow(); + let reader = log + .iter() + .find_map(|(i, c)| matches!(c, Shown::Load(h) if h.contains("blyg-gen-note")).then_some(*i)) + .expect("the reader loaded the post"); + let visible = log.iter().rev().find_map(|(i, c)| match c { + Shown::Visible(v) if *i == reader => Some(*v), + _ => None, + }); + assert_eq!(visible, Some(true), "the tour shows the post"); + assert_eq!(e.real.take(), Vec::<&str>::new(), "all on sample data"); +} + +enum Shown { + Load(String), + Visible(bool), +} + +/// A WebView stand-in that logs what it was asked to show. +struct Surface(usize, Rc>>); + +impl PreviewSurface for Surface { + fn set_frame(&mut self, _: gpui_kit::Bounds) {} + fn set_visible(&mut self, v: bool) { + self.1.borrow_mut().push((self.0, Shown::Visible(v))); + } + fn load(&mut self, html: &str) { + self.1.borrow_mut().push((self.0, Shown::Load(html.into()))); + } + fn eval(&mut self, _: &str) {} + fn focus_parent(&mut self) {} + fn set_dark(&mut self, _: bool) {} +} diff --git a/crates/blyg-app/src/onboarding/tutorial.rs b/crates/blyg-app/src/onboarding/tutorial.rs index 1e38aac..7847b0f 100644 --- a/crates/blyg-app/src/onboarding/tutorial.rs +++ b/crates/blyg-app/src/onboarding/tutorial.rs @@ -645,6 +645,16 @@ impl MainView { self.open(&id, window, cx); } } + Setup::EchoedQuote => { + self.tutorial_tidy(window, cx); + let md = crate::fake::echoed_quote_sample(); + if let Ok(id) = self.backend.create_draft(Kind::Thread, &md) { + self.set_query_text("", window, cx); + self.current = None; + self.open(&id, window, cx); + } + self.studio_set_view(ViewMode::Studio, cx); + } Setup::Tk(instruction) => { self.tutorial_tidy(window, cx); self.studio_set_view(ViewMode::Write, cx); @@ -711,6 +721,17 @@ impl MainView { None => self.stream_move(1, window, cx), } } + Setup::StreamGenerated => { + self.tutorial_reading(ReadMode::Stream, window, cx); + let ix = self + .reading + .shown_rows() + .position(|r| r.remote_id == steps::GENERATED); + match ix { + Some(ix) => self.stream_select(ix, window, cx), + None => self.stream_move(1, window, cx), + } + } Setup::Lineage => { self.tutorial_setup(Setup::StreamQuote, window, cx); let at = self @@ -773,7 +794,8 @@ impl MainView { t.hold = true; } match id { - "stream" => self.stream_open_selected(window, cx), + "stream" | "generated" => self.stream_open_selected(window, cx), + "echoed-quote" => self.publish(window, cx), "original" => { let origin = self .reading @@ -963,6 +985,30 @@ impl MainView { } } + /// Whether the tour hides the reading pane's WebView. A native view + /// draws over everything GPUI draws, so it stays hidden unless the post + /// it shows is clear of the tour card (bottom left): the stream's side + /// pane or the Reader's post pane on the Reading screen, in a window + /// wide enough. Never under the what's-new card or beside the lineage + /// sheet (the card moves right for it). + pub(crate) fn tutorial_hides_reader(&self, window: &Window) -> bool { + let Some(t) = self.onboarding.tutorial.as_ref() else { + return false; + }; + if !t.news.is_empty() || self.lineage_open() || self.reading.view != View::Reading { + return true; + } + let vp = window.viewport_size(); + let (w, h) = (f32::from(vp.width), f32::from(vp.height)); + let left = match self.reading.mode { + ReadMode::Stream => Some(w - stream_pane_w(w)), + ReadMode::Reader => self + .tutorial_reading_region(Region::ReaderPost, w, h) + .map(|r| r.0), + }; + left.is_none_or(|x| x < CARD_LEFT + CARD_W + 8.) + } + /// What covers the panes right now: the browser pane, the notes drawer /// and the stream's side pane (a ring around a pane under one of them /// would draw over it). @@ -1138,13 +1184,13 @@ impl MainView { // Clear of the lineage sheet's responses row. .map(|d| { if self.lineage_open() { - d.right(px(14.)) + d.right(px(CARD_LEFT)) } else { - d.left(px(14.)) + d.left(px(CARD_LEFT)) } }) .bottom(px(STATUS_H + 14.)) - .w(px(380.)) + .w(px(CARD_W)) .max_w(relative(0.9)) // --- themes --- the theme's card. .map(|d| crate::theme_ext::card(d, &self.theme)) @@ -1401,6 +1447,10 @@ impl MainView { /// (x, y, w, h) in window pixels. type Rect = (f32, f32, f32, f32); +/// The tour card's margin from the window's left edge, and its width. +const CARD_LEFT: f32 = 14.; +const CARD_W: f32 = 380.; + /// The Stream | Reader toggle's width (two segments, 11.5 px Inter). const TOGGLE_W: f32 = 124.; diff --git a/crates/blyg-app/src/onboarding/whats_new.rs b/crates/blyg-app/src/onboarding/whats_new.rs index 3ddbfcf..ef67b37 100644 --- a/crates/blyg-app/src/onboarding/whats_new.rs +++ b/crates/blyg-app/src/onboarding/whats_new.rs @@ -20,6 +20,25 @@ pub struct Release { } pub const RELEASES: &[Release] = &[ + Release { + version: "0.12.0", + items: &[ + "Burrow works with a stock blyg: an unmodified blygger-studio 0.40 or later, no \ + server patches. Reading, read marks, lineage and citations all sync.", + "Sign in with browser, or with a token from Studio › More › Client access (allow \ + reading:state so read marks sync). The old server owner token is gone from \ + sign-in.", + "If your blyg refuses Burrow's sign-in, Burrow stops syncing, keeps every change \ + waiting, and opens the sign-in sheet to say why.", + "Publishing warns when generated text left a ![[…]] on its own line that its \ + instruction didn't ask for: it would quote that post and notify its author. \ + The preview shows the quote.", + "In the Reader, text a blyg marks as generated says, on hover and at the end of \ + the post, that the mark is the publisher's own disclosure, not verified.", + "A post that drew on yours for generated text is listed as “drew on this”.", + ], + first_step: Some("echoed-quote"), + }, Release { version: "0.11.0", items: &[ @@ -228,6 +247,19 @@ mod tests { } } + #[test] + fn a_launch_after_0_11_shows_0_12() { + let news = since(Some("0.11.0"), "0.12.0"); + assert_eq!(news.len(), 1); + assert_eq!(news[0].version, "0.12.0"); + assert_eq!(news[0].first_step, Some("echoed-quote")); + let from_0_10: Vec<_> = since(Some("0.10.0"), "0.12.0") + .iter() + .map(|r| r.first_step) + .collect(); + assert_eq!(from_0_10, [Some("echoed-quote"), Some("slots")]); + } + #[test] fn a_launch_after_0_10_shows_0_11() { let news = since(Some("0.10.0"), "0.11.0"); diff --git a/crates/blyg-app/src/studio/reader.rs b/crates/blyg-app/src/studio/reader.rs index 50ebfb7..e3a6380 100644 --- a/crates/blyg-app/src/studio/reader.rs +++ b/crates/blyg-app/src/studio/reader.rs @@ -658,7 +658,8 @@ impl MainView { || self.reading.sheet.is_some() || dropdown || self.onboarding.flow.is_some() - || self.onboarding.tutorial.is_some() + // The tour shows the post only where its card isn't. + || self.tutorial_hides_reader(window) // Every GPUI overlay that can sit over the body belongs here (and // in `studio_frame`): a native view draws above all GPUI content. || self.profile_sheet_open() From a8b6358eca69b66c214aab5e07a1cd044f096911 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:42:25 -0700 Subject: [PATCH 09/10] Docs for 0.12.0: stock blyg, sign-in, publish warning, generated text - getting-started, introduction, faq, config: a stock studio 0.40+ needs no patches; no server owner token in sign-in; a refused sign-in opens the sheet (new FAQ); the 0.11 sign-in-again note softened. - writing: a quote the AI added (the publish warning); reading: Generated text section, "drew on this", read-state wording; ai: both. - dev docs, SPEC: no patched Worker; decisions #59/#60; tour notes. - Config key docs: "your sign-in" instead of the owner token. Co-Authored-By: Claude Opus 5.5 --- crates/blyg-core/src/config/keys.rs | 4 ++-- docs/SPEC.md | 20 ++++++++++++-------- site/src/ai.md | 9 +++++++++ site/src/config.md | 5 +++-- site/src/dev/architecture.md | 6 +++--- site/src/dev/building.md | 6 +++--- site/src/faq.md | 19 ++++++++++++++----- site/src/generated/config-keys.md | 4 ++-- site/src/getting-started.md | 22 +++++++++++++--------- site/src/introduction.md | 6 +++--- site/src/reading.md | 17 +++++++++++++---- site/src/writing.md | 8 ++++++++ 12 files changed, 85 insertions(+), 41 deletions(-) diff --git a/crates/blyg-core/src/config/keys.rs b/crates/blyg-core/src/config/keys.rs index 0bf40e6..a8a819f 100644 --- a/crates/blyg-core/src/config/keys.rs +++ b/crates/blyg-core/src/config/keys.rs @@ -113,7 +113,7 @@ pub const KEYS: &[KeySpec] = &[ key("blyg-url", ValueKind::Url, None), "The blyg this app writes to, e.g. https://blyg.example.com. There is no default: \ with no blyg-url the app starts by asking you to connect one.\n\ - The owner token for this blyg is kept in the macOS Keychain, never in this file.", + Your sign-in for this blyg is kept in the macOS Keychain, never in this file.", ), docs( key("theme", ValueKind::ThemeName, Some("system")), @@ -302,7 +302,7 @@ pub const KEYS: &[KeySpec] = &[ you name it here. Extensions are the bundled markdown-notes and folders in \ ~/.config/blygger/extensions/; `blygger +list-extensions` lists them.\n\ An extension is a separate program that talks to Burrow over its standard input \ - and output. It never sees your owner token, API keys or the database: Burrow makes \ + and output. It never sees your blyg sign-in, API keys or the database: Burrow makes \ every call for it, and only the ones extension-allow grants.", ), docs( diff --git a/docs/SPEC.md b/docs/SPEC.md index 645ddad..c908a31 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -2,7 +2,8 @@ Burrow is a blygger client (called Blygger Desktop up to 0.6.0; only the displayed name changed, see "Naming" below). A native macOS client for a Blygger blog ("blyg") running the Blygger reference -Worker plus the owner-API extensions in `docs/SERVER.md`. Goal: **zero +Worker (blygger-studio); a stock studio 0.40 or later is fully supported, and +`docs/SERVER.md` says what each version brings. Goal: **zero friction between a thought and a published post.** The feel target is Notational Velocity: one window, type to search, ⏎ to create, nothing ever waits on the network. @@ -222,7 +223,7 @@ valid in threads. See upstream `worker/src/transclusion.ts` for which ids resolv ## Safety - **Never write to the production blyg while developing.** Test against a mock - HTTP server (core) or a local `wrangler dev` of a patched reference Worker (integration). + HTTP server (core) or a local `wrangler dev` of the reference Worker (integration). The first real publish belongs to the user. - Never log or print the token. @@ -232,7 +233,7 @@ valid in threads. See upstream `worker/src/transclusion.ts` for which ids resolv **The stream is the default reading view** (`reading/stream.rs`, issue #1). A Stream | Reader toggle heads the reading screen (⌥⌘1 / ⌥⌘2, remembered in `state.json` as `reading_mode`). Stream: one native GPUI timeline in a virtualized `list`, newest first, drawn from each post's Markdown (`blyg_render::native_blocks`, the same markdown-it engine; no WebView per post). Each post shows its author and blyg, date, kind and the lineage line (↳ stub of / ⑂ forked from); fragments show in full, threads their title, about four lines and "Read more". A `![[id]]` is a grey quote box with the quoted text when it's held here, else the excerpt the quoting blyg cited (`transclusions[].cited`, protocol 0.3), else a note that it isn't held. An inline `[[id]]` link (protocol 0.3 §16.2) reads as the held target's excerpt in quotes, as the published page's anchor does (else "linked post"), and opens the post like a quote box. Images, embeds and tables are compact placeholders. j/k (↑/↓) select, and the selected post shows the actions (Read more, Quote into a thread, Reply · new stub, AI reply, Link post, Open on web, thumbs). ⏎, Space or "Read more" open the post in a side pane (the sanitized WebView reader with its pill and actions); j/k then move the pane along; esc closes it and the stream keeps its place. A post is read once it has been at least half on screen (or filled half the screen) for one second while the window is active (`stream_vm::ReadTracker`, `Backend::mark_read`); a post opened in the pane is read too. Unread posts get a dot, never a count. The search filters the stream as it filters the list. Reader is the list + post layout below. -**Reader is three panes, NetNewsWire style** (`reading/sources.rs`, `sources_vm.rs`): sources | posts | the post. The sources pane lists smart feeds (All unread: never read or edited since you read it; Today: the post's own date, local time; Thumbed: 👍; All, the default), then the user's **folders** with their subscriptions, then the subscriptions in no folder. Each subscription shows its name (or host) with its cached profile avatar, else a letter badge, and a dot when something is unread; smart feeds and folders show a muted unread count (reader-local, private: allowed by rule 1). Picking a source filters the list (the search is scoped to it); a post read in a source stays listed until the source changes. **Folders are local only**: the store's `folders` / `folder_members` tables (schema v7), one folder per subscription, never sent to the blyg and not the server's hoppers (`Backend::folders`, `subscription_folders`, `create_folder`, `rename_folder`, `delete_folder`, which unfiles, `move_folder`, `set_subscription_folder`). File a subscription by dragging it onto a folder (onto the "Subscriptions" heading unfiles it), from its context menu (Move to folder ›), or on the Subscriptions screen; folders are made with Blyg › New Folder… or the pane's context menu, renamed, deleted, and reordered by dragging or Move Up / Move Down. Keys: ←/→ move between the panes, ↑/↓ within one (the sources pane picks as it moves; in the post pane they scroll), j/k next/previous post from anywhere, ⏎ goes right, Space pages through the post and then opens the next unread one, esc returns to the list, [ / ] step through versions (←/→ still do in the stream's pane). ⌥⌘S (Blyg › Sources Pane, or the header's button) hides the pane; its edge drags to resize (`state.json`: `reader_sources_hidden`, `reader_sources_width`). **Picking and marking** (`reading/marks.rs`, model `pick_vm.rs`): ⌘-click adds or drops a post, ⇧-click adds the run from the anchor, ⌘A picks everything the list shows, esc lets go; **r** marks the picks (else the open post) read and **u** unread, as does the row menu (right-click: Mark Read / Mark N Unread). Marks are local and instant (`Backend::set_read`); how they reach the server is docs/SERVER.md § Extension 5. **Subscriptions** (studio 0.30): a subscription's menu in the pane has **Rename…** (a name of your own, kept when the blyg renames itself), **Use the Blyg's Own Name** once renamed (`title: null`; `Subscription::title_follows_source`), and **Check Now** (a blyg's index reconciled at once, `POST /subscriptions/{id}/resync`; a feed is polled with the rest); the pane's menu and **⇧⌘R** (Blyg › Check All Feeds Now) poll every feed (`POST /subscriptions/poll`). A pull follows each, and another a few seconds later. +**Reader is three panes, NetNewsWire style** (`reading/sources.rs`, `sources_vm.rs`): sources | posts | the post. The sources pane lists smart feeds (All unread: never read or edited since you read it; Today: the post's own date, local time; Thumbed: 👍; All, the default), then the user's **folders** with their subscriptions, then the subscriptions in no folder. Each subscription shows its name (or host) with its cached profile avatar, else a letter badge, and a dot when something is unread; smart feeds and folders show a muted unread count (reader-local, private: allowed by rule 1). Picking a source filters the list (the search is scoped to it); a post read in a source stays listed until the source changes. **Folders are local only**: the store's `folders` / `folder_members` tables (schema v7), one folder per subscription, never sent to the blyg and not the server's hoppers (`Backend::folders`, `subscription_folders`, `create_folder`, `rename_folder`, `delete_folder`, which unfiles, `move_folder`, `set_subscription_folder`). File a subscription by dragging it onto a folder (onto the "Subscriptions" heading unfiles it), from its context menu (Move to folder ›), or on the Subscriptions screen; folders are made with Blyg › New Folder… or the pane's context menu, renamed, deleted, and reordered by dragging or Move Up / Move Down. Keys: ←/→ move between the panes, ↑/↓ within one (the sources pane picks as it moves; in the post pane they scroll), j/k next/previous post from anywhere, ⏎ goes right, Space pages through the post and then opens the next unread one, esc returns to the list, [ / ] step through versions (←/→ still do in the stream's pane). ⌥⌘S (Blyg › Sources Pane, or the header's button) hides the pane; its edge drags to resize (`state.json`: `reader_sources_hidden`, `reader_sources_width`). **Picking and marking** (`reading/marks.rs`, model `pick_vm.rs`): ⌘-click adds or drops a post, ⇧-click adds the run from the anchor, ⌘A picks everything the list shows, esc lets go; **r** marks the picks (else the open post) read and **u** unread, as does the row menu (right-click: Mark Read / Mark N Unread). Marks are local and instant (`Backend::set_read`); how they reach the server is docs/SERVER.md § Read-state sync. **Subscriptions** (studio 0.30): a subscription's menu in the pane has **Rename…** (a name of your own, kept when the blyg renames itself), **Use the Blyg's Own Name** once renamed (`title: null`; `Subscription::title_follows_source`), and **Check Now** (a blyg's index reconciled at once, `POST /subscriptions/{id}/resync`; a feed is polled with the rest); the pane's menu and **⇧⌘R** (Blyg › Check All Feeds Now) poll every feed (`POST /subscriptions/poll`). A pull follows each, and another a few seconds later. **OPML import and export** (`blyg_core::opml`, `reading/opml.rs`; blygger/blygger-studio#63). Blyg › Import Subscriptions from OPML… (and Import OPML… on the Subscriptions screen; no key) opens a file picker, then a sheet in the Publish/Delete chrome listing the file's feeds: one OPML parser (`opml::outlines`, also behind `profile::parse_opml`) reads OPML 1.0/2.0 leniently (nested outlines flattened, the folder kept for display only, `xmlUrl` required, `title` else `text`, HTML entities and double escaping undone, BOM / UTF-16 / Windows-1252-labelled-UTF-8 decoded), at most 5 MB and 2,000 feeds, never fetching. Duplicates collapse by normalised feed URL; a feed already followed (same normalised feed URL, or under a blyg subscription's origin) shows "already following" and starts unticked. Keys: 1–9 / Space tick, ↑/↓, ⌘A all or none, ⏎ Import N, esc cancel. The run (`opml::run_import`, off the main thread) sends `POST /api/subscriptions {url, confirm:true}` per feed with no title (the source names itself) and no separate resolve, two at a time with starts ≥ 2 s apart (≤ 30 a minute, far under the owner write budget, because each subscribe backfills the archive server-side); a 429 holds every worker for its `Retry-After` and retries that feed (5 times at most). 201 is added, 409 already following, 422 not a feed (the server's reason shown), Offline unreachable, anything else refused with its reason; nothing fails the whole run. Esc stops after the requests in flight. Each added subscription is filed (by the id in the 201) in the local Reader folder **Imported feeds**, made on first use and reused by name; 409s are never moved, and the OPML's folders never become folders. `createSubscription` has no `in_blogroll` member and new rows default to `in_blogroll = 0`, so imports stay out of the public blogroll without a PATCH. The summary gives the counts and each failure with Retry failed (r). Blyg › Export Subscriptions as OPML… saves OPML 2.0 of every subscription (save dialog, `burrow-subscriptions.opml`), in the studio's `blogroll.opml` shape: `text`/`title` the name, `type="rss"`, `xmlUrl` the feed, `htmlUrl` the origin. Both need a connected blyg. CLI: `blygger +import-opml [--dry-run]` (skips feeds already followed, files into the same folder; opens the local database without the sync worker) and `blygger +export-opml `. @@ -252,7 +253,10 @@ valid in threads. See upstream `worker/src/transclusion.ts` for which ids resolv ## AI / TK -The blyg's AI feature is TK: `[TK]instruction[/TK]` → `[TK]instruction[=]output[/TK]`. See `docs/BLYGGER-SPEC-DIGEST.md` §AI and the mock at `docs/prototype/ai-and-reading.html`. Text generated in the app must be recorded as provenance (the provenance extension in `docs/SERVER.md`) so that it's disclosed as `blyg-tk-gen`. The app never offers a direct claude.ai or ChatGPT subscription login (Anthropic prohibits it; OpenAI has no sanctioned route). +The blyg's AI feature is TK: `[TK]instruction[/TK]` → `[TK]instruction[=]output[/TK]`. See `docs/BLYGGER-SPEC-DIGEST.md` §AI and the mock at `docs/prototype/ai-and-reading.html`. Text generated in the app must be recorded as provenance (studio 0.28+'s `tk-provenance` route, `docs/SERVER.md`) so that it's disclosed as `blyg-tk-gen`. + +- **Quotes generated text echoed (decision #60).** An own-line `![[id]]` in a scope's output is a real quote once a thread is published, and notifies the quoted blyg. When the scope's instruction never named that id (usually a model echoing a directive), the publish sheet warns before publishing (`studio::echoed_quote_warning`, over `blyg_render::unrequested_output_directives`), and the preview already renders it as the quote publish will make. +- **The generated mark is the publisher's claim (spec §5.7, decision #59).** In the reading pane, every `blyg-tk-gen` span or block gets a tooltip, and the post ends with a small note (`studio::reader::GENERATED_NOTE`): the disclosure is the publisher's own, not verified. The app never offers a direct claude.ai or ChatGPT subscription login (Anthropic prohibits it; OpenAI has no sanctioned route). ## AI providers & sign-in @@ -273,9 +277,9 @@ Disclosure: **always on**. Edited reading items move **to the top**. Helpers in ## Onboarding & tutorial -The first launch is an onboarding flow: connect a blyg (URL plus token, or owner password), then optionally connect AI accounts. After that comes an **interactive tutorial** that walks through the features (the ▶ demos in the mocks are the script), with a "Show this tutorial every time I open Burrow" checkbox. It can be re-enabled or replayed from Settings › Help. **After an update**, the first launch of a newer version opens the tutorial on a "What's new in Burrow x.y" card (`onboarding/whats_new.rs`, compared with `seen_version` in state.json): ⏎ starts at the first new step, "Take the whole tour" starts at the beginning, esc or "Skip tutorial" closes it. A first run never sees it. +The first launch is an onboarding flow: connect a blyg (its address, then Sign in with browser, an API token from Studio → More → Client access, or the studio password), then optionally connect AI accounts. After that comes an **interactive tutorial** that walks through the features (the ▶ demos in the mocks are the script), with a "Show this tutorial every time I open Burrow" checkbox. It can be re-enabled or replayed from Settings › Help. **After an update**, the first launch of a newer version opens the tutorial on a "What's new in Burrow x.y" card (`onboarding/whats_new.rs`, compared with `seen_version` in state.json): ⏎ starts at the first new step, "Take the whole tour" starts at the beginning, esc or "Skip tutorial" closes it. A first run never sees it. -**Every release updates the tour and the docs (the maintainer's rule).** A new feature gets a step in `onboarding/steps.rs` that lands on it (its `since` is the release), steps whose UI changed are edited, and the release's what's-new entry starts at the first of its own steps; `whats_new.rs`'s tests fail otherwise, and fail for an x.y.0 version with no entry. A step that teaches the running version's news says "NEW IN x.y.z" on its card. The tour runs on the FakeBackend and also on **sample extensions** (`extensions/tour.rs`): the user's extension host is parked and a tour host runs the bundled markdown-notes (two sample folders allowed, one not), reading-time and inspect over invented notes in `/tour-sample/`, deleted when the tour ends (and cleared when the next one starts); nothing in the tour writes the config file (Allow, Turn on/off, Forget permissions and the notes folders' Add and Remove say what they would do instead). The cross-post step shows a sample run in the pane (■ Stop, then "macro running · show pane" once folded) with nothing running, and ✂ Clip on the sample page clips its sample text. The `opml` step rings the Subscriptions screen's header; its **Import OPML…** chip opens the preview of a sample export (one feed already followed, two new) instead of a file picker, and importing it subscribes in the sample data. +**Every release updates the tour and the docs (the maintainer's rule).** A new feature gets a step in `onboarding/steps.rs` that lands on it (its `since` is the release), steps whose UI changed are edited, and the release's what's-new entry starts at the first of its own steps; `whats_new.rs`'s tests fail otherwise, and fail for an x.y.0 version with no entry. A step that teaches the running version's news says "NEW IN x.y.z" on its card. The tour runs on the FakeBackend and also on **sample extensions** (`extensions/tour.rs`): the user's extension host is parked and a tour host runs the bundled markdown-notes (two sample folders allowed, one not), reading-time and inspect over invented notes in `/tour-sample/`, deleted when the tour ends (and cleared when the next one starts); nothing in the tour writes the config file (Allow, Turn on/off, Forget permissions and the notes folders' Add and Remove say what they would do instead). The cross-post step shows a sample run in the pane (■ Stop, then "macro running · show pane" once folded) with nothing running, and ✂ Clip on the sample page clips its sample text. The `opml` step rings the Subscriptions screen's header; its **Import OPML…** chip opens the preview of a sample export (one feed already followed, two new) instead of a file picker, and importing it subscribes in the sample data. The reading pane's WebView draws over GPUI, so the tour shows it only where it is clear of the tour card (the stream's side pane, or the Reader's post pane, in a wide enough window; `tutorial_hides_reader`). 0.12.0's steps open a sample thread whose TK output echoed a quote (`echoed-quote`, ⌘⏎ shows the warning) and Lin's fork with a sentence marked generated (`generated`, ⏎ shows the note). ## Protocol philosophy → UI rules (from the creator's talk, blygger.org/talks/2026-09-24-blygger/) @@ -283,7 +287,7 @@ Core principle: *"anything the protocol can't verify, it declines to represent." 1. **No counts, anywhere social.** Responses and mentions are shown as *a list, never a count*: who, origin, relation, when. Mentions and responses never get numeric badges; use a dot for "something new". There are no follower lists or follower counts: following is client-local and invisible. (A reader-local unread count for *your own* reading list is fine; it's private state, not a social metric. So are a post's word count and reading time, which describe the text, not its audience: the opt-in `reading-time` extension.) **One exception, the lineage glyph (2026-10-09, the user's decision).** The lineage glyph beside a post, the ⌘J lineage view and its action ring show *how many* posts a post draws on and how many known here draw on it, by kind (stub, quote, fork), next to the kinds, because the web Studio's `lineage-glyph` extension shows the same numbers and the data is there. The numbers are blygger-studio's, counted by its rules (from the node when it serves the extension, else from this Mac). The exception covers those three surfaces only: responses lists, mentions, profiles and everything else social stay lists without counts. -2. **Generation happens in the studio, at authoring time, with review.** Publishing never generates. The TK tint shows in the editor only; published bytes are identical, apart from the `generated` metadata and the `blyg-tk-gen` class. Provenance is self-asserted, so the app always records it (the provenance extension). +2. **Generation happens in the studio, at authoring time, with review.** Publishing never generates. The TK tint is an authoring aid: published bytes are identical, apart from the `generated` metadata and the `blyg-tk-gen` class. Provenance is self-asserted, so the app always records it (studio 0.28+'s `tk-provenance` route), and the reading pane, which tints someone else's marked text, says the mark is the publisher's own and unverified. 3. **Transclusion is quoting, and it's snapshotted at publish.** Later edits to the source never rewrite the quote. The picker offers only what's already held (your own posts plus imported items from blyg subscriptions). It never fetches by URL. The picker opens with ⌘K, or by typing `![[` at the start of a line in a thread (after optional indent, outside a fenced code block): the typed `![[` comes out, what you type next filters the picker, ⏎ inserts the whole `![[id]]` line, and esc puts the `![[` back so it can be typed literally. Only typing triggers it, never a paste. In a fragment it shows the "Quotes go in threads" toast and leaves the text alone. 4. **Forking descends from pins only.** Pins are the costly, irrevocable signal: confirm with plain words ("This version will be served forever. You can't undo this."). 5. **No deletes of published work. Withdraw instead**: permanent, visible, and irreversible. Only drafts can be discarded. The UI says "Withdraw", never "Delete", for published items. @@ -292,7 +296,7 @@ Core principle: *"anything the protocol can't verify, it declines to represent." 8. **Titles are the author's own words.** Items stay titleless on the wire; the app derives a title (`blyg_core::plain_title`) from the first line of text, so a leading heading is the title on every surface (posts list, reading list, stream). Blockquote lines are skipped, including the `>` run attached to a `![[id]]` (a partial transclusion), so a thread is never named by what it quotes; a stub with nothing of its own yet reads "In response to <host>" (blygger-studio 0.8.2). 9. **Tolerate the unknown.** Ignore unknown kinds and fields; never reject. The wire is v0.3 and pre-1.0 unstable. -## Client-recorded provenance (owner-API extension, see `docs/SERVER.md`) +## Client-recorded provenance (studio 0.28+, see `docs/SERVER.md`) - `PUT /api/items/:id/tk-provenance {content_md?, scopes: [ {index, model, sources?:[{id,version}], at?} | null ]}` → `{ok, disclosed}`. Validation runs first and nothing is written on a 400. `scopes.length` must equal the number of TK scopes in the (new) working copy. - `GET /api/items/:id/tk-provenance` → `{scopes: [...]}`. diff --git a/site/src/ai.md b/site/src/ai.md index 59ef171..6b94daa 100644 --- a/site/src/ai.md +++ b/site/src/ai.md @@ -72,5 +72,14 @@ your blyg's studio is older, Burrow warns you before publishing generated text, and Cancel is the default. Proofreading isn't generated prose and isn't disclosed. +If generated text in a thread holds a quote (`![[id]]` on its own line) that +the gap's instruction didn't ask for, the publish sheet warns you: publishing +would quote that post and notify its author (see +[TK: gaps for later](writing.md#tk-gaps-for-later)). + +When you read other blygs, text they mark as generated is the publisher's own +disclosure: Burrow shows it, and says it isn't verified (see +[Generated text](reading.md#generated-text)). + How the providers, sign-in, prompts and provenance work inside is in [blyg-ai](dev/ai.md). diff --git a/site/src/config.md b/site/src/config.md index 319df66..c5c3757 100644 --- a/site/src/config.md +++ b/site/src/config.md @@ -6,8 +6,9 @@ optional. Most options can also be changed in Settings (⌘,), which writes them back to the file and keeps your comments. **Burrow › Open Config File** opens it, and ⇧⌘, reloads it after you edit it by hand. -**Secrets never go in this file.** The blyg owner token and AI API keys are -stored in the macOS Keychain, and the app never logs them. +**Secrets never go in this file.** Your blyg sign-in (browser grant, API +token or password) and AI API keys are stored in the macOS Keychain, and the +app never logs them. ## The file format diff --git a/site/src/dev/architecture.md b/site/src/dev/architecture.md index bc11202..85f7b54 100644 --- a/site/src/dev/architecture.md +++ b/site/src/dev/architecture.md @@ -23,7 +23,7 @@ crates/blyg-app the GPUI app. It talks only to Arc, and has a typing stops, and pulls the server's changes periodically. - **No async runtime in the app.** Network calls are blocking `ureq` (rustls) on background threads. -- **Secrets** (the owner token, AI keys) live in the macOS Keychain, never in +- **Secrets** (the blyg sign-in, AI keys) live in the macOS Keychain, never in a file and never in logs. - **One table each** for configuration keys (`blyg-core/src/config/keys.rs`) and keyboard shortcuts (`blyg-app/src/keymap.rs`). Defaults, validation, @@ -45,8 +45,8 @@ crates/blyg-app the GPUI app. It talks only to Arc, and has a parts of the Blygger protocol a desktop client needs. - [Extensions (BXP)](extensions.md) (`docs/EXTENSIONS.md`): the extension protocol, manifests, capabilities and the host's lifecycle. -- [Server requirements](../server.md) (`docs/SERVER.md`): the owner-API - extensions the app needs. +- [Server requirements](../server.md) (`docs/SERVER.md`): what each + blygger-studio version brings, and the old fork's legacy extensions. These pages include the files in `docs/` directly, so the repository and this site never disagree. diff --git a/site/src/dev/building.md b/site/src/dev/building.md index c559175..6e90bba 100644 --- a/site/src/dev/building.md +++ b/site/src/dev/building.md @@ -110,9 +110,9 @@ view models, keymap, menus and toolbar. ## End-to-end tests End-to-end tests run blyg-core against a real Worker under `wrangler dev ---local` (never a deployed blyg). Point them at a Worker checkout that carries -the extensions in [Server requirements](../server.md) (blygger-studio 0.9 or -later; build it first): +--local` (never a deployed blyg). Point them at a checkout of the reference +Worker (a stock blygger-studio; see [Server requirements](../server.md); +build it first): ```sh (cd /path/to/worker && npm install && npm run build) diff --git a/site/src/faq.md b/site/src/faq.md index 1c8441e..49d3c31 100644 --- a/site/src/faq.md +++ b/site/src/faq.md @@ -42,7 +42,7 @@ your blyg. Only what a feature needs, when you use it: -- your blyg, with your owner token; +- your blyg, with your sign-in; - other blygs' **public** files, without your token, when you open a profile or a quoted post you don't hold; - GitHub, about once a day, to check for updates (`auto-update = off` stops @@ -83,16 +83,25 @@ Studio shows. ### Do I need to sign in again after updating? -Once, after updating to 0.11 or later, if your blyg runs blygger-studio 0.39 -or later and you read on more than one Mac. Studio 0.39 keeps read state -behind its own permission (`reading:state`), which a sign-in made before -then doesn't have. Until you sign in again, posts you read are marked read on +Usually not. Only if your sign-in was made before your blyg ran +blygger-studio 0.39, and you read on more than one Mac: studio 0.39 keeps read +state behind its own permission (`reading:state`), which an older sign-in +doesn't have. Until you sign in again, posts you read are marked read on this Mac and wait to be sent; everything else syncs as usual, and Burrow says once that read state is waiting. To sign in again: **Burrow › Disconnect…** (keep the local copy), then connect with **Sign in with browser** and allow every permission. With an API token, make a new one in the studio's **More › Client access** that includes reading:state. +### Why did Burrow open the sign-in sheet by itself? + +Your blyg refused Burrow's sign-in: an API token expired (they last 30 days) +or was revoked, a browser sign-in ended, or the server changed (a blyg that +moved from the old patched Worker to a stock studio no longer takes the +old owner token). Burrow stops syncing rather than retrying, keeps every +change on your Mac, and says why. Sign in with your browser, or paste a new +token from the studio's **More › Client access**, and what waited is sent. + ### Can I bring my feeds from another reader? Yes. **Blyg › Import Subscriptions from OPML…** (or **Import OPML…** on the diff --git a/site/src/generated/config-keys.md b/site/src/generated/config-keys.md index d4cdb35..e8d2490 100644 --- a/site/src/generated/config-keys.md +++ b/site/src/generated/config-keys.md @@ -8,7 +8,7 @@ The blyg this app writes to, e.g. https://blyg.example.com. There is no default: with no blyg-url the app starts by asking you to connect one. -The owner token for this blyg is kept in the macOS Keychain, never in this file. +Your sign-in for this blyg is kept in the macOS Keychain, never in this file. ### `theme` @@ -213,7 +213,7 @@ Show the interactive tutorial every time Burrow opens. The very first launch alw An extension to run, by name (lowercase kebab-case), e.g. extension = markdown-notes. Repeat the key for each one. None by default: nothing runs until you name it here. Extensions are the bundled markdown-notes and folders in ~/.config/blygger/extensions/; `blygger +list-extensions` lists them. -An extension is a separate program that talks to Burrow over its standard input and output. It never sees your owner token, API keys or the database: Burrow makes every call for it, and only the ones extension-allow grants. +An extension is a separate program that talks to Burrow over its standard input and output. It never sees your blyg sign-in, API keys or the database: Burrow makes every call for it, and only the ones extension-allow grants. ### `extension-allow` diff --git a/site/src/getting-started.md b/site/src/getting-started.md index bc0f31f..b6eff76 100644 --- a/site/src/getting-started.md +++ b/site/src/getting-started.md @@ -20,8 +20,8 @@ Replay it from **Help › Burrow Tutorial** or **Settings (⌘,) › Help**, or **After an update**, the first launch opens the tour on a **What's new** card: ⏎ starts at the steps that teach the new things (each says **NEW IN** its version), **Take the whole tour** starts at the beginning, and esc skips -it. After updating to 0.11, sign in again once if you read on several Macs -(see [the FAQ](faq.md#do-i-need-to-sign-in-again-after-updating)). +it. If your read marks stop syncing between Macs after an update, see +[the FAQ](faq.md#do-i-need-to-sign-in-again-after-updating). **Coming from another feed reader?** Blyg › Import Subscriptions from OPML… brings its feeds along (see the [FAQ](faq.md#can-i-bring-my-feeds-from-another-reader)). @@ -34,10 +34,10 @@ brings its feeds along (see the [FAQ](faq.md#can-i-bring-my-feeds-from-another-r ## Connecting your blyg -Burrow works with any blyg running blygger-studio 0.9 or later (0.32 is current). -A server with the extensions in [Server requirements](server.md) gets a few -more features; without them, Burrow tells you what's limited. No blyg yet? Try -the app on the sample data. +Burrow works with any blyg running blygger-studio 0.9 or later, and fully with +a stock, unmodified studio 0.40 or later: no server patches. An older studio +limits a few things, and Burrow tells you which (see +[Server requirements](server.md)). No blyg yet? Try the app on the sample data. The Connect step asks for your blyg's address (such as `https://blyg.example.com`, including any path it lives under) and how to sign @@ -49,9 +49,8 @@ in: if you revoke it in the studio (**More › Client access**), it asks you to sign in again. - **API token**: make one in your studio under **More › Client access** - (REST API, with every permission, including reading:state on studio 0.39+) - and paste it. A server with Burrow's extensions also takes its - `BLYG_OWNER_TOKEN` here. + (REST API, with every permission; allow **reading:state**, on studio 0.39+, + so read marks sync) and paste it. It lasts 30 days. - **Studio password**, for a studio older than 0.28. It checks them with the server before saving anything, and says plainly what's @@ -69,6 +68,11 @@ older than 0.9, Burrow says so when it starts and pushes nothing until it's updated. **Burrow › Disconnect…** forgets both, and can also delete the local copy. +If your blyg later refuses Burrow's sign-in (a token expired or was revoked, +or the server changed), Burrow stops syncing instead of retrying, keeps every +change waiting on your Mac, and opens the sign-in sheet to say why. Signing in +again sends what waited. + | | | |---|---| | ![Connected to a blyg](screenshots/live-list.png) | ![Checking the token](screenshots/connect-check.png) | diff --git a/site/src/introduction.md b/site/src/introduction.md index c163d84..e3816ae 100644 --- a/site/src/introduction.md +++ b/site/src/introduction.md @@ -45,9 +45,9 @@ written in Rust with [GPUI](https://www.gpui.rs), the UI framework behind Zed. - **Configure** it in one plain-text file, Ghostty-style. See [Configuration](config.md). -Burrow needs a blyg with a few owner-API extensions that aren't in upstream -Blygger yet (see [Server requirements](server.md)). You can try it without -one: the first launch offers sample data. +Burrow works with a stock blyg: an unmodified blygger-studio 0.40 or later +needs no server patches (see [Server requirements](server.md)). You can try +it without one: the first launch offers sample data. ## Disclaimer diff --git a/site/src/reading.md b/site/src/reading.md index 0398d09..3104f57 100644 --- a/site/src/reading.md +++ b/site/src/reading.md @@ -45,9 +45,9 @@ Unread*). ![Picking posts in the Reader](screenshots/reader-picks.png) -**Read state syncs** between your Macs through your blyg when its server keeps -read state (see [Server requirements](server.md)), and marking unread syncs -when it also supports clearing it. Otherwise it stays on this Mac, and a +**Read state syncs** between your Macs through your blyg on blygger-studio +0.39 or later, when your sign-in allows **reading:state** (see +[Server requirements](server.md)), and marking unread syncs too. Otherwise it stays on this Mac, and a post you marked unread stays unread here until you read it again. **Subscriptions** take their name from the blyg or feed, and follow it when @@ -167,7 +167,16 @@ lines under a post work the same way. Each post lists who quoted, stubbed or forked it, from the blygs you follow and your own published posts: a list, never a count. A post that both stubs and quotes another is one response, listed as the stub, and a reply to a -passage says so. +passage says so. A post whose generated text used this one as a source is +listed as **drew on this**. + +## Generated text + +A blyg can mark text in its posts as AI-generated. In the stream's side pane +and the Reader, marked text is tinted, hovering it says it's marked as +generated, and the post ends with a short note: the mark is the publisher's +own disclosure, and Burrow can't verify it. Text without a mark may have been +generated too; a mark only says what the publisher chose to disclose. ## Lineage diff --git a/site/src/writing.md b/site/src/writing.md index 35ec0dd..62ffff5 100644 --- a/site/src/writing.md +++ b/site/src/writing.md @@ -189,6 +189,14 @@ regenerates. The preview tints generated text, and it's always disclosed as generated when you publish; an unfilled gap shows as `⚠ ungenerated` in the preview. See [AI helpers](ai.md). +**A quote the AI added.** In a thread, a `![[id]]` on its own line inside a +gap's generated text is a real quote once you publish: it's baked into the +post and notifies that post's author. If the gap's instruction didn't name +that post (a model sometimes echoes a quote it saw), the preview shows it as +the quote publishing will make, and the publish sheet warns before you +publish. Delete the line, or name the post in the instruction if you meant +it. + **Highlighting generated text** (studio 0.27 or later): the blyg's public pages can tint generated passages and mark them with a small robot that says what you disclosed. Your blyg's settings (in the Reader's site settings) set From a28f4b35553b15bbbc4c153094308d9a51cf50d3 Mon Sep 17 00:00:00 2001 From: Aneesh Sathe Date: Tue, 13 Oct 2026 22:45:47 -0700 Subject: [PATCH 10/10] Bake the sample fork's HTML as an origin would; changelog sign-in wording Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- crates/blyg-app/src/fake_reading.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d4daf..5085b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ teaches something new in the tour, set the newest entry's `version` in ### Changed - **Works with a stock blyg.** Burrow now runs fully against an unmodified Blygger Studio (0.40 and later, checked against 0.46.1), with no server patches. Reading syncs from the studio's own imported feed (`GET /api/reading/imported`, paged by cursor), read marks sync through its read-state routes, and lineage, citations, the full author and disclosed generation sources come with each post. Servers running the older owner-API extensions keep working. [Server requirements](https://aneeshsathe.github.io/blygger-desktop/server.html) and `docs/SERVER.md` describe what's needed. -- **Sign in with a studio token or the browser.** The server owner token from the old extensions is gone from sign-in. Use Sign in with browser, or a token made in Studio › Access (allow `reading:state` so read marks sync). +- **Sign in with a studio token or the browser.** The server owner token from the old extensions is gone from sign-in. Use Sign in with browser, or a token from Studio → More → Client access (allow `reading:state` so read marks sync). If your blyg dropped the old owner token, Burrow opens the sign-in sheet after updating; sign in once and everything queued is sent. - **Preview matches the studio 0.46.1 renderer.** Embeds are no longer baked into the HTML (the poster still shows), and `[TK]`, `[=]` and `[/TK]` inside code anywhere in a scope are plain text (studio 0.41.1). - **"Drew on this".** A mention whose relation is `source` (a post that used yours as a generation source) shows as "drew on this". diff --git a/crates/blyg-app/src/fake_reading.rs b/crates/blyg-app/src/fake_reading.rs index e8a8df3..31a8ce6 100644 --- a/crates/blyg-app/src/fake_reading.rs +++ b/crates/blyg-app/src/fake_reading.rs @@ -331,6 +331,13 @@ pub fn seed(now: DateTime) -> Seed { "Lin", "Tides, revised\n\nForked from Ada's pinned first draft. Kit put it best:\n\n![[01K2KIT0T1DES0000000000001]]\n\n> A promise the sea never signed.\n\nAnd yet we plan the day around them, as if the moon owed us something.\n\n```\nhigh 06:12 4.1m\nlow 12:30 0.6m\n```", ); + // An origin serves its HTML baked: the directive's line is gone and the + // attached excerpt is the quote. + fork.content_html = blyg_render::render_markdown( + &fork + .content_md + .replace(&format!("![[{KIT_TIDES}]]\n\n"), ""), + ); fork.created = Some((now - d(2)).to_rfc3339()); fork.updated = Some((now - h(30)).to_rfc3339()); fork.forked_from = Some(RemoteRef {