Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .markdownlint.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"MD033": false,
"MD060": false,
"MD013": false,
"MD041": false
"MD041": false,
"MD024": false
}
30 changes: 27 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,30 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## [Unreleased]
## [0.4.0] — 2026-07-06

## [0.3.0] — 2025-07-06


### Added

- `qms-kit export --format md|html --config <path> [--output-dir <path>]`:
renders all QMS templates and writes files to a local directory
- `md`: plain Markdown files, one per document
- `html`: standalone HTML pages with inline CSS, `index.html` with
links to all documents, "← Back" navigation on every page
- `markdown` library dependency for Markdown → HTML conversion
- `_render_all()` and `_render_extra()` helpers extracted in `cli/main.py`
— eliminates render-context duplication across deploy / validate / export
- 14 unit tests for the export command and `_slugify` helper

### Changed

- deploy and validate commands refactored to use shared `_render_all()` helper

## [0.3.0] — 2026-07-06

### Added

- `qms-kit validate` CLI command — validates config and env vars for a target,
renders all templates, exits 0/1 (useful in CI pipelines)
- Atlassian Quickstart section in README
Expand All @@ -19,6 +38,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] — 2025-06-29

### Added

- **Phase 2: Atlassian Cloud adapter** (`--target atlassian`)
- `ConfluenceAdapter`: deploys QMS document tree via Confluence REST API v2,
Markdown → Storage Format (XHTML) converter (`md_to_confluence_storage`)
Expand All @@ -30,6 +50,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- 30 unit tests for Confluence converter and Jira adapter

### Changed

- `--target atlassian` now fully wired in CLI (was "not yet implemented")
- `_deploy_atlassian()` in `cli/main.py` calls both adapters in sequence
- Added record-template placeholder vars to CLI render context
Expand All @@ -38,18 +59,21 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.1] — 2025-06-22

### Fixed

- Removed `[dependency-groups]` section from `pyproject.toml`; dev deps
consolidated into `[project.optional-dependencies] dev` so
`uv sync --frozen --extra dev` installs them correctly in CI
- XWiki deploy now deploys pages flat in QMS space — eliminates duplicate
"QMS → QMS" nav entry caused by intermediate parent page

### Changed

- GitHub Actions PR size check now excludes `*.lock`, `*.md`, `tests/`, `test_*.py`

## [0.1.0] — 2025-06-15

### Added

- **Phase 1: Self-hosted adapter** (`--target selfhosted`)
- `XWikiAdapter`: deploys QMS space + document pages via XWiki REST API,
Markdown → XWiki 2.1 syntax converter (`md_to_xwiki`)
Expand All @@ -66,7 +90,7 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- GitHub Actions CI: ruff, bandit, mypy, pytest, PR size check (400-line limit)
- `uv` package manager, installable as CLI via `pyproject.toml`

[Unreleased]: https://github.com/gerfru/qms-kit/compare/v0.3.0...HEAD
[0.4.0]: https://github.com/gerfru/qms-kit/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/gerfru/qms-kit/compare/v0.2.0...v0.3.0
[0.2.0]: https://github.com/gerfru/qms-kit/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/gerfru/qms-kit/compare/v0.1.0...v0.1.1
Expand Down
254 changes: 174 additions & 80 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,75 @@
from __future__ import annotations

import os
import re
from pathlib import Path
from typing import Any

import click

from src.core.config import load_config
from src.core.config import CoreConfig, load_config
from src.core.renderer import render_template

TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
CONFIG_DIR = Path(__file__).parent.parent / "config"

_HTML_CSS = """
body{font-family:system-ui,sans-serif;max-width:860px;margin:2rem auto;padding:0 1.5rem;
line-height:1.6;color:#1a1a1a}
h1{border-bottom:2px solid #0057b8;padding-bottom:.4rem;color:#0057b8}
h2{color:#333;margin-top:2rem}h3{color:#555}
table{border-collapse:collapse;width:100%}
th,td{border:1px solid #ccc;padding:.5rem .75rem;text-align:left}
th{background:#f0f4fa}tr:nth-child(even){background:#fafafa}
code{background:#f3f3f3;padding:.1rem .3rem;border-radius:3px;font-size:.9em}
pre{background:#f3f3f3;padding:1rem;border-radius:4px;overflow-x:auto}
blockquote{border-left:4px solid #0057b8;margin:0;padding:.5rem 1rem;color:#555}
a{color:#0057b8}.back{margin-bottom:1.5rem;font-size:.9em}
""".strip()


