Skip to main content

nula_core/nips/
nip23.rs

1//! [NIP-23] Long-form Content.
2//!
3//! Defines `kind: 30023` — an **addressable** Markdown article,
4//! identified by the tuple `(pubkey, 30023, d)` — and its draft
5//! sibling `kind: 30024`. Four optional metadata pieces are pinned
6//! by the spec (everything else travels as ad-hoc tags or inline
7//! Markdown):
8//!
9//! - `title` — article title;
10//! - `image` — hero-image URL;
11//! - `summary` — short description;
12//! - `published_at` — stringified unix seconds of the first publish.
13//!
14//! Hashtags flow through repeated `t` tags, exactly like short
15//! text notes.
16//!
17//! # Authoring vs reading
18//!
19//! - Author with [`EventBuilder::long_form_article`] /
20//!   [`EventBuilder::long_form_draft`]. Both consume an
21//!   [`Article`], which groups the spec-standard fields in one
22//!   place and emits exactly one `d`, `title`, `image`, `summary`,
23//!   and `published_at` tag.
24//! - Read with [`Article::from_event`], which reverses the mapping,
25//!   tolerates missing optional fields. The draft-vs-published
26//!   distinction lives on the containing event's [`Kind`]
27//!   ([`KIND_LONG_FORM_ARTICLE`] vs [`KIND_LONG_FORM_DRAFT`]) rather
28//!   than the bundle itself.
29//!
30//! # Spec fidelity
31//!
32//! - Markdown content is left untouched: the spec forbids embedded
33//!   HTML and hard-wrapped paragraphs but does not mandate a
34//!   canonicalizer, so we decline to invent one.
35//! - `published_at` is serialised as **seconds as a stringified
36//!   `u64`** per the spec ("stringified unix seconds"), even though
37//!   that is awkwardly different from most other timestamp tags.
38//!
39//! [NIP-23]: https://github.com/nostr-protocol/nips/blob/master/23.md
40
41use thiserror::Error;
42
43use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags};
44use crate::types::Timestamp;
45
46/// `kind: 30023` — published long-form article.
47pub const KIND_LONG_FORM_ARTICLE: Kind = Kind::LONG_FORM_TEXT_NOTE;
48
49/// `kind: 30024` — long-form draft. Same shape as 30023 but relays
50/// and clients treat it as work-in-progress.
51pub const KIND_LONG_FORM_DRAFT: Kind = Kind::new(30_024);
52
53/// Wire head of the `title` metadata tag.
54pub const TITLE_TAG: &str = "title";
55/// Wire head of the `image` metadata tag.
56pub const IMAGE_TAG: &str = "image";
57/// Wire head of the `summary` metadata tag.
58pub const SUMMARY_TAG: &str = "summary";
59/// Wire head of the `published_at` metadata tag.
60pub const PUBLISHED_AT_TAG: &str = "published_at";
61
62/// Typed NIP-23 article bundle.
63///
64/// Only [`Self::identifier`] and [`Self::content`] are required.
65/// Every other field is optional and round-trips through the
66/// builder + [`Self::from_event`].
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct Article {
69    /// `d`-tag identifier (addressable coordinate `d` segment).
70    pub identifier: String,
71    /// Markdown content. Must NOT contain HTML and SHOULD NOT hard
72    /// wrap paragraphs — both are spec requirements on authors, not
73    /// gates enforced here.
74    pub content: String,
75    /// `title` tag. `None` omits the tag.
76    pub title: Option<String>,
77    /// `image` tag (hero image URL). NIP-23 does not pin a URL
78    /// format, so we accept any string (matches the looseness of
79    /// NIP-38 `r` links).
80    pub image: Option<String>,
81    /// `summary` tag (short description).
82    pub summary: Option<String>,
83    /// `published_at` tag (first-publish unix seconds).
84    pub published_at: Option<Timestamp>,
85    /// Hashtags, each producing one `t` tag.
86    pub hashtags: Vec<String>,
87}
88
89impl Article {
90    /// Construct an article with only the required fields.
91    #[must_use]
92    pub fn new(identifier: impl Into<String>, content: impl Into<String>) -> Self {
93        Self {
94            identifier: identifier.into(),
95            content: content.into(),
96            ..Self::default()
97        }
98    }
99
100    /// Chainable title setter.
101    #[must_use]
102    pub fn title(mut self, title: impl Into<String>) -> Self {
103        self.title = Some(title.into());
104        self
105    }
106
107    /// Chainable image setter.
108    #[must_use]
109    pub fn image(mut self, image: impl Into<String>) -> Self {
110        self.image = Some(image.into());
111        self
112    }
113
114    /// Chainable summary setter.
115    #[must_use]
116    pub fn summary(mut self, summary: impl Into<String>) -> Self {
117        self.summary = Some(summary.into());
118        self
119    }
120
121    /// Chainable `published_at` setter.
122    #[must_use]
123    pub const fn published_at(mut self, ts: Timestamp) -> Self {
124        self.published_at = Some(ts);
125        self
126    }
127
128    /// Push one hashtag (emits a single `t` tag on build).
129    #[must_use]
130    pub fn hashtag(mut self, tag: impl Into<String>) -> Self {
131        self.hashtags.push(tag.into());
132        self
133    }
134
135    /// `true` when the event was authored as `kind: 30024`.
136    ///
137    /// Only meaningful on a value returned by [`Self::from_event`];
138    /// a freshly-constructed [`Article`] reports `false` because
139    /// the draft-ness lives on the containing event, not the
140    /// bundle.
141    #[must_use]
142    pub const fn is_draft_marker() -> Kind {
143        KIND_LONG_FORM_DRAFT
144    }
145
146    /// Parse a `kind: 30023` or `kind: 30024` event back into an
147    /// [`Article`].
148    ///
149    /// # Errors
150    ///
151    /// - [`ArticleError::WrongKind`] for any other kind.
152    /// - [`ArticleError::MissingIdentifier`] when the `d` tag is
153    ///   absent (the event would not be addressable without it).
154    /// - [`ArticleError::InvalidPublishedAt`] when `published_at`
155    ///   is present but does not parse as unix seconds.
156    pub fn from_event(event: &Event) -> Result<Self, ArticleError> {
157        if event.kind != KIND_LONG_FORM_ARTICLE && event.kind != KIND_LONG_FORM_DRAFT {
158            return Err(ArticleError::WrongKind(event.kind));
159        }
160        let identifier = d_tag(&event.tags)
161            .ok_or(ArticleError::MissingIdentifier)?
162            .to_owned();
163
164        let mut article = Self::new(identifier, event.content.clone());
165        article.title = custom_tag(&event.tags, TITLE_TAG).map(str::to_owned);
166        article.image = custom_tag(&event.tags, IMAGE_TAG).map(str::to_owned);
167        article.summary = custom_tag(&event.tags, SUMMARY_TAG).map(str::to_owned);
168        if let Some(raw) = custom_tag(&event.tags, PUBLISHED_AT_TAG) {
169            let n: u64 = raw
170                .parse()
171                .map_err(|_| ArticleError::InvalidPublishedAt(raw.to_owned()))?;
172            article.published_at = Some(Timestamp::from_secs(n));
173        }
174        article.hashtags = event
175            .tags
176            .iter()
177            .filter_map(|tag| match tag.kind() {
178                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::T => {
179                    tag.get(1).map(str::to_owned)
180                }
181                _ => None,
182            })
183            .collect();
184
185        Ok(article)
186    }
187}
188
189/// Errors raised by [`Article::from_event`].
190#[derive(Debug, Error)]
191#[non_exhaustive]
192pub enum ArticleError {
193    /// The event was not `kind: 30023` / `30024`.
194    #[error("expected kind 30023 / 30024 (long-form), got kind {}", .0.as_u16())]
195    WrongKind(Kind),
196    /// No `d` tag was present.
197    #[error("NIP-23 event must carry exactly one `d` tag")]
198    MissingIdentifier,
199    /// `published_at` was present but malformed.
200    #[error("`published_at` must be stringified unix seconds; got `{0}`")]
201    InvalidPublishedAt(String),
202}
203
204fn d_tag(tags: &Tags) -> Option<&str> {
205    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
206    tags.find_first(&head).and_then(|t| t.get(1))
207}
208
209fn custom_tag<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
210    let head = TagKind::Custom(name.to_owned());
211    tags.find_first(&head).and_then(|t| t.get(1))
212}
213
214fn build_article_event(article: Article, kind: Kind) -> EventBuilder {
215    let mut builder = EventBuilder::new(kind, article.content).tag(Tag::d(article.identifier));
216    if let Some(title) = article.title {
217        builder = builder.tag(Tag::title(title));
218    }
219    if let Some(image) = article.image {
220        let head = TagKind::Custom(IMAGE_TAG.to_owned());
221        builder = builder.tag(Tag::with(&head, [image]));
222    }
223    if let Some(summary) = article.summary {
224        let head = TagKind::Custom(SUMMARY_TAG.to_owned());
225        builder = builder.tag(Tag::with(&head, [summary]));
226    }
227    if let Some(ts) = article.published_at {
228        let head = TagKind::Custom(PUBLISHED_AT_TAG.to_owned());
229        builder = builder.tag(Tag::with(&head, [ts.as_secs().to_string()]));
230    }
231    for hashtag in article.hashtags {
232        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T));
233        builder = builder.tag(Tag::with(&head, [hashtag]));
234    }
235    builder
236}
237
238impl EventBuilder {
239    /// Author a published NIP-23 `kind: 30023` article.
240    #[must_use]
241    pub fn long_form_article(article: Article) -> Self {
242        build_article_event(article, KIND_LONG_FORM_ARTICLE)
243    }
244
245    /// Author a NIP-23 `kind: 30024` draft.
246    #[must_use]
247    pub fn long_form_draft(article: Article) -> Self {
248        build_article_event(article, KIND_LONG_FORM_DRAFT)
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::Keys;
256
257    fn keys() -> Keys {
258        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
259    }
260
261    #[test]
262    fn article_round_trips_all_metadata_fields() {
263        let article = Article::new("lorem-ipsum", "body")
264            .title("Lorem Ipsum")
265            .image("https://example.com/i.png")
266            .summary("a short note")
267            .published_at(Timestamp::from_secs(1_296_962_229))
268            .hashtag("placeholder")
269            .hashtag("test");
270
271        let event = EventBuilder::long_form_article(article.clone())
272            .sign_with_keys(&keys())
273            .unwrap();
274        assert_eq!(event.kind, KIND_LONG_FORM_ARTICLE);
275
276        let parsed = Article::from_event(&event).unwrap();
277        assert_eq!(parsed, article);
278    }
279
280    #[test]
281    fn drafts_use_kind_30024() {
282        let article = Article::new("draft-1", "wip");
283        let event = EventBuilder::long_form_draft(article)
284            .sign_with_keys(&keys())
285            .unwrap();
286        assert_eq!(event.kind, KIND_LONG_FORM_DRAFT);
287    }
288
289    #[test]
290    fn missing_d_tag_is_rejected_when_parsing() {
291        // Hand-build an event without a d tag.
292        let event = EventBuilder::new(KIND_LONG_FORM_ARTICLE, "x")
293            .sign_with_keys(&keys())
294            .unwrap();
295        assert!(matches!(
296            Article::from_event(&event),
297            Err(ArticleError::MissingIdentifier)
298        ));
299    }
300
301    #[test]
302    fn wrong_kind_is_rejected_when_parsing() {
303        let event = EventBuilder::text_note("nope")
304            .sign_with_keys(&keys())
305            .unwrap();
306        assert!(matches!(
307            Article::from_event(&event),
308            Err(ArticleError::WrongKind(_))
309        ));
310    }
311
312    #[test]
313    fn published_at_must_parse_as_unix_seconds() {
314        let event = EventBuilder::new(KIND_LONG_FORM_ARTICLE, "x")
315            .tag(Tag::d("slug"))
316            .tag(Tag::with(
317                &TagKind::Custom(PUBLISHED_AT_TAG.to_owned()),
318                ["not-a-number"],
319            ))
320            .sign_with_keys(&keys())
321            .unwrap();
322        assert!(matches!(
323            Article::from_event(&event),
324            Err(ArticleError::InvalidPublishedAt(s)) if s == "not-a-number"
325        ));
326    }
327
328    #[test]
329    fn minimal_article_has_only_d_tag() {
330        let article = Article::new("slug", "just content");
331        let event = EventBuilder::long_form_article(article.clone())
332            .sign_with_keys(&keys())
333            .unwrap();
334        // exactly one tag: the d tag.
335        assert_eq!(event.tags.len(), 1);
336        let parsed = Article::from_event(&event).unwrap();
337        assert_eq!(parsed, article);
338    }
339}