7.8 KB
raw
{# Property report rendered by minijinja into Typst markup, then compiled to
PDF by src/pdf.rs::PdfRenderer. Mirrors analytics/templates/properties/property_report.typ
in look-and-feel: monochrome, hairline rules, tracking-letterspaced uppercase
section headers, mono for technical strings. #}
#let dim = rgb("#555")
#let muted = rgb("#888")
#let mono = ("JetBrains Mono", "DejaVu Sans Mono", "Liberation Mono")
#set page(
paper: "a4",
margin: (top: 14mm, bottom: 18mm, left: 14mm, right: 14mm),
footer: context {
set text(size: 7.5pt, fill: dim)
grid(
columns: (1fr, auto),
align: (left + horizon, right + horizon),
[Status · self-hosted{% if base_url %} · {{ base_url | typst_md }}{% endif %} · {{ property.name | typst_md }}],
[Page #counter(page).display() of #counter(page).final().first()],
)
},
)
#set text(
font: ("DejaVu Sans", "Liberation Sans", "Arial"),
size: 9.5pt,
fill: black,
)
#set par(leading: 0.5em, justify: false)
#show heading.where(level: 1): set text(size: 22pt, weight: "bold")
#show heading.where(level: 1): set block(above: 8pt, below: 4pt)
#show heading.where(level: 2): it => block(
above: 16pt,
below: 6pt,
width: 100%,
stroke: (bottom: 0.6pt + black),
inset: (bottom: 3pt),
)[#text(size: 11pt, weight: "bold", tracking: 0.6pt, upper(it.body))]
#show heading.where(level: 3): it => block(
above: 8pt,
below: 3pt,
)[#text(size: 8.5pt, weight: "bold", tracking: 0.5pt, fill: rgb("#333"), upper(it.body))]
// Header strip
#grid(
columns: (1fr, auto),
align: (left + top, right + top),
text(size: 8.5pt, tracking: 0.9pt, fill: dim, upper("// Status · property report")),
text(size: 8pt, fill: dim)[Generated {{ generated_at | typst_md }}],
)
= {{ property.name | typst_md }}
// Meta dl: 2-col with thin rules above and below.
#block(
above: 8pt,
below: 0pt,
width: 100%,
stroke: (top: 0.6pt + black, bottom: 0.6pt + black),
inset: (top: 6pt, bottom: 6pt),
)[
#grid(
columns: (1fr, 1fr),
column-gutter: 16pt,
row-gutter: 4pt,
[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Property ID")) \
#text(font: mono, size: 8.5pt)[{{ property.id | typst_md }}]
],
[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("URL")) \
#text(font: mono, size: 8.5pt)[{{ property.url | typst_md }}]
],
[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Alert state")) \
#text(weight: "semibold")[{{ property.alert_state | typst_md }}]
],
[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim, upper("Visibility")) \
#text(weight: "semibold")[{% if property.is_public %}public{% else %}private{% endif %}]
],
)
]
== Live signals
#grid(
columns: (1fr, 1fr, 1fr, 1fr),
gutter: 4pt,
rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim)[STATUS]
#v(2pt)
#text(size: 14pt, weight: "bold")[{{ property.current_status }}]
],
rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim)[AVG RESPONSE]
#v(2pt)
#text(size: 14pt, weight: "bold")[{{ property.avg_response_time }} ms]
],
rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim)[UPTIME]
#v(2pt)
#text(size: 14pt, weight: "bold")[{% if property.recent_uptime_pct is none %}—{% else %}{{ property.recent_uptime_pct }}%{% endif %}]
],
rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim)[CHECKS]
#v(2pt)
#text(size: 14pt, weight: "bold")[{{ property.total_checks }}]
],
)
== Lighthouse
{% if property.lighthouse_scores %}
#grid(
columns: (1fr, 1fr, 1fr, 1fr),
gutter: 4pt,
{% for k, v in property.lighthouse_scores | items %}
rect(width: 100%, stroke: 0.6pt + black, inset: 6pt)[
#text(size: 7.5pt, tracking: 0.4pt, fill: dim)[{{ k | upper | typst_md }}]
#v(2pt)
#text(size: 14pt, weight: "bold")[{{ v }}]
],
{% endfor %}
)
{% if property.lighthouse_details and property.lighthouse_details.metrics %}
=== Performance metrics
#block(breakable: false, table(
columns: (auto, 1fr, auto),
align: (left + top, left + top, right + top),
inset: (x: 3pt, y: 2pt),
stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
table.header(
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Metric")),
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Title")),
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Value")),
),
{% for m in property.lighthouse_details.metrics %}
text(font: mono, size: 7pt, weight: "bold")[{{ m.acronym | typst_md }}], text(size: 7.5pt)[{{ m.title | typst_md }}], text(size: 7.5pt)[{{ m.display_value | typst_md }}],
{% endfor %}
))
{% endif %}
{% if property.lighthouse_details and property.lighthouse_details.opportunities %}
=== Top opportunities
#block(breakable: false, table(
columns: (1fr, auto),
align: (left + top, right + top),
inset: (x: 3pt, y: 2pt),
stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
table.header(
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Opportunity")),
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Savings")),
),
{% for o in property.lighthouse_details.opportunities %}
text(size: 7.5pt)[{{ o.title | typst_md }}], text(size: 7.5pt)[{{ o.savings_ms | format_ms_savings | typst_md }}],
{% endfor %}
))
{% endif %}
{% else %}
#text(size: 8pt, fill: muted, style: "italic")[No lighthouse data yet.]
{% endif %}
== Security
#block(breakable: false, table(
columns: (1fr, auto),
align: (left + top, right + top),
inset: (x: 3pt, y: 2pt),
stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
table.header(
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Check")),
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Result")),
),
text(size: 7.5pt)[HTTPS], text(size: 7.5pt)[{% if property.is_https %}OK{% else %}Issue{% endif %}],
text(size: 7.5pt)[TLS certificate valid], text(size: 7.5pt)[{% if property.invalid_cert %}Issue{% else %}OK{% endif %}],
text(size: 7.5pt)[HSTS (≥1y max-age)], text(size: 7.5pt)[{% if property.has_hsts %}OK{% else %}Issue{% endif %}],
text(size: 7.5pt)[HSTS preload], text(size: 7.5pt)[{% if property.has_hsts_preload %}OK{% else %}Issue{% endif %}],
text(size: 7.5pt)[X-Frame-Options], text(size: 7.5pt)[{% if property.has_clickjack_protection %}OK{% else %}Issue{% endif %}],
text(size: 7.5pt)[X-Content-Type-Options], text(size: 7.5pt)[{% if property.has_content_sniffing_protection %}OK{% else %}Issue{% endif %}],
text(size: 7.5pt)[Server header hidden], text(size: 7.5pt)[{% if property.hides_server_version %}OK{% else %}Issue{% endif %}],
))
== SEO insights
{% if insights_groups and insights_groups | length > 0 %}
{% for group in insights_groups %}
=== {{ group.type | typst_md }}
#block(breakable: false, table(
columns: (auto, 1fr, 1fr),
align: (left + top, left + top, left + top),
inset: (x: 3pt, y: 2pt),
stroke: (x, y) => if y == 0 { (bottom: 0.8pt + black) } else { (bottom: 0.3pt + rgb("#ddd")) },
table.header(
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Severity")),
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("Issue")),
text(size: 6.5pt, tracking: 0.3pt, fill: dim, weight: "bold", upper("URL")),
),
{% for i in group.items %}
text(size: 7.5pt, weight: "bold")[{{ i.severity | typst_md }}], text(size: 7.5pt)[{{ i.issue | typst_md }}], text(font: mono, size: 7pt, fill: dim)[{{ i.url | typst_md }}],
{% endfor %}
))
{% endfor %}
{% else %}
#text(size: 8pt, fill: muted, style: "italic")[No crawl results yet.]
{% endif %}