Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/rules/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ paths:
`markdown/model.rs` は renderer-neutral に保つ。`ratatui` や端末型への依存をここへ持ち込まない。フレーバー差は `flavor/` の内側に閉じ、`layout/` 以降へ持ち込まない。`azure_devops.rs` は Azure 固有の記法を汎用の block / inline へ変換する役目であって、描画の分岐を作る役目ではない。

- **`Block::Image` は作らない。** CommonMark では画像は inline 要素であり、段落と独立した block にすると source range と wrap 処理が二重化する。`Inline::Image` として保持し、layout 側で `[image: ...]` / `[attachment: ...]` の placeholder か、描画領域へ変換する。
- **`Block::Alert` も作らない。** GFM alert は「ラベルを持つ引用」であり、構造は `Block::Quote` と同一。`QuoteBlock.kind: Option<AlertKind>` として持てば、`Block::range()`・`collect_headings`・`gfm::promote_diagrams`・`azure_devops.rs` の再帰といった既存の走査がそのまま動く。variant を足すとその全部に分岐が増え、得られるのは layout の 1 箇所の分岐だけになる。
- **`Block::Alert` も作らない。** GFM alert は「ラベルを持つ引用」であり、構造は `Block::Quote` と同一。`QuoteBlock.kind: Option<AlertKind>` として持てば、`Block::range()` や `Block::children()` を通る既存の走査がそのまま動く。variant を足すとその全部に分岐が増え、得られるのは layout の 1 箇所の分岐だけになる。
- alert の判定は `Options::ENABLE_GFM` に任せ、フレーバーで分けない。Azure DevOps Wiki も同じ `> [!NOTE]` 記法を持つため、`markdown/parser.rs` の共通経路に置くのが両フレーバーで正しい。未知の種別(`[!FOO]`)は `kind: None` の普通の引用になり、マーカーは本文に残る。
- ブロック木の再帰は `Block::children()` / `children_mut()` だけを通す。走査ごとに container を列挙すると、どこまで降りるかが走査ごとにずれる。container を足すときはこの 2 つに足す。
- すべての user-authored block は `SourceRange`(byte 範囲 + 1-based 行)を持つ。`--line` と `--watch` の読み位置保持がこれに依存するので、新しい block を足すときも必ず持たせる。
- 未終端 `:::` container の body は **最初の空行で打ち切り**、diagnostic として報告する。markdown-it 系のように文書末尾まで伸ばすと、`::: mermaid` の閉じ忘れ 1 個で以降の本文全体が diagram body に吸収され、viewer として最も見たいものが読めなくなる。
- 閉じマーカー探索は code fence を認識する。fence 内の `:::` はリテラルであり closer ではない。fence 内の Azure 記法も同様にリテラルのまま残す。
Expand Down
2 changes: 1 addition & 1 deletion .claude/rules/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ config は **既存フラグの既定値を上書きするだけ**。テーマ

## レイアウトの再実行

ドキュメントは起動時に一度だけ parse し、layout はリサイズと reload のときだけ再実行する。frame ごとに layout や diagram 描画を走らせない。reload は起動時とまったく同じ経路(`input::load` → `flavor::parse` → `diagram::resolve` → `layout_document`)を通るので、再読込した文書は開き直した文書と区別できない。
ドキュメントは起動時に一度だけ parse し、layout はリサイズと reload のときだけ再実行する。frame ごとに layout や diagram 描画を走らせない。reload は起動時とまったく同じ経路(`input::load` → `document::build` → `layout_document`)を通るので、再読込した文書は開き直した文書と区別できない。parse から diagram 描画までの手順は `document::build` にだけ書く。

## File watch

Expand Down
100 changes: 21 additions & 79 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,13 @@ use std::path::PathBuf;
use clap::builder::styling::{AnsiColor, Styles};
use clap::{ArgMatches, CommandFactory, FromArgMatches, Parser, ValueEnum, parser::ValueSource};

use crate::diagram::MermaidMode;
use crate::flavor::Flavor;
use crate::image::Protocol;
use crate::input::InputSource;
use crate::layout::icons::IconSet;
use crate::layout::theme::ThemeChoice;
use crate::output::ColorChoice;