def _slugify(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")


def _render_extra(config: CoreConfig, doc_id: str, clause: str, title: str) -> dict[str, Any]:
return {
"procedure_title": title,
"doc_id": f"QMS-{clause}-{doc_id.upper()[:8]}",
"clause": clause,
"owner_role": "Quality Management Officer",
"approver": config.organisation.management,
"author": config.organisation.quality_officer,
"date": "TBD",
"audit_date": "TBD",
"auditor": "TBD",
"audit_scope": "TBD",
"contact": "TBD",
"capa_id": "CAPA-XXXX-NNN",
"opened_date": "TBD",
"opened_by": "TBD",
"source_ref": "TBD",
"participants": "TBD",
}


def _render_all(config: CoreConfig) -> dict[str, tuple[str, str]]:
"""Render index + all templated documents. Returns {doc_id: (title, md_content)}."""
rendered: dict[str, tuple[str, str]] = {}
index_content = render_template("qms_index.md.j2", config=config, templates_dir=TEMPLATES_DIR)
rendered["qms_index"] = ("QM Manual", index_content)
for doc in config.documents:
if doc.template is None:
continue
content = render_template(
f"{doc.template}.md.j2",
config=config,
templates_dir=TEMPLATES_DIR,
extra=_render_extra(config, doc.id, doc.clause, doc.title),
)
rendered[doc.id] = (doc.title, content)
return rendered


@click.group()
def cli() -> None:
Expand Down Expand Up @@ -50,48 +109,9 @@ def deploy(target: str, config_path: Path, dry_run: bool) -> None:
f"({config.meta.standard}, {len(config.documents)} documents)"
)

# Render all templates that have a template key set
rendered: dict[str, tuple[str, str]] = {}

# Space index page
index_content = render_template(
template_name="qms_index.md.j2",
config=config,
templates_dir=TEMPLATES_DIR,
)
rendered["qms_index"] = ("QM Manual", index_content)
click.echo(" Rendered: qms_index (qms_index.md.j2)")

for doc in config.documents:
if doc.template is None:
continue
template_file = f"{doc.template}.md.j2"
content = render_template(
template_name=template_file,
config=config,
templates_dir=TEMPLATES_DIR,
extra={
"procedure_title": doc.title,
"doc_id": f"QMS-{doc.clause}-{doc.id.upper()[:8]}",
"clause": doc.clause,
"owner_role": "Quality Management Officer",
"approver": config.organisation.management,
"author": config.organisation.quality_officer,
"date": "TBD",
# Record-template placeholders — filled in per instance
"audit_date": "TBD",
"auditor": "TBD",
"audit_scope": "TBD",
"contact": "TBD",
"capa_id": "CAPA-XXXX-NNN",
"opened_date": "TBD",
"opened_by": "TBD",
"source_ref": "TBD",
"participants": "TBD",
},
)
rendered[doc.id] = (doc.title, content)
click.echo(f" Rendered: {doc.id} ({template_file})")
rendered = _render_all(config)
for doc_id, (title, _) in rendered.items():
click.echo(f" Rendered: {doc_id} ({title})")

if dry_run:
click.echo(f"\nDry run complete — {len(rendered)} template(s) rendered, nothing deployed.")
Expand Down Expand Up @@ -160,46 +180,13 @@ def validate(target: str, config_path: Path) -> None:
# Template rendering
click.echo("Rendering templates...")
render_errors = 0
templated = sum(1 for d in config.documents if d.template) + 1 # +1 for index
try:
render_template("qms_index.md.j2", config=config, templates_dir=TEMPLATES_DIR)
_render_all(config)
except Exception as exc:
errors.append(f"qms_index.md.j2: {exc}")
errors.append(f"template rendering failed: {exc}")
render_errors += 1

for doc in config.documents:
if doc.template is None:
continue
try:
render_template(
f"{doc.template}.md.j2",
config=config,
templates_dir=TEMPLATES_DIR,
extra={
"procedure_title": doc.title,
"doc_id": f"QMS-{doc.clause}-{doc.id.upper()[:8]}",
"clause": doc.clause,
"owner_role": "Quality Management Officer",
"approver": config.organisation.management,
"author": config.organisation.quality_officer,
"date": "TBD",
"audit_date": "TBD",
"auditor": "TBD",
"audit_scope": "TBD",
"contact": "TBD",
"capa_id": "CAPA-XXXX-NNN",
"opened_date": "TBD",
"opened_by": "TBD",
"source_ref": "TBD",
"participants": "TBD",
},
)
except Exception as exc:
errors.append(f"{doc.template}.md.j2: {exc}")
render_errors += 1

