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}