Skip to main content

aozora_flavored_markdown/
lib.rs

1//! Aozora Flavored Markdown — CommonMark + GFM + 青空文庫記法.
2//!
3//! Layers `aozora-pipeline` (青空文庫記法 borrowed-AST lexer) onto a
4//! vendored verbatim comrak so a single [`render`] call
5//! turns aozora-flavored-markdown source into HTML. Public entry points:
6//!
7//! - [`render`] — render aozora-flavored-markdown source straight to HTML.
8//! - [`serialize`] — aozora-md-source round-trip (delegates to
9//!   [`aozora::render::serialize::serialize`]).
10//! - [`Options`] — configuration; [`Options::default`] enables
11//!   the GFM extensions aozora-flavored-markdown uses on top of CommonMark.
12//!
13//! ```
14//! use aozora_flavored_markdown::{Options, render};
15//!
16//! let rendered = render("彼は|青梅《おうめ》に行った。", &Options::default());
17//! assert!(rendered.html.contains("<ruby>"));
18//! ```
19//!
20//! ## Pipeline
21//!
22//! ```text
23//! source                                   ── UTF-8 input
24//!   │
25//!   ▼ aozora::lex_into_arena      ── normalized text + Registry
26//!   │
27//!   ▼ comrak::parse_document               ── vanilla CommonMark + GFM
28//!   │   (PUA sentinels U+E001..U+E004 flow through as plain text)
29//!   │
30//!   ▼ comrak::format_html_with_options     ── HTML with sentinels
31//!   │
32//!   ▼ ast_splice::splice_into_ast          ── sentinel → aozora-render,
33//!   │   · INLINE_SENTINEL → NodeValue::Raw inline node
34//!   │   · BLOCK_LEAF paragraphs → NodeValue::Raw block node
35//!   │   · BLOCK_OPEN/CLOSE paragraphs → container open/close raws
36//!   │
37//!   ▼ comrak::format_html                  ── vanilla, sentinel-free AST
38//!   │
39//!   ▼
40//! HTML
41//! ```
42//!
43//! Comrak is unmodified: the v0.52.0 verbatim tree carries no
44//! Aozora-aware code (ADR-0001 budget = 0).
45
46#![forbid(unsafe_code)]
47
48// Compile every fenced `rust` block in README.md as a doctest (run by
49// `just test-doc`) so the published quick-start can't drift from the API —
50// the drift this guards against actually happened once. `#[cfg(doctest)]`
51// keeps the `include_str!` out of normal builds and `cargo package`.
52#[cfg(doctest)]
53#[doc = include_str!("../../../README.md")]
54struct ReadmeDoctests;
55
56mod ast_splice;
57mod code_block_mask;
58pub mod diagnostics;
59pub mod html;
60pub mod ir;
61mod sentinel_stream;
62mod source_line_anchors;
63
64/// PUA sentinel codepoints embedded by `aozora_pipeline`.
65///
66/// Re-exported here under aozora-md-side names so aozora-flavored-markdown's public API never
67/// names sibling crate constants — if the upstream renames or removes
68/// one of these, the change surfaces in this module instead of
69/// breaking every downstream consumer.
70pub mod sentinels {
71    /// Inline Aozora span (ruby / bouten / annotation / gaiji /
72    /// TCY / kaeriten).
73    pub const INLINE: char = aozora::INLINE_SENTINEL;
74    /// Block-leaf Aozora line (page break, section break, leaf
75    /// indent, sashie).
76    pub const BLOCK_LEAF: char = aozora::BLOCK_LEAF_SENTINEL;
77    /// Paired-container open line (e.g. `[#ここから字下げ]`).
78    pub const BLOCK_OPEN: char = aozora::BLOCK_OPEN_SENTINEL;
79    /// Paired-container close line (e.g. `[#ここで字下げ終わり]`).
80    pub const BLOCK_CLOSE: char = aozora::BLOCK_CLOSE_SENTINEL;
81}
82
83#[doc(inline)]
84pub use diagnostics::{Diagnostic, DiagnosticSource, Severity, Span};
85
86use core::mem;
87
88use aozora::pipeline::lexer::sanitize;
89use aozora::render::serialize as aozora_serialize;
90use aozora::syntax::borrowed::Arena;
91use comrak::nodes::AstNode;
92
93/// Parse-time configuration for [`render`] and friends.
94///
95/// `comrak::Options` is held with a `'static` lifetime: aozora-flavored-markdown doesn't
96/// install URL rewriters or broken-link callbacks (which are the
97/// only comrak fields that need a non-`'static` lifetime), so the
98/// borrow parameter would be dead weight in our public API.
99#[derive(Debug, Clone)]
100#[non_exhaustive]
101pub struct Options {
102    comrak: comrak::Options<'static>,
103    /// When `true`, run the aozora lex pre-pass and HTML
104    /// post-processing. When `false`, the input flows straight into
105    /// vanilla `comrak::parse_document` + `format_html` — used by the
106    /// CommonMark / GFM spec conformance runners to verify the wrapper
107    /// does not perturb upstream behaviour.
108    ///
109    /// Private: read via [`Options::aozora_enabled`], set via
110    /// [`Options::with_aozora_enabled`].
111    aozora_enabled: bool,
112    /// When `true`, the HTML renderer adds `data-aozora-md-source-line="N"`
113    /// (1-based) to every top-level block element it emits. The
114    /// aozora-flavored-markdown-obsidian document-mode adapter (Pillar 6 of the plan)
115    /// uses these anchors to map per-block post-processor calls back
116    /// to slices of the rendered fragment without re-parsing.
117    ///
118    /// Defaults to `false`. Cost when enabled: one extra walk over
119    /// comrak's top-level AST children + a streaming insert pass on
120    /// the produced HTML. Both are O(blocks).
121    ///
122    /// Private: read via [`Options::source_line_anchors`], set via
123    /// [`Options::with_source_line_anchors`].
124    source_line_anchors: bool,
125}
126
127impl Default for Options {
128    /// The recommended aozora-flavored-markdown dialect configuration:
129    /// GFM extensions on (strikethrough, table, autolink, tasklist),
130    /// hardbreaks on so each Aozora source newline becomes a `<br>`
131    /// (verse / dialogue boundaries are load-bearing in 青空文庫 source),
132    /// and the Aozora pre-pass enabled. Raw-HTML passthrough stays off
133    /// (`render.unsafe = false`), so this is XSS-safe on untrusted input.
134    ///
135    /// # Examples
136    ///
137    /// ```
138    /// use aozora_flavored_markdown::Options;
139    ///
140    /// let opts = Options::default();
141    /// assert!(opts.aozora_enabled());
142    /// assert!(opts.comrak().extension.table);
143    /// assert!(!opts.source_line_anchors());
144    /// ```
145    fn default() -> Self {
146        let mut comrak = comrak::Options::default();
147        comrak.extension.strikethrough = true;
148        comrak.extension.table = true;
149        comrak.extension.autolink = true;
150        comrak.extension.tasklist = true;
151        comrak.render.hardbreaks = true;
152        Self {
153            comrak,
154            aozora_enabled: true,
155            source_line_anchors: false,
156        }
157    }
158}
159
160impl Options {
161    /// Plain CommonMark (no GFM, no Aozora) with comrak's raw-HTML
162    /// passthrough **enabled** (`render.unsafe = true`). Spec-conformance
163    /// scaffolding only — it exists so the CommonMark 0.31.2 runner can
164    /// verify the wrapper does not perturb comrak's CommonMark behaviour
165    /// against a spec whose expected output includes raw HTML.
166    ///
167    /// Hidden from the published API surface (`#[doc(hidden)]`): this is
168    /// not a production configuration. Use [`Options::default`] (which
169    /// keeps `render.unsafe = false`) or a hand-built [`Options`] for any
170    /// real workload.
171    ///
172    /// # Security
173    ///
174    /// **Raw-HTML passthrough — never use on untrusted input.** This adds
175    /// no Rust `unsafe`, but it is a security footgun: it turns on
176    /// comrak's raw-HTML passthrough (`render.unsafe = true`), so comrak
177    /// emits raw HTML verbatim and passes through unsanitized URLs
178    /// (`javascript:` schemes included). Feeding attacker-controlled
179    /// source through these `Options` is an XSS sink. Reach for
180    /// [`Options::default`] instead, which leaves raw HTML escaped.
181    #[doc(hidden)]
182    #[must_use]
183    pub fn commonmark_only() -> Self {
184        let mut comrak = comrak::Options::default();
185        comrak.render.r#unsafe = true;
186        Self {
187            comrak,
188            aozora_enabled: false,
189            source_line_anchors: false,
190        }
191    }
192
193    /// Pure-GFM extension set (no Aozora) with comrak's raw-HTML
194    /// passthrough **enabled** (`render.unsafe = true`). Spec-conformance
195    /// scaffolding only — it backs the GFM 0.29 conformance runner.
196    ///
197    /// Hidden from the published API surface (`#[doc(hidden)]`): this is
198    /// not a production configuration. Use [`Options::default`] (which
199    /// keeps `render.unsafe = false`) or a hand-built [`Options`] for any
200    /// real workload.
201    ///
202    /// # Security
203    ///
204    /// **Raw-HTML passthrough — never use on untrusted input.** This adds
205    /// no Rust `unsafe`, but it is a security footgun: it turns on
206    /// comrak's raw-HTML passthrough (`render.unsafe = true`), so comrak
207    /// emits raw HTML verbatim and passes through unsanitized URLs
208    /// (`javascript:` schemes included). Feeding attacker-controlled
209    /// source through these `Options` is an XSS sink. Reach for
210    /// [`Options::default`] instead, which leaves raw HTML escaped.
211    #[doc(hidden)]
212    #[must_use]
213    pub fn gfm_only() -> Self {
214        let mut comrak = comrak::Options::default();
215        comrak.extension.strikethrough = true;
216        comrak.extension.table = true;
217        comrak.extension.autolink = true;
218        comrak.extension.tasklist = true;
219        comrak.extension.tagfilter = true;
220        comrak.render.r#unsafe = true;
221        Self {
222            comrak,
223            aozora_enabled: false,
224            source_line_anchors: false,
225        }
226    }
227
228    /// Builder-style toggle for source-line anchors. Returns a new
229    /// `Options` with `source_line_anchors = on`.
230    ///
231    /// ```
232    /// use aozora_flavored_markdown::Options;
233    /// let opts = Options::default().with_source_line_anchors(true);
234    /// assert!(opts.source_line_anchors());
235    /// ```
236    #[must_use]
237    pub fn with_source_line_anchors(mut self, on: bool) -> Self {
238        self.source_line_anchors = on;
239        self
240    }
241
242    /// Builder-style toggle for the Aozora pre-pass. Returns a new
243    /// `Options` with `aozora_enabled = on`. When `false`, the input
244    /// flows straight through comrak with no Aozora lexing or HTML
245    /// post-processing.
246    ///
247    /// ```
248    /// use aozora_flavored_markdown::Options;
249    /// let opts = Options::default().with_aozora_enabled(false);
250    /// assert!(!opts.aozora_enabled());
251    /// ```
252    #[must_use]
253    pub fn with_aozora_enabled(mut self, on: bool) -> Self {
254        self.aozora_enabled = on;
255        self
256    }
257
258    /// Whether the Aozora lex pre-pass / HTML post-pass is enabled
259    /// (see [`Options::with_aozora_enabled`]).
260    #[must_use]
261    pub fn aozora_enabled(&self) -> bool {
262        self.aozora_enabled
263    }
264
265    /// Whether the renderer tags top-level blocks with
266    /// `data-aozora-md-source-line` anchors
267    /// (see [`Options::with_source_line_anchors`]).
268    #[must_use]
269    pub fn source_line_anchors(&self) -> bool {
270        self.source_line_anchors
271    }
272
273    /// Read access to the underlying [`comrak::Options`]. The standard
274    /// dialect configuration is set by [`Options::default`]; reach for
275    /// this only to inspect a comrak knob directly.
276    #[must_use]
277    pub fn comrak(&self) -> &comrak::Options<'static> {
278        &self.comrak
279    }
280
281    /// Mutable escape hatch to the underlying [`comrak::Options`] for
282    /// advanced comrak tuning beyond what the first-class builders cover.
283    ///
284    /// # Stability
285    ///
286    /// This re-exposes comrak's own option surface, which is **not**
287    /// covered by aozora-flavored-markdown's `SemVer` guarantee: a comrak
288    /// major bump may change these fields. Prefer the `with_*` builders
289    /// for anything they already cover.
290    pub fn comrak_mut(&mut self) -> &mut comrak::Options<'static> {
291        &mut self.comrak
292    }
293}
294
295/// Output of [`render`].
296#[derive(Debug)]
297#[non_exhaustive]
298pub struct Rendered {
299    /// HTML output, with every Aozora sentinel substituted.
300    pub html: String,
301    /// Non-fatal lexer observations (unclosed pairs, PUA collisions,
302    /// stray triggers, …). Empty on the happy path.
303    pub diagnostics: Vec<Diagnostic>,
304}
305
306/// Output of [`render_to_ir`].
307///
308/// The IR projection alongside the HTML and diagnostics. Used by the
309/// `aozora-flavored-markdown-wasm` bridge so the JS-side renderer can pick its own output
310/// target (DOM fragment, `CodeMirror` `RangeSet`, semantic tokens, …)
311/// from a single source.
312#[derive(Debug)]
313#[non_exhaustive]
314pub struct RenderedIr {
315    pub ir: ir::IrDocument,
316    pub html: String,
317    pub diagnostics: Vec<Diagnostic>,
318}
319
320/// Largest source aozora-flavored-markdown will hand to the aozora lexer.
321///
322/// The core lexer keys every span on a `u32` byte offset and asserts
323/// `source.len() <= u32::MAX` at entry (`aozora_pipeline`'s Phase 0 /
324/// `tokenize_in`). Under this workspace's `panic = "abort"` release
325/// profile that assert is a hard process abort, not a catchable panic —
326/// an in-scope crash per `SECURITY.md` for a >4 GiB hostile input. aozora-flavored-markdown's
327/// public entry points guard on this boundary *before* reaching the core
328/// so an oversized input degrades to a graceful empty render instead of
329/// aborting the host process. Mirrors `aozora-py`'s `PyValueError` guard
330/// (`source exceeds 4 GiB (u32::MAX) span limit`).
331const MAX_SOURCE_BYTES: usize = u32::MAX as usize;
332
333/// `true` when a source of `len` bytes is within the lexer's
334/// addressable `u32` span budget.
335///
336/// Split out from [`source_within_span_budget`] so the boundary
337/// arithmetic is unit-testable at `u32::MAX` / `u32::MAX + 1` without
338/// allocating a multi-gigabyte `String`.
339const fn len_within_span_budget(len: usize) -> bool {
340    len <= MAX_SOURCE_BYTES
341}
342
343/// `true` when `input` is within the lexer's addressable `u32` span
344/// budget. `false` inputs must not be handed to the core.
345const fn source_within_span_budget(input: &str) -> bool {
346    len_within_span_budget(input.len())
347}
348
349/// Render aozora-flavored-markdown source text to HTML.
350///
351/// One-stop entry point for the typical caller (aozora-flavored-markdown CLI, aozora-flavored-markdown-epub).
352/// Internally:
353///
354/// 1. [`aozora::lex_into_arena`] turns the source into a normalized
355///    text (with PUA sentinels at every Aozora construct) plus a
356///    borrowed `Registry`.
357/// 2. `comrak::parse_document` parses the normalized text — sentinels flow
358///    through as plain text since they are not in CommonMark's escape set
359///    (`<`/`>`/`&`/`"`).
360/// 3. `ast_splice::splice_into_ast` replaces each sentinel in the comrak AST
361///    with the matching `aozora::render::render_node` output, then
362///    `comrak::format_html` renders the spliced AST.
363///
364/// # Examples
365///
366/// ```
367/// use aozora_flavored_markdown::{Options, render};
368///
369/// let rendered = render("彼は|青梅《おうめ》に行った。", &Options::default());
370/// assert!(rendered.html.contains("<ruby>"));
371/// assert!(rendered.diagnostics.is_empty());
372/// ```
373///
374/// # Oversized input
375///
376/// If `input` exceeds `MAX_SOURCE_BYTES` (4 GiB − 1, the lexer's `u32`
377/// span budget) this returns an empty [`Rendered`] (`html: ""`, no
378/// diagnostics) **without** invoking the core lexer — the core would
379/// otherwise `assert!` and abort the process under `panic = "abort"`.
380/// See `MAX_SOURCE_BYTES` for the rationale.
381///
382/// # Panics
383///
384/// Panics if `comrak::format_html` fails to write into the internal
385/// `String` sink — `String` cannot fail as a `fmt::Write`, so this
386/// branch is unreachable in normal use.
387#[must_use]
388pub fn render(input: &str, options: &Options) -> Rendered {
389    if !source_within_span_budget(input) {
390        return Rendered {
391            html: String::new(),
392            diagnostics: vec![Diagnostic::source_too_large(input.len())],
393        };
394    }
395    let (html, diagnostics, ()) = drive_pipeline(input, options, |_root, _lex_out, _source| ());
396    Rendered { html, diagnostics }
397}
398
399/// Render aozora-flavored-markdown source to a structured IR + HTML + diagnostics.
400///
401/// Mirrors [`render`] but additionally walks comrak's AST
402/// to emit a typed [`ir::IrDocument`]. The IR is the canonical
403/// contract between aozora-flavored-markdown-wasm and aozora-flavored-markdown-obsidian's TS renderers.
404///
405/// The IR covers the full Markdown side (paragraph, heading,
406/// blockquote, list, code, thematic break, table, image) and the
407/// full Aozora side (`Ruby` / `DoubleRuby` / `Bouten` / `Tcy` /
408/// `Gaiji` / `Annotation` / `PageBreak` / `SectionBreak` /
409/// `Container`); heading hints (`[#「X」は大見出し]`) promote
410/// their host paragraph to `IrBlock::Heading` so the IR shape
411/// matches the rendered HTML one-for-one.
412///
413/// # Examples
414///
415/// ```
416/// use aozora_flavored_markdown::ir::IrBlock;
417/// use aozora_flavored_markdown::{Options, render_to_ir};
418///
419/// let rendered = render_to_ir("# 第一章\n\n本文", &Options::default());
420/// assert!(matches!(rendered.ir.blocks.first(), Some(IrBlock::Heading { .. })));
421/// ```
422///
423/// # Oversized input
424///
425/// If `input` exceeds `MAX_SOURCE_BYTES` this returns an empty
426/// [`RenderedIr`] (empty IR document, `html: ""`, no diagnostics)
427/// without invoking the core lexer. See `MAX_SOURCE_BYTES`.
428///
429/// # Panics
430///
431/// Panics if `comrak::format_html` fails to write into the internal
432/// `String` sink — `String` cannot fail as a `fmt::Write`, so this
433/// branch is unreachable in normal use.
434#[must_use]
435pub fn render_to_ir(input: &str, options: &Options) -> RenderedIr {
436    if !source_within_span_budget(input) {
437        return RenderedIr {
438            ir: ir::IrDocument::default(),
439            html: String::new(),
440            diagnostics: vec![Diagnostic::source_too_large(input.len())],
441        };
442    }
443    let (html, diagnostics, ir) = drive_pipeline(input, options, ir::build_ir);
444    RenderedIr {
445        ir,
446        html,
447        diagnostics,
448    }
449}
450
451/// Internal pipeline driver shared between `render` and
452/// `render_to_ir`.
453///
454/// Runs the full lex → comrak → format → post-process → unmask →
455/// anchors chain and threads the AST root + optional `BorrowedLexOutput`
456/// through `project` *before* HTML formatting starts. The closure
457/// returns whatever extra data the caller needs alongside the HTML
458/// (`()` for the plain renderer, an `IrDocument` for the IR
459/// renderer).
460fn drive_pipeline<F, T>(input: &str, options: &Options, project: F) -> (String, Vec<Diagnostic>, T)
461where
462    F: for<'a> FnOnce(&'a AstNode<'a>, Option<&aozora::BorrowedLexOutput<'a>>, &str) -> T,
463{
464    if !options.aozora_enabled {
465        let comrak_arena = comrak::Arena::new();
466        let root = comrak::parse_document(&comrak_arena, input, &options.comrak);
467        // No lexer pass (no sentinels): the sanitized-source argument is
468        // unused by `project` here (empty cursor), so the raw input stands in.
469        let extra = project(root, None, input);
470        let html = format_root(root, options, None);
471        return (html, Vec::new(), extra);
472    }
473
474    // Pre-process: hide aozora trigger characters that live inside a
475    // CommonMark fenced code block from the lexer. `aozora_pipeline` is
476    // CommonMark-blind by design (ADR-0010), so this lives here. See
477    // `code_block_mask` module docs for the masking scheme.
478    let (masked_source, mask_originals) = code_block_mask::mask_code_block_triggers(input);
479
480    // The lexer's `source_nodes` spans are in Phase-0 sanitized-source
481    // bytes (BOM/CRLF/accent-span normalised), so recover that exact text
482    // here to slice literal-context sentinels back to their source. The
483    // lexer re-derives the same sanitization internally; `sanitize` is a
484    // pure function of `masked_source`, so the coordinates line up.
485    let sanitized = sanitize(&masked_source);
486
487    let arena = Arena::new();
488    let lex_out = aozora::lex_into_arena(&masked_source, &arena);
489
490    let comrak_arena = comrak::Arena::new();
491    let root = comrak::parse_document(&comrak_arena, lex_out.normalized, &options.comrak);
492
493    // IR projection sees the AST *before* sentinel splicing — it
494    // walks the same Text-with-sentinel-char pre-mutation tree the
495    // splicer is about to consume. Both walkers share
496    // `SentinelCursor` primitives (each materialises its own cursor)
497    // so they stay in lockstep without serial coupling.
498    let extra = project(root, Some(&lex_out), &sanitized.text);
499
500    // Mutate the AST: every PUA sentinel becomes a `NodeValue::Raw`
501    // node carrying the rendered Aozora HTML. After this returns,
502    // the AST contains no sentinel character; `comrak::format_html`
503    // emits final HTML in a single verbatim pass. `masked_source` is
504    // passed so sentinels that landed in literal markdown contexts
505    // (inline code, link URLs) can be rewritten to their original source.
506    ast_splice::splice_into_ast(root, &comrak_arena, &lex_out, &sanitized.text);
507
508    let html = format_root(root, options, Some(mask_originals.as_slice()));
509    let diagnostics = lex_out.diagnostics.iter().map(Diagnostic::from).collect();
510    (html, diagnostics, extra)
511}
512
513/// Common HTML finalisation: comrak-format the root (per top-level
514/// child when `source_line_anchors` is on, so each child's first
515/// open tag picks up its `data-aozora-md-source-line` attribute), then
516/// unmask code-block triggers.
517///
518/// AST-level Aozora sentinel splicing runs in [`drive_pipeline`]
519/// before this is called, so by the time we hand the AST to
520/// `comrak::format_html` no PUA sentinel remains.
521fn format_root<'a>(
522    root: &'a AstNode<'a>,
523    options: &Options,
524    mask_originals: Option<&[char]>,
525) -> String {
526    let html = if options.source_line_anchors {
527        source_line_anchors::format_root_with_anchors(root, &options.comrak)
528    } else {
529        let mut html = String::new();
530        comrak::format_html(root, &options.comrak, &mut html)
531            .expect("formatting to a String never fails");
532        html
533    };
534    if let Some(originals) = mask_originals {
535        code_block_mask::unmask_html(&html, originals).into_owned()
536    } else {
537        html
538    }
539}
540
541/// One block of [`render_blocks_to_ir`]'s output.
542///
543/// Each entry corresponds to one top-level comrak child. `html` is the
544/// rendered HTML for that child (with Aozora sentinels spliced).
545/// `ir` is the IR projection — typically a single block, but may be
546/// empty for comrak constructs without a v0.2 IR mapping (definition
547/// lists, footnote refs, raw HTML, etc.) and may carry more than one
548/// block when an Aozora paired-container drains at the call boundary.
549#[derive(Debug, Clone)]
550#[non_exhaustive]
551pub struct RenderedBlock {
552    pub ir: Vec<ir::IrBlock>,
553    pub html: String,
554    /// 1-based line where this block began in the source.
555    pub source_line: u32,
556}
557
558/// Per-block streaming render.
559///
560/// Produces one [`RenderedBlock`] per top-level comrak child, in
561/// document order. Used by aozora-flavored-markdown-obsidian's chunked-cancellation path
562/// (ADR-0009): the JS bridge can iterate the returned vector and
563/// check its `AbortSignal` between blocks.
564///
565/// The current implementation parses the document once (a single
566/// comrak pass) and renders each top-level block's HTML separately
567/// using `comrak::format_html`. Diagnostics from the lexer are
568/// returned alongside the blocks, attached to the document as a
569/// whole rather than per-block (the lexer pass is non-block-scoped).
570///
571/// Limitation: container constructs that span multiple top-level
572/// blocks (e.g., `[#ここから2字下げ]`...`[#ここで字下げ終わり]`)
573/// are emitted as separate blocks; the consumer is responsible for
574/// re-assembling them. The whole-document `render_to_ir` path
575/// preserves cross-block structure if you need it.
576///
577/// # Examples
578///
579/// ```
580/// use aozora_flavored_markdown::{Options, render_blocks_to_ir};
581///
582/// let (blocks, diagnostics) =
583///     render_blocks_to_ir("first paragraph\n\n|second《せかんど》paragraph", &Options::default());
584/// assert_eq!(blocks.len(), 2);
585/// assert!(diagnostics.is_empty());
586/// ```
587///
588/// # Oversized input
589///
590/// If `input` exceeds `MAX_SOURCE_BYTES` this returns
591/// `(Vec::new(), Vec::new())` — no blocks, no diagnostics — without
592/// invoking the core lexer. See `MAX_SOURCE_BYTES`.
593#[must_use]
594pub fn render_blocks_to_ir(
595    input: &str,
596    options: &Options,
597) -> (Vec<RenderedBlock>, Vec<Diagnostic>) {
598    if !source_within_span_budget(input) {
599        return (Vec::new(), vec![Diagnostic::source_too_large(input.len())]);
600    }
601    if !options.aozora_enabled {
602        let comrak_arena = comrak::Arena::new();
603        let root = comrak::parse_document(&comrak_arena, input, &options.comrak);
604        let blocks = collect_rendered_blocks(root, options, Vec::new());
605        return (blocks, Vec::new());
606    }
607
608    let (masked_source, _mask_originals) = code_block_mask::mask_code_block_triggers(input);
609    // `source_nodes` spans are in sanitized-source bytes; recover that text
610    // to slice literal-context sentinels. See `drive_pipeline`.
611    let sanitized = sanitize(&masked_source);
612    let arena = Arena::new();
613    let lex_out = aozora::lex_into_arena(&masked_source, &arena);
614    let comrak_arena = comrak::Arena::new();
615    let root = comrak::parse_document(&comrak_arena, lex_out.normalized, &options.comrak);
616    // IR projection runs before AST mutation so it walks the
617    // sentinel-bearing Text nodes; AST splicing afterwards rewrites
618    // the same nodes for `comrak::format_html` consumption. A single
619    // `StreamingIrBuilder` threads its cursor across every top-level
620    // child so the registry stays in lockstep — a per-call builder
621    // would restart the cursor at 0 for every block and misalign
622    // Aozora projection against the registry.
623    let blocks_ir: Vec<Vec<ir::IrBlock>> = {
624        let mut builder = ir::StreamingIrBuilder::new(Some(&lex_out), &sanitized.text);
625        root.children()
626            .map(|child| builder.walk_block(child))
627            .collect()
628    };
629    ast_splice::splice_into_ast(root, &comrak_arena, &lex_out, &sanitized.text);
630    let blocks = collect_rendered_blocks(root, options, blocks_ir);
631    let diagnostics = lex_out.diagnostics.iter().map(Diagnostic::from).collect();
632    (blocks, diagnostics)
633}
634
635fn collect_rendered_blocks<'a>(
636    root: &'a AstNode<'a>,
637    options: &Options,
638    mut blocks_ir: Vec<Vec<ir::IrBlock>>,
639) -> Vec<RenderedBlock> {
640    // The AST has already been spliced at the document level by the
641    // caller (so `format_html` sees no sentinels here), and the IR
642    // was already projected from the *pre-splice* AST in source
643    // order. We zip them back together one block at a time.
644    //
645    // Pure-markdown mode (`Options::aozora_enabled = false`) hands
646    // us an empty IR vector; we emit `Vec::new()` per block in that
647    // case so the per-block IR field stays consistent with the IR
648    // builder's no-op behaviour.
649    let mut blocks = Vec::new();
650    for (idx, child) in root.children().enumerate() {
651        let data = child.data.borrow();
652        let line = sentinel_stream::saturating_u32(data.sourcepos.start.line).max(1);
653        drop(data);
654        let mut block_html = String::new();
655        comrak::format_html(child, &options.comrak, &mut block_html)
656            .expect("formatting a String never fails");
657        let ir_blocks = if idx < blocks_ir.len() {
658            mem::take(&mut blocks_ir[idx])
659        } else {
660            Vec::new()
661        };
662        blocks.push(RenderedBlock {
663            ir: ir_blocks,
664            html: block_html,
665            source_line: line,
666        });
667    }
668    blocks
669}
670
671/// Round-trip an aozora-flavored-markdown source through the lexer and back to canonical
672/// aozora-md-source text.
673///
674/// Delegates to [`aozora::render::serialize::serialize`] — the
675/// borrowed-AST inverse of `lex_into_arena`. Plain CommonMark portions
676/// of the input pass through verbatim because the lexer leaves them
677/// untouched.
678///
679/// # Examples
680///
681/// ```
682/// use aozora_flavored_markdown::serialize;
683///
684/// let source = "彼は|青梅《おうめ》に行った。";
685/// assert_eq!(serialize(source), source);
686/// ```
687///
688/// # Oversized input
689///
690/// If `input` exceeds `MAX_SOURCE_BYTES` this returns an empty
691/// `String` without invoking the core lexer (which would otherwise
692/// `assert!` and abort under `panic = "abort"`). See
693/// `MAX_SOURCE_BYTES`. The round-trip is therefore *not* identity on
694/// inputs larger than 4 GiB — but such input cannot be lexed at all, so
695/// an empty serialization is the only graceful option.
696#[must_use]
697pub fn serialize(input: &str) -> String {
698    if !source_within_span_budget(input) {
699        return String::new();
700    }
701    let arena = Arena::new();
702    let lex_out = aozora::lex_into_arena(input, &arena);
703    aozora_serialize::serialize(&lex_out)
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    #[test]
711    fn plain_text_round_trips_through_html() {
712        let r = render("hello, world", &Options::default());
713        assert!(r.html.contains("hello, world"), "html: {}", r.html);
714        assert!(r.diagnostics.is_empty());
715    }
716
717    #[test]
718    fn plain_text_serialize_returns_input_unchanged() {
719        assert_eq!(serialize("plain text"), "plain text");
720    }
721
722    #[test]
723    fn ruby_renders_as_html_ruby_element() {
724        let r = render("|青梅《おうめ》へ", &Options::default());
725        assert!(r.html.contains("<ruby>"), "html: {}", r.html);
726        assert!(r.html.contains("青梅"));
727        assert!(r.html.contains("おうめ"));
728        // No bare [# leak (Tier-A canary).
729        assert!(!r.html.contains("[#"));
730    }
731
732    #[test]
733    fn page_break_promotes_and_does_not_leak_brackets() {
734        let r = render("前[#改ページ]後", &Options::default());
735        assert!(!r.html.contains("[#"), "html: {}", r.html);
736    }
737
738    #[test]
739    fn unknown_annotation_keeps_brackets_inside_wrapper() {
740        let r = render("前[#ほげふが]後", &Options::default());
741        // The annotation HTML carries the original text inside an
742        // `aozora-md-annotation` wrapper, so the bracket character may
743        // appear, but never bare in body text.
744        assert!(
745            !contains_bare_bracket(&r.html),
746            "bare bracket leaked in: {}",
747            r.html
748        );
749    }
750
751    #[test]
752    fn commonmark_passes_through_with_heading_intact() {
753        let r = render("# Hello\n\nworld", &Options::default());
754        assert!(r.html.contains("<h1>Hello</h1>"), "html: {}", r.html);
755        assert!(r.html.contains("world"));
756    }
757
758    #[test]
759    fn gfm_only_options_have_aozora_disabled_and_gfm_extensions_enabled() {
760        let opts = Options::gfm_only();
761        assert!(!opts.aozora_enabled, "gfm_only must skip the aozora pass");
762        assert!(opts.comrak.extension.strikethrough);
763        assert!(opts.comrak.extension.table);
764        assert!(opts.comrak.extension.autolink);
765        assert!(opts.comrak.extension.tasklist);
766        assert!(opts.comrak.extension.tagfilter);
767        assert!(opts.comrak.render.r#unsafe);
768    }
769
770    #[test]
771    fn options_builders_and_getters_round_trip() {
772        // Exercise the public builder / getter surface (doctested, but run
773        // here too so the coverage gate counts it).
774        let opts = Options::default()
775            .with_aozora_enabled(false)
776            .with_source_line_anchors(true);
777        assert!(!opts.aozora_enabled());
778        assert!(opts.source_line_anchors());
779        assert!(opts.comrak().extension.table);
780    }
781
782    #[test]
783    fn gfm_only_renders_strikethrough_and_does_not_recognise_ruby() {
784        // gfm_only's contract: GFM extensions on, Aozora pre-pass off.
785        // The strikethrough must produce `<del>`; the ruby-shaped
786        // `|...《》` source must survive verbatim because the lexer
787        // never ran.
788        let opts = Options::gfm_only();
789        let html = render("~~strike~~ |青梅《おうめ》", &opts).html;
790        assert!(html.contains("<del>strike</del>"), "html: {html}");
791        assert!(
792            html.contains("|青梅"),
793            "ruby trigger must survive raw: {html}"
794        );
795        assert!(
796            !html.contains("<ruby>"),
797            "ruby must NOT render in gfm-only: {html}"
798        );
799    }
800
801    #[test]
802    fn contains_bare_bracket_helper_detects_leaked_marker() {
803        // Pins the "bare bracket leaked" branch of the helper itself.
804        // The needle appears outside any tag and outside an
805        // `aozora-md-annotation` wrapper.
806        assert!(contains_bare_bracket("plain [# leak"));
807        assert!(!contains_bare_bracket(
808            "<span class=\"aozora-md-annotation\" hidden>[#</span>"
809        ));
810        assert!(!contains_bare_bracket("no marker at all"));
811    }
812
813    // -------------------------------------------------------------------
814    // (a) Spec-conformance constructors are #[doc(hidden)] but still
815    // wire raw-HTML passthrough on for the spec runners. These tests pin
816    // that the hidden constructors keep their unsafe spec config so a
817    // future refactor that breaks the spec wiring is caught here.
818    // -------------------------------------------------------------------
819
820    #[test]
821    fn commonmark_only_enables_raw_html_and_disables_aozora() {
822        let opts = Options::commonmark_only();
823        assert!(
824            opts.comrak.render.r#unsafe,
825            "commonmark_only must enable raw-HTML passthrough for the spec runner"
826        );
827        assert!(
828            !opts.aozora_enabled,
829            "commonmark_only must skip the aozora pass"
830        );
831    }
832
833    #[test]
834    fn default_does_not_enable_raw_html() {
835        // The production constructor must NOT inherit the spec runners'
836        // raw-HTML passthrough — that is the XSS-safety contract that
837        // motivated hiding commonmark_only / gfm_only.
838        let opts = Options::default();
839        assert!(
840            !opts.comrak.render.r#unsafe,
841            "default must leave raw HTML escaped (no render.unsafe)"
842        );
843        assert!(opts.aozora_enabled, "default must run the aozora pass");
844    }
845
846    // -------------------------------------------------------------------
847    // (b) Oversized-input boundary guard. The lexer asserts
848    // `source.len() <= u32::MAX` and aborts under panic=abort; the aozora-flavored-markdown
849    // entry points must degrade to an empty render instead. We cannot
850    // allocate a >4 GiB string in a test, so the threshold arithmetic is
851    // pinned on the pure `len_within_span_budget` helper, and the entry
852    // points are exercised on realistic (in-budget) input.
853    // -------------------------------------------------------------------
854
855    #[test]
856    fn len_budget_boundary_is_exactly_u32_max() {
857        assert!(len_within_span_budget(0));
858        assert!(len_within_span_budget(1024));
859        assert!(
860            len_within_span_budget(MAX_SOURCE_BYTES),
861            "exactly u32::MAX bytes is still addressable"
862        );
863        // `checked_add` keeps the test sound on a hypothetical 32-bit
864        // target where `MAX_SOURCE_BYTES == usize::MAX` and `+ 1` would
865        // overflow; there, "one past the budget" is unrepresentable, so
866        // the over-budget assertion is vacuously satisfied. On the
867        // workspace's 64-bit targets `over` is `u32::MAX + 1`, the exact
868        // value the core lexer's assert rejects.
869        if let Some(over) = MAX_SOURCE_BYTES.checked_add(1) {
870            assert!(
871                !len_within_span_budget(over),
872                "one byte past u32::MAX must be rejected"
873            );
874        }
875    }
876
877    #[test]
878    fn in_budget_input_still_renders_normally() {
879        // Guard must be transparent for ordinary input.
880        let r = render("# hi\n\nbody", &Options::default());
881        assert!(r.html.contains("<h1>hi</h1>"), "html: {}", r.html);
882        let ir = render_to_ir("para", &Options::default());
883        assert!(!ir.ir.blocks.is_empty());
884        let (blocks, _) = render_blocks_to_ir("a\n\nb", &Options::default());
885        assert_eq!(blocks.len(), 2);
886        assert_eq!(serialize("plain"), "plain");
887    }
888
889    /// Tier-A canary: every occurrence of `[#` must be inside an
890    /// `aozora-md-annotation` wrapper — never in raw body text.
891    fn contains_bare_bracket(html: &str) -> bool {
892        let needle = "[#";
893        let wrapper_open = "aozora-md-annotation";
894        let mut pos = 0;
895        while let Some(idx) = html[pos..].find(needle) {
896            let abs = pos + idx;
897            let prefix = &html[..abs];
898            let last_open = prefix.rfind('<').unwrap_or(0);
899            let last_close = prefix.rfind('>').unwrap_or(0);
900            let inside_tag = last_open > last_close;
901            let in_wrapper = prefix.contains(wrapper_open);
902            if !inside_tag && !in_wrapper {
903                return true;
904            }
905            pos = abs + needle.len();
906        }
907        false
908    }
909}