2026-05-03 21:37:00 +02:00
|
|
|
"""`mkt` command entry point."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import click
|
|
|
|
|
import yaml
|
|
|
|
|
|
|
|
|
|
from markitect_tool.core import parse_markdown_file
|
2026-05-03 22:51:13 +02:00
|
|
|
from markitect_tool.contract import (
|
|
|
|
|
ContractLoaderError,
|
|
|
|
|
check_markdown_file,
|
|
|
|
|
collect_metrics,
|
|
|
|
|
load_contract_file,
|
|
|
|
|
validate_contract,
|
|
|
|
|
)
|
2026-05-04 00:23:04 +02:00
|
|
|
from markitect_tool.ops import IncludeError, compose_files, resolve_includes, transform_markdown
|
2026-05-04 00:12:07 +02:00
|
|
|
from markitect_tool.query import InvalidQueryError, extract_document, query_document
|
2026-05-03 22:12:46 +02:00
|
|
|
from markitect_tool.schema import load_schema_file, validate_markdown_file, validate_schema
|
2026-05-03 21:37:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
|
@click.version_option()
|
|
|
|
|
def main() -> None:
|
|
|
|
|
"""Markdown-native toolkit for structured knowledge artifacts."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "tree"], case_sensitive=False),
|
|
|
|
|
default="json",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def parse(file: Path, output_format: str) -> None:
|
|
|
|
|
"""Parse a Markdown file into a structured representation."""
|
|
|
|
|
|
|
|
|
|
document = parse_markdown_file(file)
|
|
|
|
|
data = document.to_dict()
|
|
|
|
|
if output_format == "yaml":
|
|
|
|
|
click.echo(yaml.safe_dump(data, sort_keys=False))
|
|
|
|
|
elif output_format == "tree":
|
|
|
|
|
for heading in document.headings:
|
|
|
|
|
click.echo(f"{'#' * heading.level} {heading.text}")
|
|
|
|
|
else:
|
|
|
|
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 22:51:13 +02:00
|
|
|
@main.command()
|
|
|
|
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
|
|
|
|
default="text",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def metrics(file: Path, output_format: str) -> None:
|
|
|
|
|
"""Report practical size and complexity metrics for a Markdown file."""
|
|
|
|
|
|
|
|
|
|
document = parse_markdown_file(file)
|
|
|
|
|
data = collect_metrics(document).to_dict() | {"document_path": str(file)}
|
|
|
|
|
_emit_metrics(data, output_format)
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 00:12:07 +02:00
|
|
|
@main.command()
|
|
|
|
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.argument("selector")
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
|
|
|
|
default="json",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def query(file: Path, selector: str, output_format: str) -> None:
|
|
|
|
|
"""Query structured Markdown content with a small selector."""
|
|
|
|
|
|
|
|
|
|
document = parse_markdown_file(file)
|
|
|
|
|
try:
|
|
|
|
|
matches = query_document(document, selector)
|
|
|
|
|
except InvalidQueryError as exc:
|
|
|
|
|
raise click.ClickException(str(exc)) from exc
|
|
|
|
|
data = {
|
|
|
|
|
"selector": selector,
|
|
|
|
|
"document_path": str(file),
|
|
|
|
|
"count": len(matches),
|
|
|
|
|
"matches": [match.to_dict() for match in matches],
|
|
|
|
|
}
|
|
|
|
|
_emit_query(data, output_format)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
|
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.argument("selector")
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["text", "json", "yaml"], case_sensitive=False),
|
|
|
|
|
default="text",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def extract(file: Path, selector: str, output_format: str) -> None:
|
|
|
|
|
"""Extract text or Markdown content from structured Markdown."""
|
|
|
|
|
|
|
|
|
|
document = parse_markdown_file(file)
|
|
|
|
|
try:
|
|
|
|
|
items = extract_document(document, selector)
|
|
|
|
|
except InvalidQueryError as exc:
|
|
|
|
|
raise click.ClickException(str(exc)) from exc
|
|
|
|
|
data = {
|
|
|
|
|
"selector": selector,
|
|
|
|
|
"document_path": str(file),
|
|
|
|
|
"count": len(items),
|
|
|
|
|
"items": items,
|
|
|
|
|
}
|
|
|
|
|
_emit_extract(data, output_format)
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 00:23:04 +02:00
|
|
|
@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)
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 22:12:46 +02:00
|
|
|
@main.command()
|
|
|
|
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
"--schema",
|
|
|
|
|
"schema_file",
|
|
|
|
|
required=True,
|
|
|
|
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
|
|
|
|
default="text",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def validate(file: Path, schema_file: Path, output_format: str) -> None:
|
|
|
|
|
"""Validate a Markdown file against a Markdown schema file."""
|
|
|
|
|
|
|
|
|
|
result = validate_markdown_file(file, schema_file)
|
|
|
|
|
_emit_result(result.to_dict(), output_format)
|
|
|
|
|
raise click.exceptions.Exit(0 if result.valid else 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@main.group()
|
|
|
|
|
def schema() -> None:
|
|
|
|
|
"""Work with Markdown schema files."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@schema.command("validate")
|
|
|
|
|
@click.argument("schema_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
|
|
|
|
default="text",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def schema_validate(schema_file: Path, output_format: str) -> None:
|
|
|
|
|
"""Validate that a Markdown schema contains a well-formed JSON Schema."""
|
|
|
|
|
|
|
|
|
|
loaded = load_schema_file(schema_file)
|
|
|
|
|
result = validate_schema(loaded.schema)
|
|
|
|
|
data = result.to_dict() | {"schema_path": str(schema_file)}
|
|
|
|
|
_emit_result(data, output_format)
|
|
|
|
|
raise click.exceptions.Exit(0 if result.valid else 1)
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 22:51:13 +02:00
|
|
|
@main.group()
|
|
|
|
|
def contract() -> None:
|
|
|
|
|
"""Work with Markdown document contracts."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contract.command("validate")
|
|
|
|
|
@click.argument("contract_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
|
|
|
|
default="text",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def contract_validate(contract_file: Path, output_format: str) -> None:
|
|
|
|
|
"""Validate that a Markdown contract file is well formed."""
|
|
|
|
|
|
|
|
|
|
result = validate_contract(load_contract_file(contract_file))
|
|
|
|
|
_emit_diagnostic_result(result.to_dict(), output_format)
|
|
|
|
|
raise click.exceptions.Exit(0 if result.valid else 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@contract.command("check")
|
|
|
|
|
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
|
|
|
@click.option(
|
|
|
|
|
"--contract",
|
|
|
|
|
"contract_file",
|
|
|
|
|
required=True,
|
|
|
|
|
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
|
|
|
)
|
|
|
|
|
@click.option(
|
|
|
|
|
"--format",
|
|
|
|
|
"output_format",
|
|
|
|
|
type=click.Choice(["json", "yaml", "text"], case_sensitive=False),
|
|
|
|
|
default="text",
|
|
|
|
|
show_default=True,
|
|
|
|
|
)
|
|
|
|
|
def contract_check(file: Path, contract_file: Path, output_format: str) -> None:
|
|
|
|
|
"""Check a Markdown file against a Markdown document contract."""
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = check_markdown_file(file, contract_file)
|
|
|
|
|
except ContractLoaderError as exc:
|
|
|
|
|
raise click.ClickException(str(exc)) from exc
|
|
|
|
|
_emit_diagnostic_result(result.to_dict(), output_format)
|
|
|
|
|
raise click.exceptions.Exit(0 if result.valid else 1)
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 22:12:46 +02:00
|
|
|
def _emit_result(data: dict, output_format: str) -> None:
|
|
|
|
|
if output_format == "json":
|
|
|
|
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
elif output_format == "yaml":
|
|
|
|
|
click.echo(yaml.safe_dump(data, sort_keys=False))
|
|
|
|
|
else:
|
|
|
|
|
if data.get("valid"):
|
|
|
|
|
click.echo("valid")
|
|
|
|
|
else:
|
|
|
|
|
click.echo("invalid")
|
|
|
|
|
for violation in data.get("violations", []):
|
|
|
|
|
click.echo(f"- {violation['path']}: {violation['message']}")
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 22:51:13 +02:00
|
|
|
def _emit_diagnostic_result(data: dict, output_format: str) -> None:
|
|
|
|
|
if output_format == "json":
|
|
|
|
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
elif output_format == "yaml":
|
|
|
|
|
click.echo(yaml.safe_dump(data, sort_keys=False))
|
|
|
|
|
else:
|
|
|
|
|
click.echo("valid" if data.get("valid") else "invalid")
|
|
|
|
|
for diagnostic in data.get("diagnostics", []):
|
|
|
|
|
click.echo(
|
|
|
|
|
f"- [{diagnostic['severity']}] {diagnostic['code']}: "
|
|
|
|
|
f"{diagnostic['message']}"
|
|
|
|
|
)
|
|
|
|
|
if diagnostic.get("source"):
|
|
|
|
|
source = diagnostic["source"]
|
|
|
|
|
suffix = f":{source['line']}" if source.get("line") else ""
|
|
|
|
|
click.echo(f" source: {source.get('path', '<document>')}{suffix}")
|
|
|
|
|
if diagnostic.get("guidance"):
|
|
|
|
|
click.echo(f" guidance: {diagnostic['guidance']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _emit_metrics(data: dict, output_format: str) -> None:
|
|
|
|
|
if output_format == "json":
|
|
|
|
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
elif output_format == "yaml":
|
|
|
|
|
click.echo(yaml.safe_dump(data, sort_keys=False))
|
|
|
|
|
else:
|
|
|
|
|
doc = data["document"]
|
|
|
|
|
click.echo("document")
|
|
|
|
|
for metric, value in doc.items():
|
|
|
|
|
click.echo(f"- {metric}: {value}")
|
|
|
|
|
sections = data.get("sections", [])
|
|
|
|
|
if sections:
|
|
|
|
|
click.echo("sections")
|
|
|
|
|
for section in sections:
|
|
|
|
|
click.echo(
|
|
|
|
|
f"- {section['heading']}: words={section['words']}, "
|
|
|
|
|
f"paragraphs={section['paragraphs']}, line={section['line']}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 00:12:07 +02:00
|
|
|
def _emit_query(data: dict, output_format: str) -> None:
|
|
|
|
|
if output_format == "json":
|
|
|
|
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
elif output_format == "yaml":
|
|
|
|
|
click.echo(yaml.safe_dump(data, sort_keys=False))
|
|
|
|
|
else:
|
|
|
|
|
click.echo(f"{data['count']} match(es)")
|
|
|
|
|
for match in data["matches"]:
|
|
|
|
|
location = f":{match['line']}" if match.get("line") else ""
|
|
|
|
|
click.echo(f"- {match['kind']} {match['path']}{location}")
|
|
|
|
|
if match.get("text"):
|
|
|
|
|
click.echo(f" {match['text'].splitlines()[0]}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _emit_extract(data: dict, output_format: str) -> None:
|
|
|
|
|
if output_format == "json":
|
|
|
|
|
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
|
|
|
elif output_format == "yaml":
|
|
|
|
|
click.echo(yaml.safe_dump(data, sort_keys=False))
|
|
|
|
|
else:
|
|
|
|
|
click.echo("\n\n".join(data["items"]))
|
|
|
|
|
|
|
|
|
|
|
2026-05-04 00:23:04 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 21:37:00 +02:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|