templated = sum(1 for d in config.documents if d.template) + 1 # +1 for index
rendered_ok = templated - render_errors
click.echo(f" {rendered_ok}/{templated} templates rendered successfully")
click.echo(f" {templated - render_errors}/{templated} templates rendered successfully")

if errors:
click.echo("\nValidation FAILED:")
Expand All @@ -210,6 +197,113 @@ def validate(target: str, config_path: Path) -> None:
click.echo(f"\nValidation OK — ready to deploy --target {target}")


@cli.command()
@click.option(
"--format",
"fmt",
required=True,
type=click.Choice(["md", "html"]),
help="Output format: md (Markdown files) or html (standalone HTML pages).",
)
@click.option(
"--config",
"config_path",
required=True,
type=click.Path(exists=True, path_type=Path),
help="Path to the client overlay config.",
)
@click.option(
"--output-dir",
"output_dir",
default="qms-export",
show_default=True,
type=click.Path(path_type=Path),
help="Directory to write exported files into.",
)
def export(fmt: str, config_path: Path, output_dir: Path) -> None:
"""Export QMS documents as Markdown or standalone HTML files.

Useful for uploading to SharePoint document libraries, email distribution,
or local archiving. The output directory is created if it does not exist.
"""
import markdown as md_lib

core_path = CONFIG_DIR / "core.yaml"
click.echo(f"Loading config: core={core_path}, overlay={config_path}")
try:
config = load_config(core_path, config_path)
except Exception as exc:
raise click.ClickException(f"Config invalid: {exc}") from exc
click.echo(
f"Config loaded: {config.organisation.name} "
f"({config.meta.standard}, {len(config.documents)} documents)"
)

click.echo("Rendering templates...")
try:
rendered = _render_all(config)
except Exception as exc:
raise click.ClickException(f"Template rendering failed: {exc}") from exc

output_dir.mkdir(parents=True, exist_ok=True)

if fmt == "md":
_export_md(rendered, output_dir)
else:
_export_html(rendered, output_dir, config.organisation.name, md_lib)

click.echo(f"\nExport complete — {len(rendered)} file(s) written to {output_dir}/")


def _export_md(rendered: dict[str, tuple[str, str]], output_dir: Path) -> None:
for _doc_id, (title, content) in rendered.items():
slug = _slugify(title)
out_path = output_dir / f"{slug}.md"
out_path.write_text(content, encoding="utf-8")
click.echo(f" Written: {out_path.name}")


def _export_html(
rendered: dict[str, tuple[str, str]],
output_dir: Path,
org_name: str,
md_lib: object,
) -> None:
# Build index entries while writing per-document files
index_entries: list[tuple[str, str]] = [] # (filename, title)

for _doc_id, (title, md_content) in rendered.items():
slug = _slugify(title)
filename = f"{slug}.html"
body_html = md_lib.markdown(md_content, extensions=["tables", "fenced_code"]) # type: ignore[attr-defined]
back = '<p class="back"><a href="index.html">← Back to QM Manual</a></p>'
page = (
f"<!doctype html><html lang='en'><head>"
f"<meta charset='utf-8'>"
f"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>{title} — {org_name}</title>"
f"<style>{_HTML_CSS}</style></head><body>"
f"{back}{body_html}</body></html>"
)
(output_dir / filename).write_text(page, encoding="utf-8")
click.echo(f" Written: {filename}")
index_entries.append((filename, title))

# Write index.html
items = "\n".join(f'<li><a href="{fn}">{title}</a></li>' for fn, title in index_entries)
index_page = (
f"<!doctype html><html lang='en'><head>"
f"<meta charset='utf-8'>"
f"<meta name='viewport' content='width=device-width,initial-scale=1'>"
f"<title>QM Manual — {org_name}</title>"
f"<style>{_HTML_CSS}</style></head><body>"
f"<h1>QM Manual — {org_name}</h1>"
f"<ul>{items}</ul></body></html>"
)
(output_dir / "index.html").write_text(index_page, encoding="utf-8")
click.echo(" Written: index.html")


def _deploy_atlassian(config: object, rendered: dict[str, tuple[str, str]]) -> None:
from adapters.atlassian.confluence import ConfluenceAdapter
from adapters.atlassian.jira import JiraAdapter
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"pyyaml>=6.0",
"requests>=2.31",
"click>=8.1",
"markdown>=3.10.2",
]

[project.optional-dependencies]
Expand All @@ -31,6 +32,7 @@ dev = [
"hatchling>=1.25",
"types-pyyaml>=6.0",
"types-requests>=2.31",
"types-markdown>=3.10.2.20260518",
]

[project.scripts]
Expand Down
Loading
Loading