forked from bchanot/claude
feat(client-handover): 4-chapter doc structure + branded HTML/PDF rendering
This commit is contained in:
@@ -3,17 +3,22 @@ name: client-handover
|
||||
description: |
|
||||
Final ship-and-handover orchestrator. End-to-end pipeline that hardens the
|
||||
project, commits, pauses for deploy, validates the live site, and only then
|
||||
generates the non-technical client deliverable (LIVRAISON.md / HANDOVER.md).
|
||||
Pipeline: (1) /seo (SEO+GEO) and /harden run in parallel with auto-fix loops
|
||||
until each score ≥17/20, (2) /commit-change + push if changes made, (3) pause
|
||||
to tell user what to deploy and wait for confirmation, (4) /validate against
|
||||
the live site, (5) per-audit gate ≥17/20 — stop and analyze if any below,
|
||||
(6) write client doc with before/after score table and explicit
|
||||
owner-maintenance checklist. Reads git history + .claude/memory/ registries.
|
||||
For local-business projects, appends manual SEO/GEO platform checklist (NAP
|
||||
consistency across Google Business, Pages Jaunes, Yelp, Facebook, Instagram,
|
||||
TikTok, Apple Maps, Bing Places, TripAdvisor, etc.). Asks whether to include
|
||||
build/deploy chapter.
|
||||
generates the non-technical client deliverable as Markdown + branded HTML +
|
||||
PDF (ZenQuality identity: green palette, Inter + Playfair Display fonts,
|
||||
cover page with logo and tagline). The deliverable uses a 4-chapter
|
||||
structure: §1 what was needed and why, §2 what was done (≤300 words, zero
|
||||
jargon, no internal tool/skill names), §3 what the client must do (action
|
||||
checklist), §4 technical details for the curious (scores, key choices,
|
||||
glossary). Pipeline: (1) /seo (SEO+GEO) and /harden run in parallel with
|
||||
auto-fix loops until each score ≥17/20, (2) /commit-change + push if
|
||||
changes made, (3) pause to tell user what to deploy and wait for
|
||||
confirmation, (4) /validate against the live site, (5) per-axis gate
|
||||
≥17/20 — stop and analyze if any below, (6) write client doc + render
|
||||
branded HTML/PDF. Reads git history + .claude/memory/ registries. For
|
||||
local-business projects, appends manual SEO/GEO platform checklist (NAP
|
||||
consistency across Google Business, Pages Jaunes, Yelp, Facebook,
|
||||
Instagram, TikTok, Apple Maps, Bing Places, TripAdvisor, etc.). Asks
|
||||
whether to include build/deploy chapter.
|
||||
Trigger: "client handover", "compte rendu client", "livraison client",
|
||||
"synthese projet", "rapport client", "deliverable", "summary for client",
|
||||
"recap projet", "handover doc", "livrable", "ship and handover",
|
||||
@@ -51,13 +56,14 @@ The agent runs a **ship-and-handover pipeline** with explicit gates:
|
||||
5. **DEPLOY PAUSE** — List exact deploy artifacts: changed files since baseline, deploy hints from project (vercel.json, netlify.toml, Dockerfile, .github/workflows/deploy.yml, etc.), and the deploy process in plain words. Use AskUserQuestion: "Deploy done? (Yes / Not yet / Skip validate)". Block until Yes or Skip.
|
||||
6. **/validate (live site)** — Run validator-analyzer against the deployed URL. Capture `SCORE_VALIDATE`.
|
||||
7. **GATE — per-axis threshold ≥17/20** — Compute final `SCORE_*_AFTER` for SEO classique, GEO (IA), HARDEN, VALIDATE. If ANY < 17/20: STOP. Generate `.claude/audits/HANDOVER-ROADMAP.md` with prioritized analysis of what's blocking each below-threshold axis. Do NOT write the client deliverable. Report to user.
|
||||
8. **DOC GENERATION (only if all scores ≥17/20)** — Read `.claude/memory/` registries + full git history. Ask whether to include build/deploy chapter. Synthesize concise client deliverable with:
|
||||
- Before/after score table with SEO classique and GEO (IA) on separate rows, plus HARDEN and VALIDATE — values + delta. SEO classique, GEO, HARDEN and VALIDATE are gated independently — each must reach ≥17/20 for the pipeline to pass.
|
||||
- Plain-language summary of all changes since first commit.
|
||||
- **Owner responsibilities** section: explicit checklist of what the client must do / maintain (SEO platforms, content updates, monitoring, deploy if self-hosted).
|
||||
- Optional build/deploy chapter.
|
||||
- For web projects with local-business signals: manual SEO/GEO platform checklist with registration links.
|
||||
9. **OUTPUT** — Write to `LIVRAISON.md` (fr) or `HANDOVER.md` (en) at project root.
|
||||
8. **DOC GENERATION (only if all scores ≥17/20)** — Read `.claude/memory/` registries + full git history. Ask whether to include build/deploy chapter. Synthesize the client deliverable using the 4-chapter structure:
|
||||
- **§1 Ce qu'il fallait faire (et pourquoi)** — brief + motivation, 100–180 words.
|
||||
- **§2 Ce qui a été fait** — lay summary, **≤300 words, zero technical jargon**, **no internal tool/skill names** (no `/seo`, `/harden`, `/validate`, `seo-analyzer`, etc. — replace with concept names: référencement / sécurité / conformité technique). Forbidden-token grep gate runs before write.
|
||||
- **§3 Ce qui vous reste à faire** — action-only checklist grouped by cadence (one-time / monthly / quarterly / yearly / when something changes).
|
||||
- **§4 Détails techniques (pour les curieux)** — score table (SEO classique + GEO + sécurité + conformité, before/after, gated independently at ≥17/20), vulgarized BDR decisions, phases with technical detail, optional glossary.
|
||||
- **§5 Annexe — plateformes externes** (web/local-business only).
|
||||
- **§6 Annexe — build & déploiement** (only if requested).
|
||||
9. **RENDER** — Write `LIVRAISON.md` (fr) or `HANDOVER.md` (en) at project root, then run `scripts/handover-to-pdf.sh` to produce the matching branded `.html` (always) and `.pdf` (when a PDF engine is on the host: weasyprint > wkhtmltopdf > chromium). HTML/PDF use the ZenQuality cover page, green palette, Inter + Playfair Display typography, running header/footer with project name + page numbers.
|
||||
|
||||
Flags:
|
||||
- `--skip-fix-loop` — run baseline audits once, skip auto-fix iterations.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{LANG}}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
{{CSS}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<section class="cover">
|
||||
<div class="cover-header">
|
||||
<img class="cover-logo" src="{{LOGO_URL}}" alt="ZenQuality">
|
||||
<div class="cover-tagline">La sérénité numérique,<br>la qualité en plus</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-body">
|
||||
<div class="cover-eyebrow">{{EYEBROW}}</div>
|
||||
<h1 class="cover-title">{{COVER_TITLE}}</h1>
|
||||
<p class="cover-subtitle">{{COVER_SUBTITLE}}</p>
|
||||
|
||||
<div class="cover-meta">
|
||||
<div><strong>{{LABEL_CLIENT}}</strong> {{CLIENT_NAME}}</div>
|
||||
<div><strong>{{LABEL_PROJECT}}</strong> {{PROJECT_NAME}}</div>
|
||||
<div><strong>{{LABEL_DATE}}</strong> {{DATE_HUMAN}}</div>
|
||||
<div><strong>{{LABEL_PERIOD}}</strong> {{PROJECT_PERIOD}}</div>
|
||||
<div><strong>{{LABEL_URL}}</strong> <a href="{{PROJECT_URL}}">{{PROJECT_URL}}</a></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-footer">
|
||||
<span>ZenQuality — {{LABEL_PREPARED_BY}}</span>
|
||||
<a href="https://zenquality.fr">zenquality.fr</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="content" data-title="{{TITLE}}">
|
||||
{{CONTENT}}
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* ZenQuality — client handover stylesheet
|
||||
* Used to render LIVRAISON.md / HANDOVER.md as a branded HTML/PDF.
|
||||
* Source brand tokens: zenquality.fr (CSS custom properties extracted from
|
||||
* the live site) — Inter (body) + Playfair Display (headings), green palette.
|
||||
*/
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Playfair+Display:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--green-dark: #1A3A25;
|
||||
--green-forest: #2D5A3D;
|
||||
--green-moss: #4A7C59;
|
||||
--green-sage: #87A878;
|
||||
--black-deep: #0A0A0A;
|
||||
--black-soft: #1A1A1A;
|
||||
--gray-dark: #2A2A2A;
|
||||
--gray-mid: #666666;
|
||||
--gray-light: #B0B0B0;
|
||||
--white-cream: #F5F0EB;
|
||||
--white-pure: #FFFFFF;
|
||||
|
||||
--status-ok: #2D5A3D;
|
||||
--status-warn: #b58900;
|
||||
--status-fail: #a83232;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 22mm 18mm 22mm 18mm;
|
||||
@top-right {
|
||||
content: string(doctitle);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 8.5pt;
|
||||
color: var(--green-moss);
|
||||
}
|
||||
@bottom-right {
|
||||
content: counter(page) " / " counter(pages);
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 8.5pt;
|
||||
color: var(--gray-mid);
|
||||
}
|
||||
@bottom-left {
|
||||
content: "ZenQuality — zenquality.fr";
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 8.5pt;
|
||||
color: var(--gray-mid);
|
||||
}
|
||||
}
|
||||
|
||||
@page :first {
|
||||
margin: 0;
|
||||
@top-right { content: ""; }
|
||||
@bottom-right { content: ""; }
|
||||
@bottom-left { content: ""; }
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.6;
|
||||
color: var(--black-deep);
|
||||
background: var(--white-pure);
|
||||
}
|
||||
|
||||
/* ============ COVER PAGE ============ */
|
||||
.cover {
|
||||
page-break-after: always;
|
||||
height: 297mm;
|
||||
width: 210mm;
|
||||
padding: 35mm 22mm 22mm 22mm;
|
||||
background:
|
||||
radial-gradient(ellipse at top right, rgba(135, 168, 120, 0.18) 0%, transparent 55%),
|
||||
radial-gradient(ellipse at bottom left, rgba(74, 124, 89, 0.10) 0%, transparent 55%),
|
||||
var(--white-cream);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
page: cover;
|
||||
}
|
||||
|
||||
.cover::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 8mm;
|
||||
background: linear-gradient(90deg, var(--green-dark), var(--green-forest), var(--green-moss));
|
||||
}
|
||||
|
||||
.cover-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cover-logo {
|
||||
width: 55mm;
|
||||
height: auto;
|
||||
max-height: 30mm;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cover-tagline {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 10pt;
|
||||
font-style: italic;
|
||||
color: var(--green-forest);
|
||||
text-align: right;
|
||||
max-width: 70mm;
|
||||
margin-top: 6mm;
|
||||
}
|
||||
|
||||
.cover-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin: -10mm 0 0 0;
|
||||
}
|
||||
|
||||
.cover-eyebrow {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--green-moss);
|
||||
margin-bottom: 6mm;
|
||||
}
|
||||
|
||||
.cover-title {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 34pt;
|
||||
font-weight: 700;
|
||||
color: var(--green-dark);
|
||||
line-height: 1.1;
|
||||
margin: 0 0 6mm 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
|
||||
.cover-subtitle {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 16pt;
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
color: var(--green-forest);
|
||||
margin: 0 0 18mm 0;
|
||||
max-width: 140mm;
|
||||
}
|
||||
|
||||
.cover-meta {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 10.5pt;
|
||||
color: var(--black-soft);
|
||||
line-height: 1.9;
|
||||
border-left: 2px solid var(--green-moss);
|
||||
padding-left: 5mm;
|
||||
}
|
||||
|
||||
.cover-meta strong {
|
||||
color: var(--green-dark);
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
min-width: 25mm;
|
||||
}
|
||||
|
||||
.cover-footer {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 9pt;
|
||||
color: var(--gray-mid);
|
||||
border-top: 1px solid var(--green-sage);
|
||||
padding-top: 5mm;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.cover-footer a {
|
||||
color: var(--green-forest);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cover-footer a:hover { color: var(--green-dark); }
|
||||
|
||||
/* ============ DOCUMENT BODY ============ */
|
||||
.content {
|
||||
string-set: doctitle attr(data-title);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 22pt;
|
||||
font-weight: 700;
|
||||
color: var(--green-dark);
|
||||
margin: 0 0 6mm 0;
|
||||
page-break-after: avoid;
|
||||
string-set: doctitle content();
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 17pt;
|
||||
font-weight: 600;
|
||||
color: var(--green-forest);
|
||||
margin: 12mm 0 4mm 0;
|
||||
padding-bottom: 2.5mm;
|
||||
border-bottom: 2px solid var(--green-sage);
|
||||
page-break-before: always;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
.content > h2:first-of-type,
|
||||
h2.no-break,
|
||||
h2.continue {
|
||||
page-break-before: auto;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: 'Playfair Display', Georgia, serif;
|
||||
font-size: 13.5pt;
|
||||
font-weight: 600;
|
||||
color: var(--green-forest);
|
||||
margin: 8mm 0 3mm 0;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: var(--green-moss);
|
||||
margin: 6mm 0 2mm 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
p { margin: 0 0 3mm 0; }
|
||||
p, li { orphans: 3; widows: 3; }
|
||||
|
||||
ul, ol { margin: 0 0 3mm 0; padding-left: 6mm; }
|
||||
ul li, ol li { margin: 0 0 1.5mm 0; }
|
||||
|
||||
ul li::marker { color: var(--green-moss); }
|
||||
ol li::marker { color: var(--green-moss); font-weight: 600; }
|
||||
|
||||
strong { color: var(--green-dark); font-weight: 600; }
|
||||
|
||||
em { color: var(--green-forest); font-style: italic; }
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--green-moss);
|
||||
padding: 3mm 5mm;
|
||||
margin: 4mm 0;
|
||||
background: var(--white-cream);
|
||||
color: var(--gray-dark);
|
||||
font-style: italic;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
blockquote p:last-child { margin-bottom: 0; }
|
||||
|
||||
a { color: var(--green-forest); text-decoration: underline; text-decoration-thickness: 0.5pt; text-underline-offset: 1.5pt; }
|
||||
a:hover { color: var(--green-dark); }
|
||||
|
||||
code {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', Menlo, monospace;
|
||||
font-size: 9pt;
|
||||
background: var(--white-cream);
|
||||
padding: 0.5mm 1.5mm;
|
||||
border-radius: 1mm;
|
||||
color: var(--green-dark);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--white-cream);
|
||||
padding: 4mm 5mm;
|
||||
border-radius: 1.5mm;
|
||||
border-left: 3px solid var(--green-moss);
|
||||
font-size: 8.5pt;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
page-break-inside: avoid;
|
||||
margin: 4mm 0;
|
||||
}
|
||||
|
||||
pre code { background: none; padding: 0; color: var(--black-deep); font-size: inherit; }
|
||||
|
||||
/* ============ TABLES ============ */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 4mm 0;
|
||||
font-size: 9.5pt;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
th {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--green-forest);
|
||||
color: var(--white-pure);
|
||||
text-align: left;
|
||||
padding: 2.5mm 3mm;
|
||||
font-weight: 600;
|
||||
font-size: 9pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 2.5mm 3mm;
|
||||
border-bottom: 1px solid var(--green-sage);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:nth-child(even) td { background: rgba(245, 240, 235, 0.55); }
|
||||
|
||||
/* Numeric / status cols of score tables auto-detected via header text */
|
||||
table th:nth-child(2),
|
||||
table th:nth-child(3),
|
||||
table th:nth-child(4),
|
||||
table td:nth-child(2),
|
||||
table td:nth-child(3),
|
||||
table td:nth-child(4) {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
table th:last-child,
|
||||
table td:last-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============ CHECKLISTS ============ */
|
||||
ul.checklist,
|
||||
ul.task-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ul.checklist li,
|
||||
ul.task-list li {
|
||||
padding-left: 8mm;
|
||||
position: relative;
|
||||
margin-bottom: 2.5mm;
|
||||
}
|
||||
|
||||
ul.checklist li::before,
|
||||
ul.task-list li::before,
|
||||
li input[type="checkbox"] + *,
|
||||
li.task-list-item::before {
|
||||
content: "☐";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--green-moss);
|
||||
font-size: 12pt;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
input[type="checkbox"]:checked + label::before {
|
||||
content: "☑";
|
||||
color: var(--green-forest);
|
||||
}
|
||||
|
||||
/* ============ CALLOUTS ============ */
|
||||
.callout {
|
||||
padding: 4mm 6mm;
|
||||
margin: 4mm 0;
|
||||
border-radius: 2mm;
|
||||
page-break-inside: avoid;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.callout.info {
|
||||
background: var(--white-cream);
|
||||
border-left: 4px solid var(--green-moss);
|
||||
}
|
||||
|
||||
.callout.warn {
|
||||
background: #fdf6e3;
|
||||
border-left: 4px solid var(--status-warn);
|
||||
}
|
||||
|
||||
.callout.success {
|
||||
background: rgba(135, 168, 120, 0.14);
|
||||
border-left: 4px solid var(--green-forest);
|
||||
}
|
||||
|
||||
.callout-title {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 10pt;
|
||||
color: var(--green-dark);
|
||||
margin-bottom: 2mm;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* ============ SECTION DIVIDERS ============ */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--green-sage);
|
||||
margin: 8mm 0;
|
||||
}
|
||||
|
||||
/* ============ STATUS PILLS (used by text replacement) ============ */
|
||||
.status-ok { color: var(--status-ok); font-weight: 600; }
|
||||
.status-warn { color: var(--status-warn); font-weight: 600; }
|
||||
.status-fail { color: var(--status-fail); font-weight: 600; }
|
||||
|
||||
/* ============ LINK BEHAVIOR IN PRINT ============ */
|
||||
@media print {
|
||||
a[href^="http"]::after {
|
||||
content: " (" attr(href) ")";
|
||||
font-size: 7.5pt;
|
||||
color: var(--gray-mid);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
a[href^="#"]::after,
|
||||
a[href^="mailto:"]::after,
|
||||
a[href^="tel:"]::after,
|
||||
.cover a::after,
|
||||
table a::after { content: ""; }
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# handover-to-pdf.sh
|
||||
# ------------------
|
||||
# Renders a client-handover Markdown report (LIVRAISON.md / HANDOVER.md)
|
||||
# into a branded HTML and (when a converter is available) a PDF using
|
||||
# ZenQuality brand styling.
|
||||
#
|
||||
# Inputs:
|
||||
# $1 Path to the source Markdown file (required)
|
||||
#
|
||||
# Optional environment variables:
|
||||
# PROJECT_NAME Displayed on the cover and as PDF page header.
|
||||
# Defaults to the source filename.
|
||||
# CLIENT_NAME Displayed on the cover. Defaults to "—".
|
||||
# PROJECT_PERIOD Displayed on the cover (e.g. "01/01/2026 → 31/03/2026").
|
||||
# Defaults to "—".
|
||||
# PROJECT_URL Displayed on the cover. Defaults to "—".
|
||||
# LANG "fr" (default) or "en". Drives cover labels.
|
||||
# COVER_TITLE Defaults to PROJECT_NAME.
|
||||
# COVER_SUBTITLE Defaults to "Compte rendu de livraison" (fr) /
|
||||
# "Project handover recap" (en).
|
||||
# EYEBROW Eyebrow line above the title. Defaults to
|
||||
# "Livraison" / "Handover".
|
||||
# LOGO_URL Logo URL or local path. Defaults to a remote
|
||||
# ZenQuality logo (no offline fallback).
|
||||
# BRANDING_DIR Override branding-asset directory. Defaults to the
|
||||
# resources/branding/ folder next to this script.
|
||||
#
|
||||
# Behaviour:
|
||||
# 1. Convert the Markdown body to HTML.
|
||||
# 2. Wrap it in the ZenQuality template (cover + branded body).
|
||||
# 3. Convert that HTML into a PDF using the first available engine:
|
||||
# weasyprint > wkhtmltopdf > chromium > headless Chrome
|
||||
# 4. Always keep the .html file next to the .md.
|
||||
# 5. If no PDF engine is available, exit with code 2 and a clear
|
||||
# message — never fail silently.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 HTML and PDF written successfully.
|
||||
# 1 Fatal error (bad arguments, missing files, conversion error).
|
||||
# 2 HTML written but no PDF engine available — manual print needed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------------------------- CLI ----------------------------------
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
echo "usage: handover-to-pdf.sh <markdown-file>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SRC_MD="$1"
|
||||
|
||||
if [ ! -f "$SRC_MD" ]; then
|
||||
echo "error: markdown file not found: $SRC_MD" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEFAULT_BRANDING_DIR="$SCRIPT_DIR/../resources/branding"
|
||||
BRANDING_DIR="${BRANDING_DIR:-$DEFAULT_BRANDING_DIR}"
|
||||
|
||||
if [ ! -f "$BRANDING_DIR/zenquality.css" ] || [ ! -f "$BRANDING_DIR/zenquality-template.html" ]; then
|
||||
echo "error: branding assets missing under $BRANDING_DIR" >&2
|
||||
echo " expected: zenquality.css + zenquality-template.html" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
OUT_DIR="$(cd "$(dirname "$SRC_MD")" && pwd)"
|
||||
BASE="$(basename "$SRC_MD" .md)"
|
||||
OUT_HTML="$OUT_DIR/$BASE.html"
|
||||
OUT_PDF="$OUT_DIR/$BASE.pdf"
|
||||
|
||||
LANG_CODE="${LANG:-fr}"
|
||||
case "$LANG_CODE" in
|
||||
en|EN|en_*|en-*)
|
||||
LANG_CODE="en"
|
||||
EYEBROW="${EYEBROW:-Project handover}"
|
||||
DEFAULT_SUBTITLE="Project handover recap"
|
||||
LABEL_CLIENT="Client"
|
||||
LABEL_PROJECT="Project"
|
||||
LABEL_DATE="Issued"
|
||||
LABEL_PERIOD="Period"
|
||||
LABEL_URL="Website"
|
||||
LABEL_PREPARED_BY="prepared for the client"
|
||||
;;
|
||||
*)
|
||||
LANG_CODE="fr"
|
||||
EYEBROW="${EYEBROW:-Livraison}"
|
||||
DEFAULT_SUBTITLE="Compte rendu de livraison"
|
||||
LABEL_CLIENT="Client"
|
||||
LABEL_PROJECT="Projet"
|
||||
LABEL_DATE="Date d'émission"
|
||||
LABEL_PERIOD="Période"
|
||||
LABEL_URL="Site"
|
||||
LABEL_PREPARED_BY="préparé pour le client"
|
||||
;;
|
||||
esac
|
||||
|
||||
PROJECT_NAME_RESOLVED="${PROJECT_NAME:-$BASE}"
|
||||
CLIENT_NAME_RESOLVED="${CLIENT_NAME:-—}"
|
||||
PROJECT_PERIOD_RESOLVED="${PROJECT_PERIOD:-—}"
|
||||
PROJECT_URL_RESOLVED="${PROJECT_URL:-—}"
|
||||
COVER_TITLE_RESOLVED="${COVER_TITLE:-$PROJECT_NAME_RESOLVED}"
|
||||
COVER_SUBTITLE_RESOLVED="${COVER_SUBTITLE:-$DEFAULT_SUBTITLE}"
|
||||
LOGO_URL_RESOLVED="${LOGO_URL:-https://zenquality.fr/logo-horizontal.svg}"
|
||||
|
||||
if command -v date >/dev/null 2>&1; then
|
||||
if [ "$LANG_CODE" = "fr" ]; then
|
||||
DATE_HUMAN="$(LC_ALL=fr_FR.UTF-8 date "+%d %B %Y" 2>/dev/null || date "+%Y-%m-%d")"
|
||||
else
|
||||
DATE_HUMAN="$(LC_ALL=en_US.UTF-8 date "+%d %B %Y" 2>/dev/null || date "+%Y-%m-%d")"
|
||||
fi
|
||||
else
|
||||
DATE_HUMAN="$(date "+%Y-%m-%d")"
|
||||
fi
|
||||
|
||||
# ---------------------------- MD -> HTML ---------------------------
|
||||
|
||||
md_to_html_body() {
|
||||
local src="$1"
|
||||
if command -v pandoc >/dev/null 2>&1; then
|
||||
pandoc --from=gfm --to=html5 --no-highlight "$src"
|
||||
return
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1 && python3 -c "import markdown" >/dev/null 2>&1; then
|
||||
python3 -c "
|
||||
import sys, markdown
|
||||
src = open(sys.argv[1], encoding='utf-8').read()
|
||||
print(markdown.markdown(
|
||||
src,
|
||||
extensions=['extra', 'tables', 'sane_lists', 'toc'],
|
||||
))" "$src"
|
||||
return
|
||||
fi
|
||||
if command -v npx >/dev/null 2>&1; then
|
||||
npx --yes marked < "$src"
|
||||
return
|
||||
fi
|
||||
echo "error: no Markdown converter available (need pandoc, python3+markdown, or npx)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
BODY_HTML="$(md_to_html_body "$SRC_MD")"
|
||||
|
||||
# ---------------------------- WRAP HTML ----------------------------
|
||||
|
||||
CSS_CONTENT="$(cat "$BRANDING_DIR/zenquality.css")"
|
||||
|
||||
render_template() {
|
||||
# Read template path from $1, output the substituted HTML on stdout.
|
||||
# Substitution variables are pulled from HQ_* environment variables.
|
||||
HQ_TEMPLATE_PATH="$1" python3 <<'PY'
|
||||
import os, sys
|
||||
path = os.environ["HQ_TEMPLATE_PATH"]
|
||||
with open(path, encoding="utf-8") as f:
|
||||
template = f.read()
|
||||
mapping = {
|
||||
"{{LANG}}": os.environ.get("HQ_LANG", "fr"),
|
||||
"{{TITLE}}": os.environ.get("HQ_TITLE", ""),
|
||||
"{{CSS}}": os.environ.get("HQ_CSS", ""),
|
||||
"{{LOGO_URL}}": os.environ.get("HQ_LOGO_URL", ""),
|
||||
"{{EYEBROW}}": os.environ.get("HQ_EYEBROW", ""),
|
||||
"{{COVER_TITLE}}": os.environ.get("HQ_COVER_TITLE", ""),
|
||||
"{{COVER_SUBTITLE}}": os.environ.get("HQ_COVER_SUBTITLE", ""),
|
||||
"{{CLIENT_NAME}}": os.environ.get("HQ_CLIENT_NAME", "—"),
|
||||
"{{PROJECT_NAME}}": os.environ.get("HQ_PROJECT_NAME", ""),
|
||||
"{{DATE_HUMAN}}": os.environ.get("HQ_DATE_HUMAN", ""),
|
||||
"{{PROJECT_PERIOD}}": os.environ.get("HQ_PROJECT_PERIOD", "—"),
|
||||
"{{PROJECT_URL}}": os.environ.get("HQ_PROJECT_URL", "—"),
|
||||
"{{LABEL_CLIENT}}": os.environ.get("HQ_LABEL_CLIENT", ""),
|
||||
"{{LABEL_PROJECT}}": os.environ.get("HQ_LABEL_PROJECT", ""),
|
||||
"{{LABEL_DATE}}": os.environ.get("HQ_LABEL_DATE", ""),
|
||||
"{{LABEL_PERIOD}}": os.environ.get("HQ_LABEL_PERIOD", ""),
|
||||
"{{LABEL_URL}}": os.environ.get("HQ_LABEL_URL", ""),
|
||||
"{{LABEL_PREPARED_BY}}": os.environ.get("HQ_LABEL_PREPARED_BY", ""),
|
||||
"{{CONTENT}}": os.environ.get("HQ_CONTENT", ""),
|
||||
}
|
||||
for k, v in mapping.items():
|
||||
template = template.replace(k, v)
|
||||
sys.stdout.write(template)
|
||||
PY
|
||||
}
|
||||
|
||||
export HQ_LANG="$LANG_CODE"
|
||||
export HQ_TITLE="$COVER_TITLE_RESOLVED"
|
||||
export HQ_CSS="$CSS_CONTENT"
|
||||
export HQ_LOGO_URL="$LOGO_URL_RESOLVED"
|
||||
export HQ_EYEBROW="$EYEBROW"
|
||||
export HQ_COVER_TITLE="$COVER_TITLE_RESOLVED"
|
||||
export HQ_COVER_SUBTITLE="$COVER_SUBTITLE_RESOLVED"
|
||||
export HQ_CLIENT_NAME="$CLIENT_NAME_RESOLVED"
|
||||
export HQ_PROJECT_NAME="$PROJECT_NAME_RESOLVED"
|
||||
export HQ_DATE_HUMAN="$DATE_HUMAN"
|
||||
export HQ_PROJECT_PERIOD="$PROJECT_PERIOD_RESOLVED"
|
||||
export HQ_PROJECT_URL="$PROJECT_URL_RESOLVED"
|
||||
export HQ_LABEL_CLIENT="$LABEL_CLIENT"
|
||||
export HQ_LABEL_PROJECT="$LABEL_PROJECT"
|
||||
export HQ_LABEL_DATE="$LABEL_DATE"
|
||||
export HQ_LABEL_PERIOD="$LABEL_PERIOD"
|
||||
export HQ_LABEL_URL="$LABEL_URL"
|
||||
export HQ_LABEL_PREPARED_BY="$LABEL_PREPARED_BY"
|
||||
export HQ_CONTENT="$BODY_HTML"
|
||||
|
||||
render_template "$BRANDING_DIR/zenquality-template.html" > "$OUT_HTML"
|
||||
|
||||
echo "wrote: $OUT_HTML"
|
||||
|
||||
# ---------------------------- HTML -> PDF --------------------------
|
||||
|
||||
PDF_ENGINE=""
|
||||
PDF_REASON=""
|
||||
|
||||
if command -v weasyprint >/dev/null 2>&1; then
|
||||
PDF_ENGINE="weasyprint"
|
||||
elif command -v wkhtmltopdf >/dev/null 2>&1; then
|
||||
PDF_ENGINE="wkhtmltopdf"
|
||||
elif command -v chromium >/dev/null 2>&1; then
|
||||
PDF_ENGINE="chromium"
|
||||
elif command -v chromium-browser >/dev/null 2>&1; then
|
||||
PDF_ENGINE="chromium-browser"
|
||||
elif command -v google-chrome >/dev/null 2>&1; then
|
||||
PDF_ENGINE="google-chrome"
|
||||
else
|
||||
PDF_REASON="no PDF engine found (looked for: weasyprint, wkhtmltopdf, chromium, google-chrome)"
|
||||
fi
|
||||
|
||||
if [ -n "$PDF_ENGINE" ]; then
|
||||
case "$PDF_ENGINE" in
|
||||
weasyprint)
|
||||
weasyprint --base-url "$OUT_DIR/" "$OUT_HTML" "$OUT_PDF"
|
||||
;;
|
||||
wkhtmltopdf)
|
||||
wkhtmltopdf --enable-local-file-access \
|
||||
--margin-top 0 --margin-bottom 0 \
|
||||
--margin-left 0 --margin-right 0 \
|
||||
--print-media-type \
|
||||
"$OUT_HTML" "$OUT_PDF"
|
||||
;;
|
||||
chromium|chromium-browser|google-chrome)
|
||||
"$PDF_ENGINE" --headless --disable-gpu --no-sandbox \
|
||||
--no-pdf-header-footer \
|
||||
--print-to-pdf="$OUT_PDF" \
|
||||
--print-to-pdf-no-header \
|
||||
"file://$OUT_HTML"
|
||||
;;
|
||||
esac
|
||||
echo "wrote: $OUT_PDF (engine: $PDF_ENGINE)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat <<EOF >&2
|
||||
|
||||
note: HTML written, but no PDF engine is available.
|
||||
reason: $PDF_REASON
|
||||
|
||||
To generate $OUT_PDF, install one of:
|
||||
- weasyprint pip install --user weasyprint
|
||||
- wkhtmltopdf apt install wkhtmltopdf (or download from wkhtmltopdf.org)
|
||||
- chromium apt install chromium-browser
|
||||
Or open $OUT_HTML in a browser and use "Print → Save as PDF".
|
||||
|
||||
EOF
|
||||
exit 2
|
||||
Reference in New Issue
Block a user