feat(content-build): scaffold package + Markdown yaml-block parser

This commit is contained in:
2026-07-11 18:01:45 -05:00
parent 40c3a7fc4b
commit bd293c1701
6 changed files with 116 additions and 0 deletions

View File

View File

@@ -0,0 +1,12 @@
"""The single failure type. Every validation/parse problem raises BuildError so
the CLI can print a uniform 'BUILD FAILED: <what> [<id>] (<file:line>)'."""
class BuildError(Exception):
def __init__(self, message: str, source: str = "", entry_id: str = ""):
self.message = message
self.source = source
self.entry_id = entry_id
loc = f" [{entry_id}]" if entry_id else ""
src = f" ({source})" if source else ""
super().__init__(f"{message}{loc}{src}")

View File

@@ -0,0 +1,40 @@
"""Markdown bible -> raw yaml-block entries. Knows only fences + YAML; nothing
about the schema's meaning. A block is opened by a line that is exactly ```yaml
(after strip) and closed by a line that is exactly ```."""
from dataclasses import dataclass
from pathlib import Path
import yaml
from .errors import BuildError
@dataclass
class RawEntry:
data: dict
source: str # "duncarrow.md:53"
def parse_bible(path: Path) -> list[RawEntry]:
lines = path.read_text().splitlines()
entries: list[RawEntry] = []
i, n = 0, len(lines)
while i < n:
if lines[i].strip() == "```yaml":
fence_line = i + 1 # 1-based line of the opening fence
i += 1
block: list[str] = []
while i < n and lines[i].strip() != "```":
block.append(lines[i])
i += 1
if i >= n:
raise BuildError("unterminated ```yaml block",
source=f"{path.name}:{fence_line}")
data = yaml.safe_load("\n".join(block))
if not isinstance(data, dict):
raise BuildError("yaml block is not a mapping",
source=f"{path.name}:{fence_line}")
entries.append(RawEntry(data=data, source=f"{path.name}:{fence_line}"))
i += 1
return entries

View File

View File

@@ -0,0 +1,61 @@
from pathlib import Path
import pytest
from content_build.errors import BuildError
from content_build.parse import parse_bible
BIBLE = """# A bible
Some prose that must be ignored.
```yaml
id: town.a
type: town
status: canon
secrecy: 0
related: []
body: >
Town A.
```
More prose.
```yaml
id: rumor.x
type: rumor
status: canon
secrecy: 1
related: [town.a]
body: >
A rumor.
```
"""
def _write(tmp_path: Path, text: str) -> Path:
p = tmp_path / "bible.md"
p.write_text(text)
return p
def test_extracts_yaml_blocks_and_ignores_prose(tmp_path):
entries = parse_bible(_write(tmp_path, BIBLE))
assert [e.data["id"] for e in entries] == ["town.a", "rumor.x"]
def test_source_carries_filename_and_line(tmp_path):
entries = parse_bible(_write(tmp_path, BIBLE))
assert entries[0].source.startswith("bible.md:")
def test_unterminated_block_raises(tmp_path):
bad = "```yaml\nid: town.a\n" # no closing fence
with pytest.raises(BuildError):
parse_bible(_write(tmp_path, bad))
def test_non_mapping_block_raises(tmp_path):
bad = "```yaml\n- just\n- a\n- list\n```\n"
with pytest.raises(BuildError):
parse_bible(_write(tmp_path, bad))

View File

@@ -0,0 +1,3 @@
# Build-tool dev/CI deps. NOT installed into the api proxy runtime.
PyYAML==6.0.3
pytest==8.3.2