Deterministic ops layer and cli

This commit is contained in:
2026-05-04 00:23:04 +02:00
parent 6f0facd744
commit 274a7fcdd6
7 changed files with 778 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ from markitect_tool.contract import (
load_contract_file,
validate_contract,
)
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
from markitect_tool.query import InvalidQueryError, extract_document, query_document
from markitect_tool.schema import load_schema_file, validate_markdown_file, validate_schema
@@ -120,6 +121,160 @@ def extract(file: Path, selector: str, output_format: str) -> None:
_emit_extract(data, output_format)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option("--strip-frontmatter", is_flag=True, help="Remove YAML frontmatter.")
@click.option(
"--set",
"set_values",
multiple=True,
metavar="KEY=VALUE",
help="Set a frontmatter value. Dot paths create nested mappings.",
)
@click.option(
"--heading-delta",
type=int,
default=0,
show_default=True,
help="Shift ATX heading levels, clamped to 1..6.",
)
@click.option("--extract", "extract_selector", help="Replace content with selector output.")
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write transformed Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def transform(
file: Path,
strip_frontmatter: bool,
set_values: tuple[str, ...],
heading_delta: int,
extract_selector: str | None,
output: Path | None,
output_format: str,
) -> None:
"""Apply deterministic transforms to a Markdown file."""
try:
frontmatter_updates = _parse_key_value_options(set_values)
result = transform_markdown(
file.read_text(encoding="utf-8"),
strip_frontmatter=strip_frontmatter,
set_frontmatter=frontmatter_updates,
heading_delta=heading_delta,
extract_selector=extract_selector,
source_path=str(file),
)
except (InvalidQueryError, ValueError) as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.command()
@click.argument(
"files",
nargs=-1,
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option("--title", help="Add a top-level title before composed files.")
@click.option(
"--heading-delta",
type=int,
default=0,
show_default=True,
help="Shift heading levels in each input before composing.",
)
@click.option(
"--include-frontmatter",
is_flag=True,
help="Keep each input file's frontmatter in the composed body.",
)
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write composed Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def compose(
files: tuple[Path, ...],
title: str | None,
heading_delta: int,
include_frontmatter: bool,
output: Path | None,
output_format: str,
) -> None:
"""Compose multiple Markdown files into one document."""
result = compose_files(
list(files),
title=title,
heading_delta=heading_delta,
include_frontmatter=include_frontmatter,
)
_emit_markdown_result(result.to_dict(), output_format, output)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
"--base-dir",
type=click.Path(exists=True, file_okay=False, path_type=Path),
help="Directory includes must stay within. Defaults to the input file directory.",
)
@click.option(
"--max-depth",
type=int,
default=10,
show_default=True,
help="Maximum recursive include depth.",
)
@click.option(
"--output",
type=click.Path(dir_okay=False, path_type=Path),
help="Write resolved Markdown to a file.",
)
@click.option(
"--format",
"output_format",
type=click.Choice(["markdown", "json", "yaml"], case_sensitive=False),
default="markdown",
show_default=True,
)
def include(
file: Path,
base_dir: Path | None,
max_depth: int,
output: Path | None,
output_format: str,
) -> None:
"""Resolve Markdown include markers in a document."""
try:
result = resolve_includes(
file.read_text(encoding="utf-8"),
base_dir=base_dir or file.parent,
current_path=file,
max_depth=max_depth,
)
except IncludeError as exc:
raise click.ClickException(str(exc)) from exc
_emit_markdown_result(result.to_dict(), output_format, output)
@main.command()
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
@click.option(
@@ -292,5 +447,42 @@ def _emit_extract(data: dict, output_format: str) -> None:
click.echo("\n\n".join(data["items"]))
def _emit_markdown_result(data: dict, output_format: str, output: Path | None) -> None:
if output_format == "json":
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
return
if output_format == "yaml":
click.echo(yaml.safe_dump(data, sort_keys=False))
return
markdown = data["markdown"]
if output:
output.write_text(markdown, encoding="utf-8")
else:
click.echo(markdown, nl=False)
def _parse_key_value_options(items: tuple[str, ...]) -> dict[str, object]:
values: dict[str, object] = {}
for item in items:
if "=" not in item:
raise ValueError(f"Expected KEY=VALUE, got `{item}`")
key, raw_value = item.split("=", 1)
key = key.strip()
if not key:
raise ValueError(f"Expected non-empty key in `{item}`")
_set_path(values, key.split("."), yaml.safe_load(raw_value))
return values
def _set_path(mapping: dict[str, object], path: list[str], value: object) -> None:
current = mapping
for part in path[:-1]:
next_value = current.setdefault(part, {})
if not isinstance(next_value, dict):
raise ValueError(f"Cannot set nested frontmatter path through scalar `{part}`")
current = next_value
current[path[-1]] = value
if __name__ == "__main__":
main()