From 648f37de5280f21972e20f1cdfb0c078931cc907 Mon Sep 17 00:00:00 2001
From: Claude
) still parts two words.
_DROPPED = Text("")
@@ -330,74 +327,25 @@ def _split_sentinels(self, text: str) -> list[Inline]:
def blocks(self, blocks: list[ModelBlock], section: int | None, *, markers: bool = True) -> tuple[Block, ...]:
"""Render-tree blocks for the validator's *blocks* of section index
*section* (None for the preamble). With *markers* False — text the
- validator reads inside a quote or an item — no marker is placed.
-
- The one place the builder reads past the validator's model: a
- comment (§8.6) left open in a paragraph or a list item runs on to
- the first ``-->`` in the blocks that follow, which render nothing
- (docs/architecture.md, stage 3; ForLegalAI/legaldown-validator#23).
- It never runs past the end of *blocks*."""
+ validator reads inside a quote or an item — no marker is placed."""
out: list[Block] = []
- in_comment = False
top_level = section is not None and markers
for index, block in enumerate(blocks):
- built: Block | None
- if in_comment and block.kind not in _PARAGRAPH_KINDS | _LIST_KINDS:
- # A quote, code, table, or rule the comment runs into: what
- # follows its "-->" is kept as text, its structure is lost.
- built, in_comment = self.paragraph(render_block(block), "", "", top_level=top_level, in_comment=True)
- if built is not None:
- out.append(built)
+ if block.kind == "html":
+ self._html_block(block)
continue
placed = self._placed(block, section, index) if markers else {}
- if block.kind in _PARAGRAPH_KINDS:
- block, identifier, condition = _strip_markers(block, placed)
- built, in_comment = self.paragraph(
- _paragraph_source(block), identifier, condition, top_level=top_level, in_comment=in_comment)
- elif block.kind in _LIST_KINDS:
- built, in_comment = self.list(block, placed, in_comment=in_comment)
- else:
- built = self.block(block, placed)
+ built = self.block(block, placed, top_level=top_level)
if built is not None:
out.append(built)
return tuple(out)
- def paragraph(
- self, source: str, identifier: str, condition: str, *, top_level: bool, in_comment: bool,
- ) -> tuple[Paragraph | None, bool]:
- """A paragraph for *source*, and whether a comment is left open at
- its end. With *in_comment*, the text up to the first ``-->`` is
- inside a comment opened earlier; without one, the whole text is."""
- if in_comment:
- end = source.find("-->")
- if end < 0:
- return None, True
- source = source[end + 3:]
- opening = self._unclosed_comment(source)
- if opening >= 0:
- source = source[:opening]
- inlines = self.text(source)
- if not inlines and not identifier:
- return None, opening >= 0 # only a comment
- return Paragraph(inlines, anchor_id=identifier, condition=condition, top_level=top_level), opening >= 0
-
- def _unclosed_comment(self, source: str) -> int:
- """Offset of a ```` closes, or -1.
-
- The lexer's view has code spans and closed comments blanked; what
- it leaves is literal to it. Of that, an escaped ``\\`` and ````
- (complete in CommonMark) open nothing."""
- lexed = self.markers.lex(source)
- view = lexed.view
- at = view.find("")
- if end < 0:
- continue
- item, in_comment = item[end + 3:].lstrip(" "), False
- if not item.strip() and not identifier:
- continue # nothing after the comment's end
first, _, rest = item.partition("\n")
if first.lstrip().startswith(">") or _FENCE_RE.match(first):
# The item opens with a quote or code, whose lines the
# validator keeps as written (a drafting note included).
blocks = self.fragment(item)
else:
- opening = self._unclosed_comment(first)
- if opening >= 0:
- first = first[:opening]
- end = rest.find("-->")
- rest, in_comment = ("", True) if end < 0 else (rest[end + 3:], False)
- blocks = (Paragraph(self.text(first)),) + (self.fragment(rest) if rest.strip() else ())
+ blocks = (Paragraph(self.text(first)),) + (self.fragment(rest) if rest else ())
items.append(ListItem(blocks=blocks, anchor_id=identifier, condition=condition))
- return (List(ordered=block.kind == "ordered_list", items=tuple(items)) if items else None), in_comment
+ return List(ordered=block.kind == "ordered_list", items=tuple(items))
def quote(self, block: ModelBlock) -> Block:
"""A block quote, its content read by the validator's parser. A
@@ -556,11 +494,12 @@ def _paragraph_source(block: ModelBlock) -> str:
def _code_block(text: str) -> CodeBlock:
- """A fenced code block from the validator's model, fences included."""
+ """A code block from the validator's model: fenced, fences included,
+ or indented, each line by four columns (CommonMark)."""
lines = text.split("\n")
opening = _FENCE_RE.match(lines[0]) if lines else None
if opening is None:
- return CodeBlock(text)
+ return CodeBlock("\n".join(_unindent(line) for line in lines) + "\n")
fence = opening.group(1)
body = lines[1:]
if body and body[-1].strip().startswith(fence[0] * len(fence)) and not body[-1].strip().strip(fence[0]):
@@ -568,6 +507,17 @@ def _code_block(text: str) -> CodeBlock:
return CodeBlock("\n".join(body) + ("\n" if body else ""), opening.group(2).strip())
+def _unindent(line: str) -> str:
+ """*line* without an indented code block's four columns of
+ indentation, a tab counting to the next multiple of four."""
+ column = 0
+ for index, char in enumerate(line):
+ if column >= 4 or char not in " \t":
+ return line[index:]
+ column = column + 4 - column % 4 if char == "\t" else column + 1
+ return ""
+
+
def _trim(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]:
"""*inlines* without spacing at either end (left where a comment or a
marker was removed)."""
diff --git a/src/legaldown_render/validator_bridge.py b/src/legaldown_render/validator_bridge.py
index ab725a7..f49debe 100644
--- a/src/legaldown_render/validator_bridge.py
+++ b/src/legaldown_render/validator_bridge.py
@@ -17,12 +17,12 @@
from legaldown import Document
from legaldown.definitions import block_fragments, text_fragments
-from legaldown.directives import Lexed, is_escaped, lex
+from legaldown.directives import Lexed, lex
from legaldown.validator.core import _frontmatter_fields
from legaldown.validator.templates import block_quotes
from legaldown.validator.units import FoundMarker, find_markers
-__all__ = ["PlacedMarkers", "block_fragments", "block_quotes", "is_escaped", "lex", "placed_markers"]
+__all__ = ["PlacedMarkers", "block_fragments", "block_quotes", "lex", "placed_markers"]
@dataclass(frozen=True, slots=True)
diff --git a/tests/golden/features.default.html b/tests/golden/features.default.html
index bb6b501..04f2d63 100644
--- a/tests/golden/features.default.html
+++ b/tests/golden/features.default.html
@@ -137,13 +137,13 @@ 3. The Client shall pay $10,000.00 per month, and €1,234.50 for setup, within 30 days of July 1, 2026. The Provider's VAT number is DE123456789.
| Service | -Fee | -Notes | +Service | +Fee | +Notes |
|---|---|---|---|---|---|
| Hosting | $500.00 | per month | |||
| Support | $250.75 | see 3 | |||
| Hosting | $500.00 | per month | |||
| Support | $250.75 | see 3 |
diff --git a/tests/test_render.py b/tests/test_render.py index e2a413e..32ba578 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -406,7 +406,7 @@ def test_locale_option_and_language_hint() -> None: def test_unreadable_document() -> None: with pytest.raises(DocumentError): - render("---\n- a list\n---\n\n# A\n") + render("---\ntitle: [unclosed\n---\n\n# A\n") def test_options_object_and_keywords_are_exclusive() -> None: @@ -726,17 +726,30 @@ def _validator_rules(source: str) -> set[str]: return {d.rule for d in validate_document(parse_document(source)).diagnostics} -def test_a_heading_in_a_comment_renders_as_the_validator_reads_it() -> None: - # The validator reads the heading as a section; the renderer never - # crashes on a disagreement, because there is none to have. - result = render(FRONT + "# Zero\n\n\n", format="text") +def test_a_signature_block_heading_is_an_ordinary_section() -> None: + result = render(FRONT + "# One\n\nText.\n\n# Signature Block {#signature-block}\n\nSigned\n", format="text") assert [s.designation for s in result.tree.sections] == ["1", "2"] + assert "Signed" in result.output -def test_a_signature_block_heading_ends_the_body_as_in_the_validator() -> None: - result = render(FRONT + "# One\n\nText.\n\n# Signature Block {#signature-block}\n\nSigned\n", format="text") - assert [s.designation for s in result.tree.sections] == ["1"] - assert "Signed" not in result.output +def test_every_list_marker_makes_a_list() -> None: + output = text("# A\n\n* star\n* list\n\n1) one\n2) two\n") + assert "(a) star\n(b) list" in output + assert "1. one\n2. two" in output + + +def test_table_columns_keep_their_alignment() -> None: + html = render(FRONT + "# A\n\n| l | c | r | n |\n|:--|:-:|--:|---|\n| 1 | 2 | 3 | 4 |\n", standalone=False).output + assert 'l ' in html + assert '2 ' in html + assert '3 ' in html + assert "4 " in html + + +def test_indented_code_renders_as_code_without_its_indent() -> None: + html = render(FRONT + "# A\n\nPara.\n\n code {{ref: x}}\n deeper\n", standalone=False).output + assert "" in html + assert "BROKEN REF" not in html def test_markers_and_references_agree_with_the_validators_diagnostics() -> None: @@ -794,17 +807,35 @@ def test_an_item_opening_with_a_drafting_note_or_code() -> None: assert "```" not in output -def test_html_tags_are_never_emitted() -> None: - # Every tag is dropped and reported. Text between the tags of an HTML - # block stays, as the validator's model holds it as paragraph text - # (ForLegalAI/legaldown-validator#23); it is escaped like any text. +def test_an_html_block_is_dropped_whole_and_reported() -> None: result = render(FRONT + "# A\n\n\n\nAfter.\n", standalone=False) - assert "code {{ref: x}}\n deeper\n