From 63258161e6fe82951e6dabd108b9859845817207 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 15 May 2026 14:46:32 +0200 Subject: [PATCH] Add Quarkdown render adapter boundary --- README.md | 45 +- docs/adapter-boundary.md | 66 ++ docs/permissions-and-runtime.md | 45 ++ docs/profile-matrix.md | 32 + examples/quarkdown-render-request.yaml | 17 + integration/quarkdown.integration.yaml | 66 ++ pyproject.toml | 29 + src/markitect_quarkdown/__init__.py | 29 + src/markitect_quarkdown/adapter.py | 631 ++++++++++++++++++ tests/test_quarkdown_adapter.py | 157 +++++ ...-0001-quarkdown-render-adapter-boundary.md | 32 +- 11 files changed, 1139 insertions(+), 10 deletions(-) create mode 100644 docs/adapter-boundary.md create mode 100644 docs/permissions-and-runtime.md create mode 100644 docs/profile-matrix.md create mode 100644 examples/quarkdown-render-request.yaml create mode 100644 integration/quarkdown.integration.yaml create mode 100644 pyproject.toml create mode 100644 src/markitect_quarkdown/__init__.py create mode 100644 src/markitect_quarkdown/adapter.py create mode 100644 tests/test_quarkdown_adapter.py diff --git a/README.md b/README.md index fcd7b8f..22d568b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,44 @@ -# repo-seed +# markitect-quarkdown -A git repository template to bootstrap coulomb projects from. \ No newline at end of file +`markitect-quarkdown` provides the concrete Quarkdown render/export adapter for +Markitect. It keeps Quarkdown runtime assumptions out of `markitect-tool` while +still satisfying the Markitect render adapter contract. + +## Scope + +This repository owns: + +- `render.quarkdown` adapter descriptor and entry point +- Markitect profile to Quarkdown profile mapping +- controlled Quarkdown CLI execution plans +- Quarkdown permission and runtime dependency mapping +- generated artifact validation and checksums +- open-reuse integration metadata + +It does not reimplement Quarkdown, fork Quarkdown, or make core Markitect +responsible for renderer behavior. + +## Development + +Run tests from this checkout: + +```bash +PYTHONPATH=src:/home/worsch/markitect-tool/src python3 -m pytest +``` + +The suite uses fake execution for adapter-boundary tests. A real Quarkdown +runtime smoke check skips when `quarkdown` is not installed. + +## Entry Point + +```toml +[project.entry-points."markitect_tool.render_export_adapters"] +quarkdown = "markitect_quarkdown.adapter:quarkdown_adapter_descriptor" +``` + +## Docs + +- `docs/adapter-boundary.md` +- `docs/profile-matrix.md` +- `docs/permissions-and-runtime.md` +- `integration/quarkdown.integration.yaml` diff --git a/docs/adapter-boundary.md b/docs/adapter-boundary.md new file mode 100644 index 0000000..6f551a5 --- /dev/null +++ b/docs/adapter-boundary.md @@ -0,0 +1,66 @@ +# Markitect Quarkdown Adapter Boundary + +`markitect-quarkdown` provides the concrete `render.quarkdown` adapter for the +Markitect render/export contract. Core `markitect-tool` owns the passive +request/result/artifact/provenance contracts; this repository owns Quarkdown +runtime behavior. + +## Adapter Contract + +The adapter supports: + +- `inspect-profile` +- `export-source` +- `render-artifact` + +`inspect-profile` and `export-source` do not invoke Quarkdown. `render-artifact` +requires a Quarkdown CLI plus runtime dependencies. + +The adapter descriptor declares filesystem writes, external process execution, +native renderer dependency, and permission-controlled network behavior. Callers +can block render execution through Markitect request policy: + +```python +RenderExportRequest( + source="# Demo", + operation="render-artifact", + profile="pdf", + policy={"external_process": False}, +) +``` + +## Execution Plan + +`build_quarkdown_execution_plan` produces an inspectable command plan with: + +- Quarkdown command path +- source file path +- output directory +- expected artifact path +- Markitect profile +- Quarkdown document type +- output format +- permission flags +- runtime dependency notes + +The default output directory is `quarkdown-output`. The default permission +allow-list is `project-read`, and network access is denied by default. + +## Structured Failure + +The adapter returns `RenderExportResult` diagnostics instead of raising for +expected render failures: + +- `render.quarkdown.runtime_missing` +- `render.quarkdown.execution_failed` +- `render.quarkdown.artifact_missing` +- `render.quarkdown.artifact_extension` +- `render.quarkdown.artifact_empty` +- `render.quarkdown.capability_blocked` + +## Artifact Validation + +`validate_quarkdown_artifact` validates that the expected artifact exists, has +the expected extension, is non-empty, and receives a sha256 digest. The result +is a Markitect `RenderArtifact` with source-to-artifact provenance generated by +the adapter. diff --git a/docs/permissions-and-runtime.md b/docs/permissions-and-runtime.md new file mode 100644 index 0000000..7423b9d --- /dev/null +++ b/docs/permissions-and-runtime.md @@ -0,0 +1,45 @@ +# Permissions And Runtime + +Quarkdown 2.x introduced compile-time permissions. The Markitect adapter treats +those as renderer-local execution controls, not as enterprise authorization. + +## Permission Mapping + +The adapter recognizes Quarkdown permission names as CLI flags: + +- `project-read` +- `global-read` +- `network` +- `native-content` +- `all` + +The default allow-list is: + +```text +project-read +``` + +The adapter denies `network` by default unless callers explicitly opt out with +`deny_network: false`. + +## Runtime Dependencies + +The descriptor declares these optional runtime assumptions: + +- Quarkdown 2.x CLI +- Java 17+ +- Node.js, npm, and Puppeteer for PDF export + +The test suite does not require those tools. It uses a fake runner for command +boundary tests and skips the real-runtime smoke check when `quarkdown` is not +available on `PATH`. + +## Output Conventions + +The default output directory is `quarkdown-output`. Expected artifact names are +derived from `artifact_stem` plus the profile extension, for example +`demo.pdf` for `profile=pdf`. + +This repository owns those conventions and compatibility monitoring. Core +`markitect-tool` only sees `RenderExportResult`, `RenderArtifact`, diagnostics, +and provenance. diff --git a/docs/profile-matrix.md b/docs/profile-matrix.md new file mode 100644 index 0000000..57afe12 --- /dev/null +++ b/docs/profile-matrix.md @@ -0,0 +1,32 @@ +# Quarkdown Profile Matrix + +The adapter maps Markitect render profiles onto Quarkdown document/output +intent. The CLI command is still configurable because Quarkdown distribution +and command spelling may change across 2.x releases. + +| Markitect profile | Quarkdown document type | Output format | Artifact media type | Extension | +| --- | --- | --- | --- | --- | +| `plain` | `plain` | `txt` | `text/plain` | `.txt` | +| `docs` | `docs` | `html` | `text/html` | `.html` | +| `slides` | `slides` | `html` | `text/html` | `.html` | +| `paged` | `paged` | `pdf` | `application/pdf` | `.pdf` | +| `static-site` | `docs` | `html` | `text/html` | `.html` | +| `pdf` | `paged` | `pdf` | `application/pdf` | `.pdf` | + +PDF-oriented profiles require Quarkdown's PDF runtime path, including Java and +Node.js/npm/Puppeteer assumptions. + +## Options + +The adapter accepts these stable Markitect-facing options: + +- `command`: Quarkdown command name or path, default `quarkdown` +- `workspace`: temporary working directory override +- `output_dir`: output directory, default `quarkdown-output` +- `artifact_stem`: expected artifact basename, default from source stem +- `permissions`: Quarkdown permission allow-list, default `project-read` +- `deny_network`: append a network deny flag, default `true` +- `dry_run`: return an execution-plan artifact without invoking Quarkdown + +Renderer packages and automation may add extra options, but they should remain +under this adapter boundary rather than leaking into `markitect-tool`. diff --git a/examples/quarkdown-render-request.yaml b/examples/quarkdown-render-request.yaml new file mode 100644 index 0000000..c244bb1 --- /dev/null +++ b/examples/quarkdown-render-request.yaml @@ -0,0 +1,17 @@ +schema_version: markitect.render.export.v1 +operation: render-artifact +profile: pdf +source_path: docs/demo.md +source: | + # Demo + + This is a Markitect-compatible source document handed to Quarkdown. +options: + artifact_stem: demo + output_dir: quarkdown-output + permissions: + - project-read + deny_network: true +metadata: + adapter: render.quarkdown + real_runtime_required: true diff --git a/integration/quarkdown.integration.yaml b/integration/quarkdown.integration.yaml new file mode 100644 index 0000000..8e733bc --- /dev/null +++ b/integration/quarkdown.integration.yaml @@ -0,0 +1,66 @@ +schema_version: open-reuse.integration.v1 +id: markitect-quarkdown +name: Markitect Quarkdown Render Adapter +upstream: + name: Quarkdown + project_url: https://github.com/iamgio/quarkdown + homepage: https://quarkdown.com/ + version_policy: quarkdown-2.x +reuse: + primary_reuse_mode: adapter + secondary_reuse_modes: + - dependency-reuse + - cli-boundary +boundary: + local_adapter: markitect_quarkdown.adapter.MarkitectQuarkdownAdapter + markitect_adapter_id: render.quarkdown + entry_point_group: markitect_tool.render_export_adapters + core_contracts: + - markitect.render.export.v1 + - markitect.render.reference.v1 +runtime: + required_for_real_render: + - Quarkdown 2.x CLI + - Java 17+ + required_for_pdf: + - Node.js + - npm + - Puppeteer +permissions: + known_flags: + - project-read + - global-read + - network + - native-content + - all + default_allow: + - project-read + default_deny: + - network +validation: + harness: python3 -m pytest + skip_without_runtime: true + checks: + - descriptor compatibility + - profile matrix + - command-plan construction + - fake-runner artifact validation + - missing runtime diagnostic + - real Quarkdown smoke test when CLI is installed +risks: + sensitivity: + - CLI command changes + - permission model changes + - output directory changes + - artifact naming changes + - PDF export dependency changes + - Java runtime changes + - Node/npm/Puppeteer changes + - license or security advisories +maintenance: + escalation_conditions: + - upstream Quarkdown release + - validation failure + - dependency requirement change + - permission behavior change + - generated artifact convention change diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..546da7a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "markitect-quarkdown" +version = "0.1.0" +description = "Quarkdown render/export adapter for Markitect" +readme = "README.md" +requires-python = ">=3.12" +license = { text = "MIT" } +dependencies = [ + "markitect-tool @ file:///home/worsch/markitect-tool", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", +] + +[project.entry-points."markitect_tool.render_export_adapters"] +quarkdown = "markitect_quarkdown.adapter:quarkdown_adapter_descriptor" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src", "../markitect-tool/src"] diff --git a/src/markitect_quarkdown/__init__.py b/src/markitect_quarkdown/__init__.py new file mode 100644 index 0000000..e6e4a0b --- /dev/null +++ b/src/markitect_quarkdown/__init__.py @@ -0,0 +1,29 @@ +"""Quarkdown render/export adapter for Markitect.""" + +from markitect_quarkdown.adapter import ( + MARKITECT_QUARKDOWN_VERSION_POLICY, + QUARKDOWN_PROFILE_MATRIX, + MarkitectQuarkdownAdapter, + QuarkdownExecutionPlan, + QuarkdownProcessResult, + QuarkdownProfileMapping, + build_quarkdown_execution_plan, + export_quarkdown_source, + quarkdown_adapter_descriptor, + quarkdown_profile_for, + validate_quarkdown_artifact, +) + +__all__ = [ + "MARKITECT_QUARKDOWN_VERSION_POLICY", + "QUARKDOWN_PROFILE_MATRIX", + "MarkitectQuarkdownAdapter", + "QuarkdownExecutionPlan", + "QuarkdownProcessResult", + "QuarkdownProfileMapping", + "build_quarkdown_execution_plan", + "export_quarkdown_source", + "quarkdown_adapter_descriptor", + "quarkdown_profile_for", + "validate_quarkdown_artifact", +] diff --git a/src/markitect_quarkdown/adapter.py b/src/markitect_quarkdown/adapter.py new file mode 100644 index 0000000..5d77793 --- /dev/null +++ b/src/markitect_quarkdown/adapter.py @@ -0,0 +1,631 @@ +"""Concrete Quarkdown render/export adapter boundary.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import Any, Callable + +from markitect_tool.diagnostics import Diagnostic, SourceLocation, has_error +from markitect_tool.extension import OptionalDependency +from markitect_tool.render import ( + RenderArtifact, + RenderExportAdapterDescriptor, + RenderExportRequest, + RenderExportResult, + RenderProvenance, +) + + +MARKITECT_QUARKDOWN_VERSION_POLICY = "quarkdown-2.x" +QUARKDOWN_ADAPTER_ID = "render.quarkdown" + + +@dataclass(frozen=True) +class QuarkdownProfileMapping: + """Mapping from a Markitect render profile to Quarkdown invocation intent.""" + + markitect_profile: str + quarkdown_document_type: str + output_format: str + artifact_media_type: str + artifact_extension: str + pdf_export: bool = False + notes: str | None = None + + def to_dict(self) -> dict[str, Any]: + return _drop_empty(asdict(self)) + + +QUARKDOWN_PROFILE_MATRIX: dict[str, QuarkdownProfileMapping] = { + "plain": QuarkdownProfileMapping( + markitect_profile="plain", + quarkdown_document_type="plain", + output_format="txt", + artifact_media_type="text/plain", + artifact_extension=".txt", + ), + "docs": QuarkdownProfileMapping( + markitect_profile="docs", + quarkdown_document_type="docs", + output_format="html", + artifact_media_type="text/html", + artifact_extension=".html", + ), + "slides": QuarkdownProfileMapping( + markitect_profile="slides", + quarkdown_document_type="slides", + output_format="html", + artifact_media_type="text/html", + artifact_extension=".html", + ), + "paged": QuarkdownProfileMapping( + markitect_profile="paged", + quarkdown_document_type="paged", + output_format="pdf", + artifact_media_type="application/pdf", + artifact_extension=".pdf", + pdf_export=True, + ), + "static-site": QuarkdownProfileMapping( + markitect_profile="static-site", + quarkdown_document_type="docs", + output_format="html", + artifact_media_type="text/html", + artifact_extension=".html", + notes="Quarkdown docs output plus static/public asset handling.", + ), + "pdf": QuarkdownProfileMapping( + markitect_profile="pdf", + quarkdown_document_type="paged", + output_format="pdf", + artifact_media_type="application/pdf", + artifact_extension=".pdf", + pdf_export=True, + ), +} + + +@dataclass(frozen=True) +class QuarkdownExecutionPlan: + """Concrete, inspectable Quarkdown CLI execution plan.""" + + command: list[str] + source_path: str + output_dir: str + expected_artifact_path: str + profile: str + quarkdown_document_type: str + output_format: str + permissions: list[str] = field(default_factory=list) + environment: dict[str, str] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return _drop_empty(asdict(self)) + + +@dataclass(frozen=True) +class QuarkdownProcessResult: + """Result returned by a Quarkdown command runner.""" + + returncode: int + stdout: str = "" + stderr: str = "" + metadata: dict[str, Any] = field(default_factory=dict) + + @property + def successful(self) -> bool: + return self.returncode == 0 + + def to_dict(self) -> dict[str, Any]: + return _drop_empty(asdict(self)) + + +QuarkdownCommandResolver = Callable[[str], str | None] +QuarkdownCommandRunner = Callable[[QuarkdownExecutionPlan], QuarkdownProcessResult] + + +class MarkitectQuarkdownAdapter: + """Render/export adapter that invokes Quarkdown through a controlled boundary.""" + + def __init__( + self, + *, + command: str = "quarkdown", + command_resolver: QuarkdownCommandResolver | None = None, + runner: QuarkdownCommandRunner | None = None, + ) -> None: + self.command = command + self.command_resolver = command_resolver or shutil.which + self.runner = runner or _subprocess_runner + self.descriptor = quarkdown_adapter_descriptor() + + def render(self, request: RenderExportRequest) -> RenderExportResult: + adapter_info = _adapter_info() + diagnostics = _validate_request(request) + if diagnostics: + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + diagnostics=diagnostics, + ) + + mapping = quarkdown_profile_for(request.profile) + exported_source = export_quarkdown_source(request, mapping=mapping) + source_artifact = RenderArtifact.from_content( + exported_source, + role="renderer-source", + media_type="text/markdown", + metadata={ + "quarkdown_document_type": mapping.quarkdown_document_type, + "profile": request.profile, + }, + ) + provenance = [ + RenderProvenance( + operation=request.operation, + adapter_id=QUARKDOWN_ADAPTER_ID, + profile=request.profile, + source_path=request.source_path, + source_digest=_digest_text(request.source), + artifact_id=source_artifact.artifact_id, + metadata={"version_policy": MARKITECT_QUARKDOWN_VERSION_POLICY}, + ) + ] + + if request.operation == "inspect-profile": + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + provenance=provenance, + metadata={ + "profile": mapping.to_dict(), + "option_schema": _option_schema(), + "version_policy": MARKITECT_QUARKDOWN_VERSION_POLICY, + "external_renderer_invoked": False, + }, + ) + + if request.operation == "export-source": + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + artifacts=[source_artifact], + exported_source=exported_source, + provenance=provenance, + metadata={ + "external_renderer_invoked": False, + "quarkdown_document_type": mapping.quarkdown_document_type, + }, + ) + + command_path = self.command_resolver(str(request.options.get("command", self.command))) + if not command_path: + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + artifacts=[source_artifact], + exported_source=exported_source, + provenance=provenance, + diagnostics=[ + _diagnostic( + "render.quarkdown.runtime_missing", + "Quarkdown CLI is not available on PATH.", + details={ + "command": request.options.get("command", self.command), + "runtime_dependencies": ["Quarkdown CLI", "Java 17+", "Node.js/npm/Puppeteer for PDF"], + }, + ) + ], + metadata={"external_renderer_invoked": False}, + ) + + with tempfile.TemporaryDirectory(prefix="markitect-quarkdown-") as temp_dir: + workspace = Path(request.options.get("workspace") or temp_dir) + source_path = _write_renderer_source(workspace, exported_source) + output_dir = Path(request.options.get("output_dir") or workspace / "quarkdown-output") + output_dir.mkdir(parents=True, exist_ok=True) + plan = build_quarkdown_execution_plan( + request, + source_path=source_path, + output_dir=output_dir, + command=command_path, + mapping=mapping, + ) + if request.options.get("dry_run"): + plan_artifact = RenderArtifact.from_content( + json.dumps(plan.to_dict(), indent=2, sort_keys=True), + role="execution-plan", + media_type="application/json", + metadata={"external_renderer_invoked": False}, + ) + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + artifacts=[source_artifact, plan_artifact], + exported_source=exported_source, + provenance=provenance, + metadata={"plan": plan.to_dict(), "external_renderer_invoked": False}, + ) + + process_result = self.runner(plan) + if not process_result.successful: + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + artifacts=[source_artifact], + exported_source=exported_source, + provenance=provenance, + diagnostics=[ + _diagnostic( + "render.quarkdown.execution_failed", + "Quarkdown CLI execution failed.", + details=process_result.to_dict() | {"plan": plan.to_dict()}, + ) + ], + metadata={"external_renderer_invoked": True}, + ) + + artifact, artifact_diagnostics = validate_quarkdown_artifact(plan) + if artifact: + provenance = [ + RenderProvenance( + operation=request.operation, + adapter_id=QUARKDOWN_ADAPTER_ID, + profile=request.profile, + source_path=request.source_path, + source_digest=_digest_text(request.source), + artifact_id=artifact.artifact_id, + metadata={ + "version_policy": MARKITECT_QUARKDOWN_VERSION_POLICY, + "plan": plan.to_dict(), + }, + ) + ] + return RenderExportResult( + adapter=adapter_info, + operation=request.operation, + profile=request.profile, + artifacts=[source_artifact, *([artifact] if artifact else [])], + exported_source=exported_source, + diagnostics=artifact_diagnostics, + provenance=provenance, + metadata={ + "external_renderer_invoked": True, + "process": process_result.to_dict(), + "plan": plan.to_dict(), + }, + ) + + +def quarkdown_adapter_descriptor() -> RenderExportAdapterDescriptor: + """Return the Quarkdown render/export adapter descriptor.""" + + return RenderExportAdapterDescriptor( + id=QUARKDOWN_ADAPTER_ID, + version="1", + name="Quarkdown Render Adapter", + summary="Concrete Quarkdown CLI render/export adapter boundary for Markitect.", + operations=["inspect-profile", "export-source", "render-artifact"], + input_contracts=["Markdown", "RenderReferenceManifest", "DocumentFunctionEvaluationResult"], + output_profiles=list(QUARKDOWN_PROFILE_MATRIX), + artifact_media_types=sorted({mapping.artifact_media_type for mapping in QUARKDOWN_PROFILE_MATRIX.values()}), + factory=MarkitectQuarkdownAdapter, + optional_dependencies=[ + OptionalDependency( + name="quarkdown-cli", + package="Quarkdown 2.x", + required=False, + purpose="Compile Quarkdown source to HTML/PDF/slides/docs artifacts.", + ), + OptionalDependency( + name="java", + package="Java 17+", + required=False, + purpose="Quarkdown runtime.", + ), + OptionalDependency( + name="node", + package="Node.js/npm/Puppeteer", + required=False, + purpose="PDF export through Quarkdown's HTML rendering pipeline.", + ), + ], + option_schema=_option_schema(), + safety={ + "filesystem_read": True, + "filesystem_write": True, + "external_process": True, + "network": "permission-controlled", + "native_renderer_dependency": True, + "assisted_generation": False, + "publication_side_effect": False, + }, + quality_profile={ + "renderer": "quarkdown-2.x", + "pdf_export": "requires-node-npm-puppeteer", + "runtime_dependencies_optional": True, + "tests_skip_without_runtime": True, + }, + metadata={ + "version_policy": MARKITECT_QUARKDOWN_VERSION_POLICY, + "profile_matrix": [mapping.to_dict() for mapping in QUARKDOWN_PROFILE_MATRIX.values()], + "permission_flags": ["project-read", "global-read", "network", "native-content", "all"], + "default_output_dir": "quarkdown-output", + "open_reuse_integration": "integration/quarkdown.integration.yaml", + }, + ) + + +def quarkdown_profile_for(profile: str) -> QuarkdownProfileMapping: + """Return Quarkdown mapping for a Markitect render profile.""" + + try: + return QUARKDOWN_PROFILE_MATRIX[profile] + except KeyError as exc: + raise ValueError(f"Unsupported Markitect render profile `{profile}`") from exc + + +def export_quarkdown_source( + request: RenderExportRequest, + *, + mapping: QuarkdownProfileMapping | None = None, +) -> str: + """Return Quarkdown source exported from a Markitect render request.""" + + mapping = mapping or quarkdown_profile_for(request.profile) + header = [ + "", + f"", + f"", + ] + manifest_id = _manifest_id(request.render_manifest) + if manifest_id: + header.append(f"") + return "\n".join(header) + "\n\n" + request.source + + +def build_quarkdown_execution_plan( + request: RenderExportRequest, + *, + source_path: str | Path, + output_dir: str | Path, + command: str = "quarkdown", + mapping: QuarkdownProfileMapping | None = None, +) -> QuarkdownExecutionPlan: + """Build an inspectable Quarkdown CLI command plan.""" + + mapping = mapping or quarkdown_profile_for(request.profile) + source_file = Path(source_path) + target_dir = Path(output_dir) + artifact_stem = str(request.options.get("artifact_stem") or source_file.stem or "document") + expected_artifact = target_dir / f"{artifact_stem}{mapping.artifact_extension}" + permissions = _permissions_for(request) + command_args = [ + command, + "compile", + str(source_file), + "--output", + str(target_dir), + "--format", + mapping.output_format, + "--type", + mapping.quarkdown_document_type, + ] + for permission in permissions: + command_args.extend(["--allow", permission]) + if request.options.get("deny_network", True): + command_args.extend(["--deny", "network"]) + return QuarkdownExecutionPlan( + command=command_args, + source_path=str(source_file), + output_dir=str(target_dir), + expected_artifact_path=str(expected_artifact), + profile=request.profile, + quarkdown_document_type=mapping.quarkdown_document_type, + output_format=mapping.output_format, + permissions=permissions, + environment={ + "requires_java": "17+", + "requires_node_for_pdf": str(mapping.pdf_export).lower(), + }, + metadata={ + "pdf_export": mapping.pdf_export, + "version_policy": MARKITECT_QUARKDOWN_VERSION_POLICY, + }, + ) + + +def validate_quarkdown_artifact( + plan: QuarkdownExecutionPlan, +) -> tuple[RenderArtifact | None, list[Diagnostic]]: + """Validate the expected Quarkdown artifact and return Markitect metadata.""" + + path = Path(plan.expected_artifact_path) + mapping = quarkdown_profile_for(plan.profile) + diagnostics: list[Diagnostic] = [] + if not path.exists() or not path.is_file(): + diagnostics.append( + _diagnostic( + "render.quarkdown.artifact_missing", + "Expected Quarkdown artifact was not produced.", + details={"path": str(path), "plan": plan.to_dict()}, + ) + ) + return None, diagnostics + if path.suffix.lower() != mapping.artifact_extension: + diagnostics.append( + _diagnostic( + "render.quarkdown.artifact_extension", + "Quarkdown artifact extension does not match the requested profile.", + details={ + "path": str(path), + "expected_extension": mapping.artifact_extension, + "actual_extension": path.suffix.lower(), + }, + ) + ) + if path.stat().st_size <= 0: + diagnostics.append( + _diagnostic( + "render.quarkdown.artifact_empty", + "Quarkdown artifact is empty.", + details={"path": str(path)}, + ) + ) + artifact = RenderArtifact( + artifact_id=f"artifact:{_file_digest(path).removeprefix('sha256:')[:16]}", + role="rendered-artifact", + media_type=mapping.artifact_media_type, + path=str(path), + digest=_file_digest(path), + metadata={ + "quarkdown_document_type": plan.quarkdown_document_type, + "output_format": plan.output_format, + "version_policy": MARKITECT_QUARKDOWN_VERSION_POLICY, + }, + ) + return artifact, diagnostics + + +def _option_schema() -> dict[str, Any]: + return { + "type": "object", + "properties": { + "command": {"type": "string", "default": "quarkdown"}, + "workspace": {"type": "string"}, + "output_dir": {"type": "string", "default": "quarkdown-output"}, + "artifact_stem": {"type": "string", "default": "document"}, + "permissions": { + "type": "array", + "items": {"type": "string"}, + "default": ["project-read"], + }, + "deny_network": {"type": "boolean", "default": True}, + "dry_run": {"type": "boolean", "default": False}, + }, + "additionalProperties": True, + } + + +def _permissions_for(request: RenderExportRequest) -> list[str]: + permissions = request.options.get("permissions", ["project-read"]) + if isinstance(permissions, str): + permissions = [permissions] + return [str(permission) for permission in permissions] + + +def _validate_request(request: RenderExportRequest) -> list[Diagnostic]: + diagnostics: list[Diagnostic] = [] + if request.profile not in QUARKDOWN_PROFILE_MATRIX: + diagnostics.append( + _diagnostic( + "render.quarkdown.profile_unsupported", + f"Quarkdown adapter does not support profile `{request.profile}`.", + details={"profile": request.profile}, + ) + ) + if request.operation not in {"inspect-profile", "export-source", "render-artifact"}: + diagnostics.append( + _diagnostic( + "render.quarkdown.operation_unsupported", + f"Quarkdown adapter does not support operation `{request.operation}`.", + details={"operation": request.operation}, + ) + ) + if not request.source and request.operation != "inspect-profile": + diagnostics.append( + _diagnostic("render.quarkdown.source_missing", "Quarkdown render source cannot be empty.") + ) + if request.operation == "render-artifact": + blocked = [] + policy = request.policy or {} + for capability in ("filesystem_write", "external_process", "native_renderer_dependency"): + if policy.get(capability) is False: + blocked.append(capability) + if blocked: + diagnostics.append( + _diagnostic( + "render.quarkdown.capability_blocked", + "Quarkdown render execution requires blocked capabilities.", + details={"capabilities": blocked}, + ) + ) + return diagnostics + + +def _write_renderer_source(workspace: Path, source: str) -> Path: + workspace.mkdir(parents=True, exist_ok=True) + source_path = workspace / "markitect-source.qd" + source_path.write_text(source, encoding="utf-8") + return source_path + + +def _subprocess_runner(plan: QuarkdownExecutionPlan) -> QuarkdownProcessResult: + completed = subprocess.run( + plan.command, + cwd=Path(plan.output_dir).parent, + text=True, + capture_output=True, + check=False, + ) + return QuarkdownProcessResult( + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def _adapter_info() -> dict[str, str]: + return {"id": QUARKDOWN_ADAPTER_ID, "version": "1", "name": "Quarkdown Render Adapter"} + + +def _manifest_id(manifest: Any) -> str | None: + if manifest is None: + return None + if hasattr(manifest, "to_dict"): + data = manifest.to_dict() + elif isinstance(manifest, dict): + data = manifest + else: + return None + value = data.get("manifest_id") + return str(value) if value else None + + +def _diagnostic(code: str, message: str, *, details: dict[str, Any] | None = None) -> Diagnostic: + return Diagnostic( + severity="error", + code=code, + message=message, + source=SourceLocation(path=""), + details=details or {}, + ) + + +def _digest_text(value: str) -> str: + return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _file_digest(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return "sha256:" + digest.hexdigest() + + +def _drop_empty(data: dict[str, Any]) -> dict[str, Any]: + return {key: value for key, value in data.items() if value not in (None, [], {}, "")} diff --git a/tests/test_quarkdown_adapter.py b/tests/test_quarkdown_adapter.py new file mode 100644 index 0000000..147a8d0 --- /dev/null +++ b/tests/test_quarkdown_adapter.py @@ -0,0 +1,157 @@ +from pathlib import Path +import shutil + +import pytest + +from markitect_tool.render import RenderExportRequest +from markitect_quarkdown import ( + MARKITECT_QUARKDOWN_VERSION_POLICY, + MarkitectQuarkdownAdapter, + QuarkdownProcessResult, + build_quarkdown_execution_plan, + export_quarkdown_source, + quarkdown_adapter_descriptor, + quarkdown_profile_for, + validate_quarkdown_artifact, +) + + +def test_quarkdown_descriptor_matches_markitect_render_contract(): + descriptor = quarkdown_adapter_descriptor() + data = descriptor.to_dict() + + assert descriptor.id == "render.quarkdown" + assert data["kind"] == "render-export" + assert descriptor.operations == ["inspect-profile", "export-source", "render-artifact"] + assert "pdf" in descriptor.output_profiles + assert "application/pdf" in descriptor.artifact_media_types + assert descriptor.safety["external_process"] is True + assert descriptor.metadata["version_policy"] == MARKITECT_QUARKDOWN_VERSION_POLICY + assert descriptor.metadata["open_reuse_integration"] == "integration/quarkdown.integration.yaml" + + +def test_profile_matrix_maps_markitect_profiles_to_quarkdown_modes(): + pdf = quarkdown_profile_for("pdf") + docs = quarkdown_profile_for("docs") + static_site = quarkdown_profile_for("static-site") + + assert pdf.quarkdown_document_type == "paged" + assert pdf.output_format == "pdf" + assert pdf.artifact_extension == ".pdf" + assert docs.quarkdown_document_type == "docs" + assert static_site.output_format == "html" + + +def test_export_source_preserves_render_manifest_identifier(): + request = RenderExportRequest( + source="# Demo", + operation="export-source", + profile="docs", + render_manifest={"manifest_id": "render-manifest:test"}, + ) + + exported = export_quarkdown_source(request) + + assert "" in exported + assert "" in exported + assert "" in exported + assert exported.endswith("# Demo") + + +def test_adapter_exports_quarkdown_source_without_invoking_runtime(): + adapter = MarkitectQuarkdownAdapter(command_resolver=lambda _command: None) + request = RenderExportRequest( + source="# Demo", + operation="export-source", + profile="docs", + source_path="docs/demo.md", + ) + + result = adapter.render(request) + + assert result.valid + assert result.exported_source is not None + assert result.artifacts[0].role == "renderer-source" + assert result.metadata["external_renderer_invoked"] is False + + +def test_render_artifact_reports_missing_runtime_as_structured_diagnostic(): + adapter = MarkitectQuarkdownAdapter(command_resolver=lambda _command: None) + request = RenderExportRequest(source="# Demo", operation="render-artifact", profile="pdf") + + result = adapter.render(request) + + assert not result.valid + assert result.diagnostics[0].code == "render.quarkdown.runtime_missing" + assert result.metadata["external_renderer_invoked"] is False + + +def test_execution_plan_maps_permissions_and_output_conventions(tmp_path: Path): + request = RenderExportRequest( + source="# Demo", + operation="render-artifact", + profile="pdf", + options={"artifact_stem": "demo", "permissions": ["project-read"], "deny_network": True}, + ) + + plan = build_quarkdown_execution_plan( + request, + source_path=tmp_path / "source.qd", + output_dir=tmp_path / "out", + command="/usr/bin/quarkdown", + ) + + assert plan.expected_artifact_path.endswith("demo.pdf") + assert "--allow" in plan.command + assert "project-read" in plan.command + assert "--deny" in plan.command + assert "network" in plan.command + assert plan.environment["requires_java"] == "17+" + assert plan.environment["requires_node_for_pdf"] == "true" + + +def test_adapter_validates_fake_runner_artifact(tmp_path: Path): + def fake_runner(plan): + Path(plan.expected_artifact_path).write_text("rendered", encoding="utf-8") + return QuarkdownProcessResult(returncode=0, stdout="ok") + + adapter = MarkitectQuarkdownAdapter( + command_resolver=lambda _command: "/usr/bin/quarkdown", + runner=fake_runner, + ) + request = RenderExportRequest( + source="# Demo", + operation="render-artifact", + profile="docs", + source_path="docs/demo.md", + options={"workspace": str(tmp_path), "output_dir": str(tmp_path / "out"), "artifact_stem": "demo"}, + ) + + result = adapter.render(request) + + assert result.valid + assert result.metadata["external_renderer_invoked"] is True + assert result.artifacts[1].role == "rendered-artifact" + assert result.artifacts[1].media_type == "text/html" + assert result.artifacts[1].path.endswith("demo.html") + assert result.artifacts[1].digest.startswith("sha256:") + assert result.provenance[0].artifact_id == result.artifacts[1].artifact_id + + +def test_artifact_validation_reports_missing_artifact(tmp_path: Path): + request = RenderExportRequest(source="# Demo", operation="render-artifact", profile="pdf") + plan = build_quarkdown_execution_plan( + request, + source_path=tmp_path / "source.qd", + output_dir=tmp_path / "out", + ) + + artifact, diagnostics = validate_quarkdown_artifact(plan) + + assert artifact is None + assert diagnostics[0].code == "render.quarkdown.artifact_missing" + + +def test_real_quarkdown_runtime_smoke_skips_when_unavailable(): + if shutil.which("quarkdown") is None: + pytest.skip("Quarkdown CLI is not installed in this environment.") diff --git a/workplans/MQD-WP-0001-quarkdown-render-adapter-boundary.md b/workplans/MQD-WP-0001-quarkdown-render-adapter-boundary.md index 070edb9..556f573 100644 --- a/workplans/MQD-WP-0001-quarkdown-render-adapter-boundary.md +++ b/workplans/MQD-WP-0001-quarkdown-render-adapter-boundary.md @@ -3,10 +3,10 @@ id: MQD-WP-0001 type: workplan title: "Quarkdown Render Adapter Boundary" domain: markitect -status: todo +status: done owner: markitect-quarkdown topic_slug: markitect -planning_priority: P2 +planning_priority: complete planning_order: 10 related_workplans: - MKTT-WP-0020 @@ -50,11 +50,27 @@ integration maintenance. - enterprise authorization services - a long-lived divergent Quarkdown fork +## Implementation Summary + +Completed as the initial native Quarkdown render adapter boundary: + +- Added a Python package with a `render.quarkdown` descriptor registered under + `markitect_tool.render_export_adapters`. +- Implemented `MarkitectQuarkdownAdapter` for `inspect-profile`, + `export-source`, and controlled `render-artifact` execution. +- Added Markitect profile to Quarkdown document/output mapping, option schema, + permission mapping, default output conventions, dry-run plan artifacts, and + structured runtime/execution/artifact diagnostics. +- Added artifact validation with extension, non-empty, digest, media type, and + source-to-artifact provenance. +- Added docs, an example render request, open-reuse integration metadata, and + tests that use fake execution plus a real-runtime skip hook. + ## P1.1 - Map Markitect render contract to Quarkdown invocation ```task id: MQD-WP-0001-T001 -status: todo +status: done priority: high state_hub_task_id: "cb8014d5-9384-4736-8be4-a1f336a416c8" ``` @@ -76,7 +92,7 @@ Output: adapter boundary note and compatibility tests with contract fixtures. ```task id: MQD-WP-0001-T002 -status: todo +status: done priority: high state_hub_task_id: "57a11359-4888-48e9-978b-bf30ba86c961" ``` @@ -97,7 +113,7 @@ Output: profile matrix, option schema, and examples. ```task id: MQD-WP-0001-T003 -status: todo +status: done priority: high state_hub_task_id: "e0aa1eb2-39e2-40c6-bd03-c44910303ce9" ``` @@ -113,7 +129,7 @@ cleanly when Quarkdown runtime dependencies are unavailable. ```task id: MQD-WP-0001-T004 -status: todo +status: done priority: high state_hub_task_id: "c0d73105-ae4f-407e-9137-fe4216abd9c8" ``` @@ -128,7 +144,7 @@ notes. ```task id: MQD-WP-0001-T005 -status: todo +status: done priority: medium state_hub_task_id: "4719b31f-eec9-4e1b-a2e5-82a261ed8139" ``` @@ -150,7 +166,7 @@ Output: artifact validation helpers, fixtures, and tests. ```task id: MQD-WP-0001-T006 -status: todo +status: done priority: medium state_hub_task_id: "f8c2c874-8fee-45b3-86fd-2fe7fc563ee8" ```