Unsupported input should explain itself. A heading outside the subset, an unclosed code fence or a forbidden link needs a source line, column and fixed code. Returning half a page with a success flag is not a useful publishing result.
compile_page composes block parsing and rendering. It catches SourceError and converts it into a stable failure record. It does not catch every exception and call it bad Markdown.
A source error belongs to authored input. A title outside configuration limits is a configuration error. An unexpected RuntimeError inside the renderer is an implementation bug. Keep those categories distinct so the person fixing the problem has honest evidence.
An empty normalized file has no blocks and cannot become a usable page. That is a source failure at line one, column one, even though normalization itself accepted the empty string.
def outcome(ok,html=None,error=None):
return {"ok":ok,"html":html,"error":error}
print(outcome(False,error={"line":3,"column":5,"code":"unclosed-inline"}))
print(outcome(True,html="<p>Complete page fragment</p>"))Line/column data must survive every stage. The block parser retained each source line and content offset; the inline scanner added its local opener index; the renderer reused that token location when a link policy failed. The wrapper should copy those values, not replace every column with one.
The result contains no source snippets or raw local paths. It reports the fixed error code and location. The local CLI can print that diagnosis and refuse to replace a previously successful output file.
Tests intentionally inject an implementation failure to ensure it is not converted into a false source error. The fixture restores the original renderer afterward. That checks failure ownership rather than hiding a bug behind a catch-all handler.
class SourceError(ValueError): pass
for error in [SourceError("unclosed"),RuntimeError("bug")]:
try: raise error
except SourceError: print("source diagnostic")
except RuntimeError: print("implementation failure")Complete the wrapper and test successful block counts, an inline error on a later source line, a forbidden link, empty source and an unexpected renderer failure. Every failed source result must have html None.
Next we fingerprint the successful bytes and connect the pure model to a repeatable local publishing command.