parity(7): table cell alignment markers (|:--|--:|:--:|)
The Markdown-style alignment anchors on a table's header-separator row now propagate from the lexer through the parser into the AST, and the HTML renderer emits a `style="text-align: …;"` attribute on each affected `<th>` / `<td>`. |:--| → left |--:| → right |:--:| → center |---| → default (no style attr) Encoding choice: rather than threading the source string into the parser, the lexer now extracts per-cell alignment when it recognises the separator row and carries the `Vec<TableAlign>` inline on the `TableHeaderRow` token. The parser unpacks it into `TableNode`'s new `alignments: Vec<TableAlign>` field. Cells past the end of the vector render with the default alignment. `TableAlign` lives in `nuwiki_core::ast::block` alongside the table nodes and is re-exported via `nuwiki_core::ast`. Tests: 4 new in crates/nuwiki-core/tests/vimwiki_table_alignment.rs covering plain dashes, all three anchor flavours, the HTML output shape, and the no-style-attr case. The existing `vimwiki_lexer::table_header_separator_row` was updated to assert the new payload shape. Gates: 425 Rust / 39 Neovim / 12 Vim. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,11 +118,29 @@ pub struct DefinitionItemNode {
|
|||||||
pub definitions: Vec<Vec<InlineNode>>,
|
pub definitions: Vec<Vec<InlineNode>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TableAlign {
|
||||||
|
Default,
|
||||||
|
Left,
|
||||||
|
Right,
|
||||||
|
Center,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TableAlign {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TableNode {
|
pub struct TableNode {
|
||||||
pub span: Span,
|
pub span: Span,
|
||||||
pub rows: Vec<TableRowNode>,
|
pub rows: Vec<TableRowNode>,
|
||||||
pub has_header: bool,
|
pub has_header: bool,
|
||||||
|
/// One entry per column, taken from a Markdown-style header
|
||||||
|
/// separator row (`|:--|--:|:--:|`). Empty when no alignment was
|
||||||
|
/// specified; cells past the end inherit `Default`.
|
||||||
|
pub alignments: Vec<TableAlign>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ pub mod visit;
|
|||||||
pub use block::{
|
pub use block::{
|
||||||
BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode,
|
BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode,
|
||||||
ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode,
|
ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode,
|
||||||
ParagraphNode, PreformattedNode, TableCellNode, TableNode, TableRowNode, TagNode, TagScope,
|
ParagraphNode, PreformattedNode, TableAlign, TableCellNode, TableNode, TableRowNode, TagNode,
|
||||||
|
TagScope,
|
||||||
};
|
};
|
||||||
pub use inline::{
|
pub use inline::{
|
||||||
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, KeywordNode,
|
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, KeywordNode,
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ impl HtmlRenderer {
|
|||||||
in_tbody = true;
|
in_tbody = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.render_table_row(row, row_idx, &layout, w)?;
|
self.render_table_row(row, row_idx, &layout, &n.alignments, w)?;
|
||||||
}
|
}
|
||||||
if in_thead {
|
if in_thead {
|
||||||
w.write_all(b"</thead>\n")?;
|
w.write_all(b"</thead>\n")?;
|
||||||
@@ -326,6 +326,7 @@ impl HtmlRenderer {
|
|||||||
n: &TableRowNode,
|
n: &TableRowNode,
|
||||||
row_idx: usize,
|
row_idx: usize,
|
||||||
layout: &[Vec<CellLayout>],
|
layout: &[Vec<CellLayout>],
|
||||||
|
alignments: &[crate::ast::TableAlign],
|
||||||
w: &mut dyn Write,
|
w: &mut dyn Write,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
w.write_all(b"<tr>")?;
|
w.write_all(b"<tr>")?;
|
||||||
@@ -341,7 +342,11 @@ impl HtmlRenderer {
|
|||||||
match info {
|
match info {
|
||||||
CellLayout::Skip => continue,
|
CellLayout::Skip => continue,
|
||||||
CellLayout::Lead { colspan, rowspan } => {
|
CellLayout::Lead { colspan, rowspan } => {
|
||||||
self.render_table_cell(cell, n.is_header, colspan, rowspan, w)?;
|
let align = alignments
|
||||||
|
.get(col_idx)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(crate::ast::TableAlign::Default);
|
||||||
|
self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,6 +359,7 @@ impl HtmlRenderer {
|
|||||||
is_header: bool,
|
is_header: bool,
|
||||||
colspan: u32,
|
colspan: u32,
|
||||||
rowspan: u32,
|
rowspan: u32,
|
||||||
|
align: crate::ast::TableAlign,
|
||||||
w: &mut dyn Write,
|
w: &mut dyn Write,
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
let tag = if is_header { "th" } else { "td" };
|
let tag = if is_header { "th" } else { "td" };
|
||||||
@@ -364,6 +370,15 @@ impl HtmlRenderer {
|
|||||||
if rowspan > 1 {
|
if rowspan > 1 {
|
||||||
write!(w, " rowspan=\"{rowspan}\"")?;
|
write!(w, " rowspan=\"{rowspan}\"")?;
|
||||||
}
|
}
|
||||||
|
let align_attr = match align {
|
||||||
|
crate::ast::TableAlign::Left => Some("left"),
|
||||||
|
crate::ast::TableAlign::Right => Some("right"),
|
||||||
|
crate::ast::TableAlign::Center => Some("center"),
|
||||||
|
crate::ast::TableAlign::Default => None,
|
||||||
|
};
|
||||||
|
if let Some(a) = align_attr {
|
||||||
|
write!(w, " style=\"text-align: {a};\"")?;
|
||||||
|
}
|
||||||
write!(w, ">")?;
|
write!(w, ">")?;
|
||||||
self.render_inlines(&n.children, w)?;
|
self.render_inlines(&n.children, w)?;
|
||||||
write!(w, "</{tag}>")
|
write!(w, "</{tag}>")
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ pub enum VimwikiTokenKind {
|
|||||||
// ----- Tables -----
|
// ----- Tables -----
|
||||||
/// `|` cell separator.
|
/// `|` cell separator.
|
||||||
TableSep,
|
TableSep,
|
||||||
/// A header-separator row, e.g. `|---|---|`.
|
/// A header-separator row, e.g. `|---|---|`. Carries per-cell
|
||||||
TableHeaderRow,
|
/// Markdown-style alignment markers (`:--`, `--:`, `:--:`).
|
||||||
|
TableHeaderRow(Vec<crate::ast::TableAlign>),
|
||||||
/// A cell whose only content is `>`, meaning "merge with cell to the left".
|
/// A cell whose only content is `>`, meaning "merge with cell to the left".
|
||||||
TableColSpan,
|
TableColSpan,
|
||||||
/// A cell whose only content is `\/`, meaning "merge with cell above".
|
/// A cell whose only content is `\/`, meaning "merge with cell above".
|
||||||
@@ -563,14 +564,20 @@ impl<'src> LexState<'src> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header separator row: every cell content is dashes (e.g. "|---|---|").
|
// Header separator row: every cell is `-`s (with optional `:`
|
||||||
|
// anchors at one or both ends for alignment) or whitespace.
|
||||||
if trimmed
|
if trimmed
|
||||||
.split('|')
|
.split('|')
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.all(|cell| cell.chars().all(|c| c == '-' || c == ' ' || c == '\t'))
|
.all(is_sep_cell)
|
||||||
{
|
{
|
||||||
|
let aligns: Vec<crate::ast::TableAlign> = trimmed
|
||||||
|
.split('|')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(parse_sep_alignment)
|
||||||
|
.collect();
|
||||||
let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
|
let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
|
||||||
self.push(VimwikiTokenKind::TableHeaderRow, span);
|
self.push(VimwikiTokenKind::TableHeaderRow(aligns), span);
|
||||||
self.emit_newline(line);
|
self.emit_newline(line);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1199,6 +1206,29 @@ fn looks_like_list_marker(s: &str) -> bool {
|
|||||||
list_marker_at(s).is_some_and(|(_, len)| s.as_bytes().get(len) == Some(&b' '))
|
list_marker_at(s).is_some_and(|(_, len)| s.as_bytes().get(len) == Some(&b' '))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Is this cell content a valid separator-cell body? Allows leading /
|
||||||
|
/// trailing `:` for alignment and inner runs of `-` plus whitespace.
|
||||||
|
fn is_sep_cell(cell: &str) -> bool {
|
||||||
|
let t = cell.trim();
|
||||||
|
if t.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let body = t.trim_start_matches(':').trim_end_matches(':');
|
||||||
|
!body.is_empty() && body.chars().all(|c| c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_sep_alignment(cell: &str) -> crate::ast::TableAlign {
|
||||||
|
let t = cell.trim();
|
||||||
|
let l = t.starts_with(':');
|
||||||
|
let r = t.ends_with(':');
|
||||||
|
match (l, r) {
|
||||||
|
(true, true) => crate::ast::TableAlign::Center,
|
||||||
|
(true, false) => crate::ast::TableAlign::Left,
|
||||||
|
(false, true) => crate::ast::TableAlign::Right,
|
||||||
|
_ => crate::ast::TableAlign::Default,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn checkbox_at(s: &str) -> Option<(CheckboxState, usize)> {
|
fn checkbox_at(s: &str) -> Option<(CheckboxState, usize)> {
|
||||||
let b = s.as_bytes();
|
let b = s.as_bytes();
|
||||||
if b.len() < 3 || b[0] != b'[' || b[2] != b']' {
|
if b.len() < 3 || b[0] != b'[' || b[2] != b']' {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ impl<'a> ParseState<'a> {
|
|||||||
K::HorizontalRule => self.parse_horizontal_rule(),
|
K::HorizontalRule => self.parse_horizontal_rule(),
|
||||||
K::ListMarker { .. } => self.parse_list(),
|
K::ListMarker { .. } => self.parse_list(),
|
||||||
K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(),
|
K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(),
|
||||||
K::TableSep | K::TableHeaderRow => self.parse_table(),
|
K::TableSep | K::TableHeaderRow(_) => self.parse_table(),
|
||||||
K::PreformattedOpen { .. } => self.parse_preformatted(),
|
K::PreformattedOpen { .. } => self.parse_preformatted(),
|
||||||
K::MathBlockOpen { .. } => self.parse_math_block(),
|
K::MathBlockOpen { .. } => self.parse_math_block(),
|
||||||
K::CommentLine(_) => self.parse_single_comment(),
|
K::CommentLine(_) => self.parse_single_comment(),
|
||||||
@@ -620,11 +620,15 @@ impl<'a> ParseState<'a> {
|
|||||||
let mut rows: Vec<TableRowNode> = Vec::new();
|
let mut rows: Vec<TableRowNode> = Vec::new();
|
||||||
let mut next_is_header = false;
|
let mut next_is_header = false;
|
||||||
let mut has_header = false;
|
let mut has_header = false;
|
||||||
|
let mut alignments: Vec<crate::ast::TableAlign> = Vec::new();
|
||||||
|
|
||||||
while let Some(t) = self.peek() {
|
while let Some(t) = self.peek() {
|
||||||
match &t.kind {
|
match &t.kind {
|
||||||
K::TableHeaderRow => {
|
K::TableHeaderRow(aligns) => {
|
||||||
has_header = true;
|
has_header = true;
|
||||||
|
if alignments.is_empty() {
|
||||||
|
alignments = aligns.clone();
|
||||||
|
}
|
||||||
if let Some(last) = rows.last_mut() {
|
if let Some(last) = rows.last_mut() {
|
||||||
last.is_header = true;
|
last.is_header = true;
|
||||||
}
|
}
|
||||||
@@ -638,7 +642,6 @@ impl<'a> ParseState<'a> {
|
|||||||
span_end = row.span.end;
|
span_end = row.span.end;
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
if next_is_header {
|
if next_is_header {
|
||||||
// Header sep applies to the *previous* row, not this one.
|
|
||||||
next_is_header = false;
|
next_is_header = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -649,6 +652,7 @@ impl<'a> ParseState<'a> {
|
|||||||
span: Span::new(span_start, span_end),
|
span: Span::new(span_start, span_end),
|
||||||
rows,
|
rows,
|
||||||
has_header,
|
has_header,
|
||||||
|
alignments,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +874,7 @@ fn starts_new_block(kind: &K) -> bool {
|
|||||||
| K::BlockquoteMarker
|
| K::BlockquoteMarker
|
||||||
| K::BlockquoteIndent
|
| K::BlockquoteIndent
|
||||||
| K::TableSep
|
| K::TableSep
|
||||||
| K::TableHeaderRow
|
| K::TableHeaderRow(_)
|
||||||
| K::PreformattedOpen { .. }
|
| K::PreformattedOpen { .. }
|
||||||
| K::MathBlockOpen { .. }
|
| K::MathBlockOpen { .. }
|
||||||
| K::CommentLine(_)
|
| K::CommentLine(_)
|
||||||
|
|||||||
@@ -352,7 +352,14 @@ fn simple_table_row() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn table_header_separator_row() {
|
fn table_header_separator_row() {
|
||||||
assert_eq!(lex("|---|---|\n"), vec![TableHeaderRow, Newline]);
|
use nuwiki_core::ast::TableAlign;
|
||||||
|
assert_eq!(
|
||||||
|
lex("|---|---|\n"),
|
||||||
|
vec![
|
||||||
|
TableHeaderRow(vec![TableAlign::Default, TableAlign::Default]),
|
||||||
|
Newline
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Cluster 7 — Markdown-style alignment markers on the header
|
||||||
|
//! separator row (`|:--|--:|:--:|`) propagate to the AST and HTML.
|
||||||
|
|
||||||
|
use nuwiki_core::ast::{BlockNode, TableAlign};
|
||||||
|
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||||
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
|
|
||||||
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||||
|
VimwikiSyntax::new().parse(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn separator_row_with_no_anchors_yields_defaults() {
|
||||||
|
let doc = parse("| h1 | h2 |\n|----|----|\n| a | b |\n");
|
||||||
|
let table = match &doc.children[0] {
|
||||||
|
BlockNode::Table(t) => t,
|
||||||
|
_ => panic!("expected table"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
table.alignments,
|
||||||
|
vec![TableAlign::Default, TableAlign::Default]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn left_right_center_anchors_parsed() {
|
||||||
|
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
|
||||||
|
let table = match &doc.children[0] {
|
||||||
|
BlockNode::Table(t) => t,
|
||||||
|
_ => panic!("expected table"),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
table.alignments,
|
||||||
|
vec![TableAlign::Left, TableAlign::Right, TableAlign::Center]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn html_renderer_emits_text_align_style() {
|
||||||
|
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
|
||||||
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.contains(r#"<th style="text-align: left;"> a </th>"#),
|
||||||
|
"got: {out}"
|
||||||
|
);
|
||||||
|
assert!(out.contains(r#"<th style="text-align: right;"> b </th>"#));
|
||||||
|
assert!(out.contains(r#"<th style="text-align: center;"> c </th>"#));
|
||||||
|
assert!(out.contains(r#"<td style="text-align: left;"> 1 </td>"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_without_alignment_omits_style_attr() {
|
||||||
|
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||||
|
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||||
|
assert!(!out.contains("text-align"));
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@ fn visitor_descends_into_every_node() {
|
|||||||
let table = BlockNode::Table(TableNode {
|
let table = BlockNode::Table(TableNode {
|
||||||
span: Span::default(),
|
span: Span::default(),
|
||||||
has_header: false,
|
has_header: false,
|
||||||
|
alignments: Vec::new(),
|
||||||
rows: vec![TableRowNode {
|
rows: vec![TableRowNode {
|
||||||
span: Span::default(),
|
span: Span::default(),
|
||||||
is_header: false,
|
is_header: false,
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ fn table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
|
|||||||
span,
|
span,
|
||||||
rows,
|
rows,
|
||||||
has_header,
|
has_header,
|
||||||
|
alignments: Vec::new(),
|
||||||
})],
|
})],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user