const STYLES: Styles = Styles::styled()
.header(AnsiColor::Green.on_default().bold())
Expand Down Expand Up @@ -37,8 +42,8 @@ pub struct Cli {
pub width: Option<u16>,

/// Open near the given 1-based source line
#[arg(short = 'l', long, value_name = "LINE", value_parser = clap::value_parser!(u64).range(1..))]
pub line: Option<u64>,
#[arg(short = 'l', long, value_name = "LINE", value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..))]
pub line: Option<usize>,

/// Markdown flavor
#[arg(long, value_enum, default_value_t = Flavor::AzureDevops)]
Expand All @@ -49,8 +54,8 @@ pub struct Cli {
pub mermaid: MermaidMode,

/// Theme
#[arg(long, value_enum, default_value_t = Theme::Auto)]
pub theme: Theme,
#[arg(long, value_enum, default_value_t = ThemeChoice::Auto)]
pub theme: ThemeChoice,

/// ANSI color policy
#[arg(long, value_enum, default_value_t = ColorWhen::Auto, value_name = "WHEN")]
Expand All @@ -69,8 +74,8 @@ pub struct Cli {
pub images: ImagesWhen,

/// Glyphs used for alerts, code fences and placeholders
#[arg(long, value_enum, default_value_t = IconsSet::Unicode, value_name = "SET")]
pub icons: IconsSet,
#[arg(long, value_enum, default_value_t = IconSet::Unicode, value_name = "SET")]
pub icons: IconSet,

/// Re-render the file when it changes on disk
#[arg(long)]
Expand All @@ -81,30 +86,6 @@ pub struct Cli {
pub plain: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Flavor {
Gfm,
#[value(name = "azure-devops")]
AzureDevops,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum MermaidMode {
#[default]
Unicode,
Ascii,
Image,
Source,
Off,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Theme {
Auto,
Dark,
Light,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum ColorWhen {
Auto,
Expand Down Expand Up @@ -135,36 +116,13 @@ pub enum ImagesWhen {
Never,
}

/// `nerd` is opt-in and never detected: whether a Nerd Font is installed is a
/// property of the terminal's font, and asking the terminal would mean writing
/// to the tty and waiting for an answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum IconsSet {
Unicode,
Nerd,
}

/// Where the Markdown source comes from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputSource {
File(PathBuf),
Stdin,
}

/// Which backend renders the document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Pager,
Stdout,
}

/// Whether the stdout backend emits ANSI escapes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColorChoice {
Ansi,
Plain,
}

/// Terminal facts that mode resolution depends on. Kept explicit so resolution
/// stays a pure function and remains testable without a TTY.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down Expand Up @@ -386,22 +344,6 @@ impl Cli {
(mode, _) => mode,
}
}

/// Which glyph set the chrome is drawn with.
///
/// Glyphs are ordinary characters rather than escape sequences, so unlike
/// images and hyperlinks they are not tied to the colour policy: `--plain`
/// still shows them.
pub fn icons(&self) -> IconSet {
match self.icons {
IconsSet::Unicode => IconSet::Unicode,
IconsSet::Nerd => IconSet::Nerd,
}
}

pub fn start_line(&self) -> Option<usize> {
self.line.map(|n| n as usize)
}
}

#[cfg(test)]
Expand Down Expand Up @@ -442,7 +384,7 @@ mod tests {
let cli = cli(&["a.md"]);
assert_eq!(cli.flavor, Flavor::AzureDevops);
assert_eq!(cli.mermaid, MermaidMode::Unicode);
assert_eq!(cli.theme, Theme::Auto);
assert_eq!(cli.theme, ThemeChoice::Auto);
assert_eq!(cli.color, ColorWhen::Auto);
}

Expand Down Expand Up @@ -516,39 +458,39 @@ mod tests {
let config = Config {
flavor: Some(Flavor::Gfm),
mermaid: Some(MermaidMode::Ascii),
theme: Some(Theme::Light),
theme: Some(ThemeChoice::Light),
color: Some(ColorWhen::Never),
hyperlinks: Some(HyperlinkWhen::Always),
highlight: Some(HighlightWhen::Never),
images: Some(ImagesWhen::Never),
icons: Some(IconsSet::Nerd),
icons: Some(IconSet::Nerd),
width: Some(100),
watch: None,
};
let cli = configured(&["a.md"], &config);
assert_eq!(cli.flavor, Flavor::Gfm);
assert_eq!(cli.mermaid, MermaidMode::Ascii);
assert_eq!(cli.theme, Theme::Light);
assert_eq!(cli.theme, ThemeChoice::Light);
assert_eq!(cli.color, ColorWhen::Never);
assert_eq!(cli.hyperlinks, HyperlinkWhen::Always);
assert_eq!(cli.highlight, HighlightWhen::Never);
assert_eq!(cli.images, ImagesWhen::Never);
assert_eq!(cli.icons, IconsSet::Nerd);
assert_eq!(cli.icons, IconSet::Nerd);
assert_eq!(cli.width, Some(100));
}

/// Glyphs are opt-in, and a reader who turned them on in the configuration
/// can still ask for the plain set on a machine without the font.
#[test]
fn icons_default_to_unicode_and_are_chosen_explicitly() {
assert_eq!(cli(&["a.md"]).icons(), IconSet::Unicode);
assert_eq!(cli(&["--icons", "nerd", "a.md"]).icons(), IconSet::Nerd);
assert_eq!(cli(&["a.md"]).icons, IconSet::Unicode);
assert_eq!(cli(&["--icons", "nerd", "a.md"]).icons, IconSet::Nerd);
let config = Config {
icons: Some(IconsSet::Nerd),
icons: Some(IconSet::Nerd),
..Config::default()
};
assert_eq!(
configured(&["--icons", "unicode", "a.md"], &config).icons(),
configured(&["--icons", "unicode", "a.md"], &config).icons,
IconSet::Unicode
);
}
Expand All @@ -558,7 +500,7 @@ mod tests {
#[test]
fn icons_are_independent_of_the_colour_policy() {
assert_eq!(
cli(&["--plain", "--icons", "nerd", "a.md"]).icons(),
cli(&["--plain", "--icons", "nerd", "a.md"]).icons,
IconSet::Nerd
);
}
Expand Down
16 changes: 9 additions & 7 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ use std::path::PathBuf;
use clap::ValueEnum;
use serde::Deserialize;

use crate::cli::{
ColorWhen, Flavor, HighlightWhen, HyperlinkWhen, IconsSet, ImagesWhen, MermaidMode, Theme,
};
use crate::cli::{ColorWhen, HighlightWhen, HyperlinkWhen, ImagesWhen};
use crate::diagram::MermaidMode;
use crate::flavor::Flavor;
use crate::layout::icons::IconSet;
use crate::layout::theme::ThemeChoice;

/// Overrides an absent flag would otherwise take from its built-in default.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Config {
pub flavor: Option<Flavor>,
pub mermaid: Option<MermaidMode>,
pub theme: Option<Theme>,
pub theme: Option<ThemeChoice>,
pub color: Option<ColorWhen>,
pub hyperlinks: Option<HyperlinkWhen>,
pub highlight: Option<HighlightWhen>,
pub images: Option<ImagesWhen>,
pub icons: Option<IconsSet>,
pub icons: Option<IconSet>,
pub width: Option<u16>,
pub watch: Option<bool>,
}
Expand Down Expand Up @@ -186,12 +188,12 @@ mod tests {
Config {
flavor: Some(Flavor::Gfm),
mermaid: Some(MermaidMode::Ascii),
theme: Some(Theme::Light),
theme: Some(ThemeChoice::Light),
color: Some(ColorWhen::Always),
hyperlinks: Some(HyperlinkWhen::Never),
highlight: Some(HighlightWhen::Never),
images: Some(ImagesWhen::Kitty),
icons: Some(IconsSet::Nerd),
icons: Some(IconSet::Nerd),
width: Some(100),
watch: None,
}
Expand Down
2 changes: 1 addition & 1 deletion src/diagram/mermaid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use merman::ascii::{AsciiRenderOptions, HeadlessAsciiRenderer};
use merman::render::HeadlessRenderer;

use crate::cli::MermaidMode;
use crate::diagram::MermaidMode;
use crate::markdown::model::DiagramPng;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand Down
60 changes: 31 additions & 29 deletions src/diagram/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,23 @@ mod raster;
#[cfg(test)]
mod tests;

use crate::cli::{Flavor, MermaidMode};
use clap::ValueEnum;

use crate::diagnostic::Diagnostic;
use crate::flavor::Flavor;
use crate::markdown::model::{Block, DiagramBlock, Document};
use mermaid::{DiagramRenderOptions, DiagramRenderer, MermanRenderer};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
pub enum MermaidMode {
#[default]
Unicode,
Ascii,
Image,
Source,
Off,
}

pub fn resolve(document: &mut Document, mode: MermaidMode, flavor: Flavor) {
let renderer = MermanRenderer;
let mut diagnostics = Vec::new();
Expand All @@ -38,38 +50,28 @@ fn resolve_blocks(
diagnostics: &mut Vec<Diagnostic>,
) {
for block in blocks.iter_mut() {
match block {
Block::Diagram(diagram) => {
if flavor == Flavor::AzureDevops {
diagram.warnings = azure_compat::warnings(&diagram.source);
for warning in &diagram.warnings {
diagnostics.push(Diagnostic::warning(warning.clone(), Some(diagram.range)));
}
if let Block::Diagram(diagram) = block {
if flavor == Flavor::AzureDevops {
diagram.warnings = azure_compat::warnings(&diagram.source);
for warning in &diagram.warnings {
diagnostics.push(Diagnostic::warning(warning.clone(), Some(diagram.range)));
}
match mode {
// Source and off never invoke the renderer.
MermaidMode::Source | MermaidMode::Off => {}
MermaidMode::Image => match renderer.render_png(&diagram.source) {
Ok(png) => diagram.png = Some(png),
// Text may still draw it, and reports the error if not.
Err(_) => render_text(diagram, renderer, MermaidMode::Unicode, diagnostics),
},
MermaidMode::Unicode | MermaidMode::Ascii => {
render_text(diagram, renderer, mode, diagnostics)
}
}
}
Block::Quote(q) => resolve_blocks(&mut q.blocks, renderer, mode, flavor, diagnostics),
Block::Details(d) => resolve_blocks(&mut d.blocks, renderer, mode, flavor, diagnostics),
Block::Footnote(f) => {
resolve_blocks(&mut f.blocks, renderer, mode, flavor, diagnostics)
}
Block::List(l) => {
for item in &mut l.items {
resolve_blocks(&mut item.blocks, renderer, mode, flavor, diagnostics);
match mode {
// Source and off never invoke the renderer.
MermaidMode::Source | MermaidMode::Off => {}
MermaidMode::Image => match renderer.render_png(&diagram.source) {
Ok(png) => diagram.png = Some(png),
// Text may still draw it, and reports the error if not.
Err(_) => render_text(diagram, renderer, MermaidMode::Unicode, diagnostics),
},
MermaidMode::Unicode | MermaidMode::Ascii => {
render_text(diagram, renderer, mode, diagnostics)
}
}
_ => {}
}
for children in block.children_mut() {
resolve_blocks(children, renderer, mode, flavor, diagnostics);
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/diagram/tests.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::cli::{Flavor, MermaidMode};
use crate::diagnostic::Severity;
use crate::diagram::MermaidMode;
use crate::diagram::resolve;
use crate::flavor;
use crate::flavor::Flavor;
use crate::markdown::model::{Block, DiagramBlock, Document};
use crate::source::SourceText;

Expand Down
15 changes: 15 additions & 0 deletions src/document.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Markdown text to a document ready for layout.
//!
//! Startup and every `--watch` reload go through `build`, so a reloaded
//! document is indistinguishable from one opened fresh.

use crate::diagram::{self, MermaidMode};
use crate::flavor::{self, Flavor};
use crate::markdown::model::Document;
use crate::source::SourceText;

pub fn build(text: String, flavor: Flavor, mermaid: MermaidMode) -> Document {
let mut document = flavor::parse(SourceText::new(text), flavor);
diagram::resolve(&mut document, mermaid, flavor);
document
}
Loading
Loading