Implementer synops-render CLI-verktøy (oppgave 21.3)

Nytt CLI-verktøy som rendrer artikler og forsider til HTML via
Tera-templates og lagrer resultatet i CAS. Erstatter rendering-logikken
i maskinrommet/src/publishing.rs som standalone verktøy.

Støtter to render-typer:
- article: Rendrer enkeltartikkel med SEO-metadata, presentasjonselementer,
  TipTap→HTML-konvertering, og tema-basert CSS.
- index: Rendrer forside med hero/featured/stream-artikler.

Fire innebygde temaer: avis, magasin, blogg, tidsskrift.
Templates er kopiert fra maskinrommet og innebygd via include_str!().
TipTap-modulen er duplisert inntil synops-common (21.16) samler felles kode.

Følger eksisterende CLI-mønster: --write gater DB-oppdateringer,
JSON til stdout, stderr for logging.

16 enhetstester dekker CSS-variabler, SEO, kategorisering, rendering
og TipTap-konvertering. Verifisert mot produksjons-DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 09:19:02 +00:00
parent 46fe19b78d
commit 17e35b2644
19 changed files with 5645 additions and 2 deletions

View file

@ -243,8 +243,7 @@ kaller dem direkte. Samme verktøy, to brukere.
- [x] 21.1 `synops-transcribe`: Whisper-transkribering. Input: `--cas-hash <hash> --model <model> [--initial-prompt <tekst>]`. Output: JSON med segmenter. Skriver segmenter til PG, oppdaterer node metadata. Erstatter `transcribe.rs`.
- [x] 21.2 `synops-audio`: FFmpeg-prosessering. Input: `--cas-hash <hash> --edl <json>`. Output: ny CAS-hash. Erstatter `audio.rs`. Inkluder parametervalidering (fase 17.217.3).
- [~] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
> Påbegynt: 2026-03-18T09:10
- [x] 21.3 `synops-render`: Tera HTML-rendering. Input: `--node-id <uuid> --theme <tema>`. Output: CAS-hash for rendret HTML. Erstatter `publishing.rs`.
- [ ] 21.4 `synops-rss`: RSS/Atom-generering. Input: `--collection-id <uuid>`. Output: XML til stdout. Erstatter `rss.rs`.
- [ ] 21.5 `synops-tts`: Tekst-til-tale. Input: `--text <tekst> --voice <stemme>`. Output: CAS-hash for lydfil. Erstatter `tts.rs`.
- [ ] 21.6 `synops-summarize`: AI-oppsummering. Input: `--communication-id <uuid>`. Output: sammendrag som tekst. Erstatter `summarize.rs`.

View file

@ -9,6 +9,7 @@ eller maskinrommet-API. Ligger i PATH via symlink eller direkte kall.
|---------|-------------|--------|
| `synops-transcribe` | Whisper-transkribering av lydfil fra CAS | Ferdig |
| `synops-audio` | FFmpeg lydprosessering med EDL (cut, normalize, EQ, m.m.) | Ferdig |
| `synops-render` | Tera HTML-rendering til CAS (artikler, forsider) | Ferdig |
## Konvensjoner
- Navnekonvensjon: `synops-<verb>` (f.eks. `synops-context`)

2722
tools/synops-render/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
[package]
name = "synops-render"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "synops-render"
path = "src/main.rs"
[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "chrono", "json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v7", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tera = "1"
sha2 = "0.10"
hex = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}Om — {{ collection_title }}{% endblock %}
{% block extra_css %}
.about-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.about-page__content {
font-size: 1.05rem;
line-height: 1.8;
}
.about-page__content h1,
.about-page__content h2,
.about-page__content h3 {
font-family: var(--font-heading);
color: var(--color-primary);
margin-top: 2rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.about-page__content h1 { font-size: 2rem; }
.about-page__content h2 { font-size: 1.5rem; }
.about-page__content h3 { font-size: 1.25rem; }
.about-page__content p { margin-bottom: 1rem; }
.about-page__content blockquote {
border-left: 3px solid var(--color-accent);
padding-left: 1rem;
margin: 1.5rem 0;
color: var(--color-muted);
font-style: italic;
}
.about-page__content ul, .about-page__content ol {
margin: 1rem 0;
padding-left: 1.5rem;
}
.about-page__content li { margin-bottom: 0.5rem; }
.about-page__content img { margin: 1.5rem 0; border-radius: 4px; }
@media (max-width: 768px) {
.about-page__content h1 { font-size: 1.5rem; }
.about-page__content h2 { font-size: 1.25rem; }
.about-page__content { font-size: 1rem; }
}
{% endblock %}
{% block content %}
<div class="about-page">
<div class="about-page__content">
{{ about_html | safe }}
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}Arkiv — {{ collection_title }}{% endblock %}
{% block extra_css %}
.dynamic-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.dynamic-page__header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-accent);
}
.dynamic-page__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.dynamic-page__subtitle {
color: var(--color-muted);
margin-top: 0.25rem;
}
.month-group {
margin-bottom: 2rem;
}
.month-group__heading {
font-family: var(--font-heading);
font-size: 1.25rem;
color: var(--color-primary);
padding-bottom: 0.5rem;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 0.75rem;
}
.article-list { list-style: none; }
.article-list__item {
padding: 1rem 0;
border-bottom: 1px solid #f5f5f5;
}
.article-list__item:last-child { border-bottom: none; }
.article-list__title {
font-family: var(--font-heading);
font-size: 1.2rem;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: 0.15rem;
}
.article-list__meta {
font-size: 0.8rem;
color: var(--color-muted);
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.pagination a, .pagination span {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.9rem;
}
.pagination .current {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.dynamic-page__title { font-size: 1.5rem; }
.article-list__title { font-size: 1.05rem; }
}
{% endblock %}
{% block content %}
<div class="dynamic-page">
<div class="dynamic-page__header">
<h1 class="dynamic-page__title">Arkiv</h1>
<p class="dynamic-page__subtitle">{{ total_articles }} artikler totalt</p>
</div>
{% if month_groups | length > 0 %}
{% for group in month_groups %}
<section class="month-group">
<h2 class="month-group__heading">{{ group.label }}</h2>
<ul class="article-list">
{% for item in group.articles %}
<li class="article-list__item">
<h3 class="article-list__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
<div class="article-list__meta">{{ item.published_at_short }}</div>
</li>
{% endfor %}
</ul>
</section>
{% endfor %}
{% if total_pages > 1 %}
<nav class="pagination">
{% if current_page > 1 %}
<a href="{{ base_url }}/arkiv?side={{ current_page - 1 }}">Forrige</a>
{% endif %}
{% for p in page_range %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% else %}
<a href="{{ base_url }}/arkiv?side={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ base_url }}/arkiv?side={{ current_page + 1 }}">Neste</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">Ingen publiserte artikler ennå.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,82 @@
{% extends "base.html" %}
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.article {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
display: grid;
grid-template-columns: 1fr 300px;
gap: 2rem;
}
.article__main { min-width: 0; }
.article__sidebar {
border-left: 2px solid var(--color-accent);
padding-left: 1.5rem;
}
.article__title {
font-family: var(--font-heading);
font-size: 2.5rem;
line-height: 1.15;
margin-bottom: 0.5rem;
color: var(--color-primary);
}
.article__meta {
color: var(--color-muted);
font-size: 0.875rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--color-muted);
}
.article__content {
font-size: 1.05rem;
line-height: 1.75;
}
.article__content p { margin-bottom: 1em; }
.article__back {
display: inline-block;
margin-top: 2rem;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.article { grid-template-columns: 1fr; }
.article__sidebar {
border-left: none;
border-top: 2px solid var(--color-accent);
padding-left: 0;
padding-top: 1.5rem;
}
}
{% endblock %}
{% block content %}
<article class="article">
<div class="article__main">
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:400px;object-fit:cover;margin-bottom:1.5rem;">{% endif %}
<h1 class="article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.15rem;color:var(--color-muted);margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="article__meta">Publisert {{ article.published_at_short }}</div>
<div class="article__content">{{ article.content | safe }}</div>
<a class="article__back" href="{{ base_url }}">&larr; Tilbake til forsiden</a>
</div>
<aside class="article__sidebar">
</aside>
</article>
{% endblock %}

View file

@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}{{ index.title }}{% endblock %}
{% block extra_css %}
.avis-layout {
max-width: var(--layout-max-width);
margin: 1.5rem auto;
padding: 0 1rem;
}
/* Hero */
.hero {
border-bottom: 3px solid var(--color-primary);
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.hero__title {
font-family: var(--font-heading);
font-size: 2.5rem;
line-height: 1.1;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.hero__summary {
font-size: 1.1rem;
color: var(--color-muted);
max-width: 60ch;
}
.hero__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-top: 0.5rem;
}
/* Featured + sidebar grid */
.avis-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1.5rem;
margin-bottom: 2rem;
}
.featured-list { display: flex; flex-direction: column; gap: 1rem; }
.featured-item {
border-bottom: 1px solid #e5e7eb;
padding-bottom: 1rem;
}
.featured-item__title {
font-family: var(--font-heading);
font-size: 1.25rem;
color: var(--color-primary);
margin-bottom: 0.25rem;
}
.featured-item__summary {
font-size: 0.9rem;
color: var(--color-muted);
}
/* Sidebar */
.sidebar {
border-left: 2px solid var(--color-accent);
padding-left: 1rem;
}
.sidebar__heading {
font-family: var(--font-heading);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-bottom: 1rem;
}
/* Stream */
.stream { border-top: 2px solid var(--color-primary); padding-top: 1rem; }
.stream__heading {
font-family: var(--font-heading);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted);
margin-bottom: 1rem;
}
.stream-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.stream-item__title {
font-family: var(--font-heading);
font-size: 1rem;
color: var(--color-primary);
}
.stream-item__meta {
font-size: 0.8rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.avis-grid { grid-template-columns: 1fr; }
.sidebar { border-left: none; border-top: 2px solid var(--color-accent); padding-left: 0; padding-top: 1rem; }
.hero__title { font-size: 1.75rem; }
}
{% endblock %}
{% block content %}
<div class="avis-layout">
{% if index.hero %}
<div class="hero">
<h2 class="hero__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
{% if index.hero.summary %}
<p class="hero__summary">{{ index.hero.summary }}</p>
{% endif %}
<div class="hero__meta">{{ index.hero.published_at_short }}</div>
</div>
{% endif %}
{% if index.featured | length > 0 or index.stream | length > 0 %}
<div class="avis-grid">
<div class="featured-list">
{% for item in index.featured %}
<div class="featured-item">
<h3 class="featured-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
{% if item.summary %}
<p class="featured-item__summary">{{ item.summary }}</p>
{% endif %}
</div>
{% endfor %}
</div>
<aside class="sidebar">
<div class="sidebar__heading">Siste nytt</div>
{% for item in index.stream %}
{% if loop.index <= 5 %}
<div style="margin-bottom: 0.75rem;">
<a href="{{ base_url }}/{{ item.short_id }}" style="font-size: 0.9rem;">{{ item.title }}</a>
<div style="font-size: 0.75rem; color: var(--color-muted);">{{ item.published_at_short }}</div>
</div>
{% endif %}
{% endfor %}
</aside>
</div>
{% endif %}
{% if index.stream | length > 5 %}
<section class="stream">
<div class="stream__heading">Flere saker</div>
<div class="stream-grid">
{% for item in index.stream %}
{% if loop.index > 5 %}
<div class="stream-item">
<h4 class="stream-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h4>
<div class="stream-item__meta">{{ item.published_at_short }}</div>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ collection_title | default(value="Synops") }}{% endblock %}</title>
{% if has_rss %}<link rel="alternate" type="application/rss+xml" title="{{ collection_title | default(value='RSS') }}" href="{{ base_url }}/feed.xml">{% endif %}
{% block seo %}{% endblock %}
<style>
{{ css_variables | safe }}
/* Reset */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-background);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
a { color: var(--color-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; height: auto; display: block; }
.site-header {
border-bottom: 1px solid var(--color-muted);
padding: 1rem 0;
}
.site-header__inner {
max-width: var(--layout-max-width);
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.site-title {
font-family: var(--font-heading);
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.site-title a { color: inherit; }
.site-footer {
border-top: 1px solid var(--color-muted);
padding: 2rem 0;
text-align: center;
color: var(--color-muted);
font-size: 0.875rem;
margin-top: 4rem;
}
.container {
max-width: var(--layout-max-width);
margin: 0 auto;
padding: 0 1rem;
}
{% block extra_css %}{% endblock %}
</style>
</head>
<body>
<header class="site-header">
<div class="site-header__inner">
<div class="site-title">
{% if logo_hash %}
<a href="{{ base_url }}"><img src="/cas/{{ logo_hash }}" alt="{{ index.title | default(value=collection_title) }}" style="max-height: 2.5rem;"></a>
{% else %}
<a href="{{ base_url }}">{{ index.title | default(value=collection_title) | default(value="Synops") }}</a>
{% endif %}
</div>
<nav style="display:flex;gap:1rem;align-items:center;font-size:0.9rem;">
<a href="{{ base_url }}/arkiv">Arkiv</a>
<a href="{{ base_url }}/sok">Søk</a>
{% if has_rss %}<a href="{{ base_url }}/feed.xml" title="RSS-feed">RSS</a>{% endif %}
</nav>
</div>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer class="site-footer">
<div class="container">
Drevet av Synops
</div>
</footer>
</body>
</html>

View file

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.blog-article {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.blog-article__title {
font-family: var(--font-heading);
font-size: 2rem;
line-height: 1.2;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.blog-article__meta {
color: var(--color-muted);
font-size: 0.875rem;
margin-bottom: 2rem;
}
.blog-article__content {
font-size: 1.05rem;
line-height: 1.75;
}
.blog-article__content p { margin-bottom: 1em; }
.blog-article__back {
display: inline-block;
margin-top: 2rem;
font-size: 0.9rem;
}
{% endblock %}
{% block content %}
<article class="blog-article">
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:400px;object-fit:cover;border-radius:0.5rem;margin-bottom:1.5rem;">{% endif %}
<h1 class="blog-article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.2rem;color:var(--color-muted);margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="blog-article__meta">{{ article.published_at_short }}</div>
<div class="blog-article__content">
{{ article.content | safe }}
</div>
<a class="blog-article__back" href="{{ base_url }}">&larr; Tilbake</a>
</article>
{% endblock %}

View file

@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{{ index.title }}{% endblock %}
{% block extra_css %}
.blog-layout {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.blog-list { list-style: none; }
.blog-item {
padding: 1.5rem 0;
border-bottom: 1px solid #f3f4f6;
}
.blog-item:first-child { padding-top: 0; }
.blog-item__title {
font-family: var(--font-heading);
font-size: 1.5rem;
color: var(--color-primary);
margin-bottom: 0.25rem;
line-height: 1.3;
}
.blog-item__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-bottom: 0.5rem;
}
.blog-item__summary {
font-size: 0.95rem;
color: var(--color-text);
line-height: 1.5;
}
/* Pinned hero-artikkel */
.blog-pinned {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
border-left: 4px solid var(--color-accent);
}
.blog-pinned__label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-accent);
margin-bottom: 0.5rem;
}
@media (max-width: 768px) {
.blog-item__title { font-size: 1.25rem; }
}
{% endblock %}
{% block content %}
<div class="blog-layout">
{% if index.hero %}
<div class="blog-pinned">
<div class="blog-pinned__label">Fremhevet</div>
<h2 class="blog-item__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
{% if index.hero.summary %}
<p class="blog-item__summary">{{ index.hero.summary }}</p>
{% endif %}
<div class="blog-item__meta">{{ index.hero.published_at_short }}</div>
</div>
{% endif %}
{% if index.featured | length > 0 %}
{% for item in index.featured %}
<div class="blog-item" style="border-left: 3px solid var(--color-accent); padding-left: 1rem; margin-bottom: 1rem;">
<h3 class="blog-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
{% if item.summary %}
<p class="blog-item__summary">{{ item.summary }}</p>
{% endif %}
<div class="blog-item__meta">{{ item.published_at_short }}</div>
</div>
{% endfor %}
{% endif %}
<ul class="blog-list">
{% for item in index.stream %}
<li class="blog-item">
<h3 class="blog-item__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
<div class="blog-item__meta">{{ item.published_at_short }}</div>
{% if item.summary %}
<p class="blog-item__summary">{{ item.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endblock %}

View file

@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}{{ tag_name }} — {{ collection_title }}{% endblock %}
{% block extra_css %}
.dynamic-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.dynamic-page__header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-accent);
}
.dynamic-page__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.dynamic-page__subtitle {
color: var(--color-muted);
margin-top: 0.25rem;
}
.article-list { list-style: none; }
.article-list__item {
padding: 1.25rem 0;
border-bottom: 1px solid #f0f0f0;
}
.article-list__item:first-child { padding-top: 0; }
.article-list__title {
font-family: var(--font-heading);
font-size: 1.35rem;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: 0.25rem;
}
.article-list__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-bottom: 0.4rem;
}
.article-list__summary {
font-size: 0.95rem;
line-height: 1.5;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.pagination a, .pagination span {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.9rem;
}
.pagination .current {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.tag-link {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #f3f4f6;
border-radius: 1rem;
font-size: 0.85rem;
color: var(--color-text);
}
.tag-link:hover { background: var(--color-accent); color: #fff; text-decoration: none; }
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.dynamic-page__title { font-size: 1.5rem; }
.article-list__title { font-size: 1.15rem; }
}
{% endblock %}
{% block content %}
<div class="dynamic-page">
<div class="dynamic-page__header">
<h1 class="dynamic-page__title">{{ tag_name }}</h1>
<p class="dynamic-page__subtitle">{{ article_count }} {% if article_count == 1 %}artikkel{% else %}artikler{% endif %}</p>
</div>
{% if articles | length > 0 %}
<ul class="article-list">
{% for item in articles %}
<li class="article-list__item">
<h2 class="article-list__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h2>
<div class="article-list__meta">{{ item.published_at_short }}</div>
{% if item.summary %}
<p class="article-list__summary">{{ item.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if total_pages > 1 %}
<nav class="pagination">
{% if current_page > 1 %}
<a href="{{ base_url }}/kategori/{{ tag_slug }}?side={{ current_page - 1 }}">Forrige</a>
{% endif %}
{% for p in page_range %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% else %}
<a href="{{ base_url }}/kategori/{{ tag_slug }}?side={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ base_url }}/kategori/{{ tag_slug }}?side={{ current_page + 1 }}">Neste</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">Ingen artikler i denne kategorien.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.mag-article {
max-width: var(--layout-max-width);
margin: 0 auto;
padding: 0 1rem;
}
.mag-article__header {
text-align: center;
padding: 3rem 0 2rem;
max-width: 720px;
margin: 0 auto;
}
.mag-article__title {
font-family: var(--font-heading);
font-size: 3rem;
line-height: 1.1;
color: var(--color-primary);
margin-bottom: 0.75rem;
}
.mag-article__meta {
color: var(--color-muted);
font-size: 0.9rem;
}
.mag-article__content {
max-width: 680px;
margin: 0 auto;
font-size: 1.1rem;
line-height: 1.8;
padding-bottom: 3rem;
}
.mag-article__content p { margin-bottom: 1.25em; }
.mag-article__back {
display: inline-block;
margin-top: 2rem;
font-size: 0.9rem;
}
@media (max-width: 768px) {
.mag-article__title { font-size: 2rem; }
.mag-article__header { padding: 2rem 0 1.5rem; }
}
{% endblock %}
{% block content %}
<article class="mag-article">
{% if article.og_image %}<img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="width:100%;max-height:500px;object-fit:cover;margin-bottom:0;">{% endif %}
<header class="mag-article__header">
<h1 class="mag-article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.25rem;color:var(--color-muted);margin-top:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="mag-article__meta">Publisert {{ article.published_at_short }}</div>
</header>
<div class="mag-article__content">
{{ article.content | safe }}
<a class="mag-article__back" href="{{ base_url }}">&larr; Tilbake</a>
</div>
</article>
{% endblock %}

View file

@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}{{ index.title }}{% endblock %}
{% block extra_css %}
.mag-layout {
max-width: var(--layout-max-width);
margin: 0 auto;
padding: 0 1rem;
}
/* Hero — fullbredde */
.mag-hero {
padding: 3rem 0;
text-align: center;
border-bottom: 1px solid #e5e7eb;
margin-bottom: 2rem;
}
.mag-hero__title {
font-family: var(--font-heading);
font-size: 3rem;
line-height: 1.1;
color: var(--color-primary);
margin-bottom: 0.75rem;
}
.mag-hero__summary {
font-size: 1.15rem;
color: var(--color-muted);
max-width: 50ch;
margin: 0 auto;
}
.mag-hero__meta {
font-size: 0.85rem;
color: var(--color-muted);
margin-top: 0.75rem;
}
/* Featured cards */
.mag-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.mag-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1.5rem;
transition: box-shadow 0.2s;
}
.mag-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.mag-card__title {
font-family: var(--font-heading);
font-size: 1.35rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
line-height: 1.25;
}
.mag-card__summary {
font-size: 0.95rem;
color: var(--color-muted);
line-height: 1.5;
}
.mag-card__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-top: 0.75rem;
}
/* Kronologisk strøm */
.mag-stream { border-top: 2px solid var(--color-primary); padding-top: 1.5rem; }
.mag-stream__heading {
font-family: var(--font-heading);
font-size: 1.25rem;
color: var(--color-primary);
margin-bottom: 1rem;
}
.mag-stream-item {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.75rem 0;
border-bottom: 1px solid #f3f4f6;
}
.mag-stream-item__title { font-size: 1rem; }
.mag-stream-item__date { font-size: 0.8rem; color: var(--color-muted); white-space: nowrap; margin-left: 1rem; }
@media (max-width: 768px) {
.mag-hero__title { font-size: 2rem; }
.mag-hero { padding: 2rem 0; }
}
{% endblock %}
{% block content %}
<div class="mag-layout">
{% if index.hero %}
<div class="mag-hero">
<h2 class="mag-hero__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
{% if index.hero.summary %}
<p class="mag-hero__summary">{{ index.hero.summary }}</p>
{% endif %}
<div class="mag-hero__meta">{{ index.hero.published_at_short }}</div>
</div>
{% endif %}
{% if index.featured | length > 0 %}
<div class="mag-cards">
{% for item in index.featured %}
<div class="mag-card">
<h3 class="mag-card__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
{% if item.summary %}
<p class="mag-card__summary">{{ item.summary }}</p>
{% endif %}
<div class="mag-card__meta">{{ item.published_at_short }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if index.stream | length > 0 %}
<section class="mag-stream">
<h3 class="mag-stream__heading">Alle artikler</h3>
{% for item in index.stream %}
<div class="mag-stream-item">
<a class="mag-stream-item__title" href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a>
<span class="mag-stream-item__date">{{ item.published_at_short }}</span>
</div>
{% endfor %}
</section>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,162 @@
{% extends "base.html" %}
{% block title %}{% if query %}Søk: {{ query }} — {% endif %}{{ collection_title }}{% endblock %}
{% block extra_css %}
.dynamic-page {
max-width: var(--layout-max-width);
margin: 2rem auto;
padding: 0 1rem;
}
.dynamic-page__header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--color-accent);
}
.dynamic-page__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.search-form {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.search-form__input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1rem;
font-family: var(--font-body);
color: var(--color-text);
background: var(--color-background);
}
.search-form__input:focus {
border-color: var(--color-accent);
outline: none;
}
.search-form__button {
padding: 0.75rem 1.5rem;
background: var(--color-accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
}
.search-form__button:hover { opacity: 0.9; }
.search-results__info {
color: var(--color-muted);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.article-list { list-style: none; }
.article-list__item {
padding: 1.25rem 0;
border-bottom: 1px solid #f0f0f0;
}
.article-list__item:first-child { padding-top: 0; }
.article-list__title {
font-family: var(--font-heading);
font-size: 1.35rem;
color: var(--color-primary);
line-height: 1.3;
margin-bottom: 0.25rem;
}
.article-list__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-bottom: 0.4rem;
}
.article-list__summary {
font-size: 0.95rem;
line-height: 1.5;
}
.article-list__highlight {
background: rgba(233, 69, 96, 0.1);
padding: 0 0.15rem;
border-radius: 2px;
}
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f0f0f0;
}
.pagination a, .pagination span {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-size: 0.9rem;
}
.pagination .current {
background: var(--color-accent);
color: #fff;
border-color: var(--color-accent);
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-muted);
}
@media (max-width: 768px) {
.dynamic-page__title { font-size: 1.5rem; }
.article-list__title { font-size: 1.15rem; }
.search-form { flex-direction: column; }
}
{% endblock %}
{% block content %}
<div class="dynamic-page">
<div class="dynamic-page__header">
<h1 class="dynamic-page__title">Søk</h1>
<form class="search-form" method="get" action="{{ base_url }}/sok">
<input class="search-form__input" type="text" name="q" value="{{ query | default(value='') }}" placeholder="Søk i artikler..." autocomplete="off">
<button class="search-form__button" type="submit">Søk</button>
</form>
</div>
{% if query %}
{% if articles | length > 0 %}
<p class="search-results__info">{{ result_count }} treff for &laquo;{{ query }}&raquo;</p>
<ul class="article-list">
{% for item in articles %}
<li class="article-list__item">
<h2 class="article-list__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h2>
<div class="article-list__meta">{{ item.published_at_short }}</div>
{% if item.summary %}
<p class="article-list__summary">{{ item.summary }}</p>
{% endif %}
</li>
{% endfor %}
</ul>
{% if total_pages > 1 %}
<nav class="pagination">
{% if current_page > 1 %}
<a href="{{ base_url }}/sok?q={{ query | urlencode }}&side={{ current_page - 1 }}">Forrige</a>
{% endif %}
{% for p in page_range %}
{% if p == current_page %}
<span class="current">{{ p }}</span>
{% else %}
<a href="{{ base_url }}/sok?q={{ query | urlencode }}&side={{ p }}">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{{ base_url }}/sok?q={{ query | urlencode }}&side={{ current_page + 1 }}">Neste</a>
{% endif %}
</nav>
{% endif %}
{% else %}
<div class="empty-state">Ingen treff for &laquo;{{ query }}&raquo;.</div>
{% endif %}
{% else %}
<div class="empty-state">Skriv inn et søkeord for å søke i artiklene.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}{{ article.title }} — {{ collection_title }}{% endblock %}
{% block seo %}
<meta name="description" content="{{ seo.description }}">
<link rel="canonical" href="{{ seo.canonical_url }}">
<meta property="og:type" content="article">
<meta property="og:title" content="{{ seo.og_title }}">
<meta property="og:description" content="{{ seo.description }}">
<meta property="og:url" content="{{ seo.canonical_url }}">
<meta property="og:site_name" content="{{ collection_title }}">
{% if seo.og_image %}<meta property="og:image" content="{{ seo.og_image }}">{% endif %}
<meta property="article:published_time" content="{{ article.published_at }}">
<link rel="alternate" type="application/atom+xml" title="{{ collection_title }} — RSS" href="{{ base_url }}/feed.xml">
<script type="application/ld+json">{{ seo.json_ld | safe }}</script>
{% endblock %}
{% block extra_css %}
.journal-article {
max-width: var(--layout-max-width);
margin: 3rem auto;
padding: 0 1rem;
}
.journal-article__title {
font-family: var(--font-heading);
font-size: 1.75rem;
line-height: 1.3;
color: var(--color-primary);
margin-bottom: 0.25rem;
text-align: center;
}
.journal-article__meta {
color: var(--color-muted);
font-size: 0.85rem;
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-muted);
}
.journal-article__content {
font-size: 1rem;
line-height: 1.8;
text-align: justify;
hyphens: auto;
}
.journal-article__content p { margin-bottom: 1em; text-indent: 1.5em; }
.journal-article__content p:first-child { text-indent: 0; }
.journal-article__back {
display: inline-block;
margin-top: 2rem;
font-size: 0.85rem;
}
{% endblock %}
{% block content %}
<article class="journal-article">
{% if article.og_image %}<div style="text-align:center;margin-bottom:1.5rem;"><img src="/cas/{{ article.og_image }}" alt="{{ article.title }}" style="max-width:100%;max-height:400px;object-fit:cover;"></div>{% endif %}
<h1 class="journal-article__title">{{ article.title }}</h1>
{% if article.subtitle %}<p style="font-size:1.1rem;color:var(--color-muted);text-align:center;margin-bottom:0.5rem;font-family:var(--font-heading);">{{ article.subtitle }}</p>{% endif %}
<div class="journal-article__meta">Publisert {{ article.published_at_short }}</div>
<div class="journal-article__content">
{{ article.content | safe }}
</div>
<a class="journal-article__back" href="{{ base_url }}">&larr; Tilbake til innholdsfortegnelse</a>
</article>
{% endblock %}

View file

@ -0,0 +1,133 @@
{% extends "base.html" %}
{% block title %}{{ index.title }}{% endblock %}
{% block extra_css %}
.journal-layout {
max-width: var(--layout-max-width);
margin: 3rem auto;
padding: 0 1rem;
}
.journal-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 2px solid var(--color-primary);
}
.journal-header__title {
font-family: var(--font-heading);
font-size: 2rem;
color: var(--color-primary);
}
.journal-header__desc {
font-size: 0.95rem;
color: var(--color-muted);
font-style: italic;
margin-top: 0.5rem;
}
/* Nummerert innholdsfortegnelse */
.journal-toc {
counter-reset: article-counter;
list-style: none;
}
.journal-toc__item {
counter-increment: article-counter;
padding: 1rem 0;
border-bottom: 1px solid #eee;
display: flex;
align-items: baseline;
gap: 1rem;
}
.journal-toc__item::before {
content: counter(article-counter) ".";
font-family: var(--font-heading);
font-size: 1.1rem;
color: var(--color-muted);
min-width: 2rem;
text-align: right;
}
.journal-toc__title {
font-family: var(--font-heading);
font-size: 1.1rem;
color: var(--color-primary);
line-height: 1.3;
}
.journal-toc__meta {
font-size: 0.8rem;
color: var(--color-muted);
margin-top: 0.25rem;
}
/* Fremhevet (hero/featured) */
.journal-featured {
margin-bottom: 2rem;
padding-bottom: 1.5rem;
border-bottom: 1px solid var(--color-primary);
}
.journal-featured__label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-accent);
margin-bottom: 0.5rem;
}
.journal-featured__title {
font-family: var(--font-heading);
font-size: 1.5rem;
color: var(--color-primary);
line-height: 1.3;
}
.journal-featured__summary {
font-size: 0.95rem;
color: var(--color-text);
margin-top: 0.5rem;
line-height: 1.5;
}
{% endblock %}
{% block content %}
<div class="journal-layout">
<header class="journal-header">
<h1 class="journal-header__title">{{ index.title }}</h1>
{% if index.description %}
<p class="journal-header__desc">{{ index.description }}</p>
{% endif %}
</header>
{% if index.hero %}
<div class="journal-featured">
<div class="journal-featured__label">Hovedartikkel</div>
<h2 class="journal-featured__title"><a href="{{ base_url }}/{{ index.hero.short_id }}">{{ index.hero.title }}</a></h2>
{% if index.hero.summary %}
<p class="journal-featured__summary">{{ index.hero.summary }}</p>
{% endif %}
</div>
{% endif %}
{% if index.featured | length > 0 %}
{% for item in index.featured %}
<div class="journal-featured" style="border-bottom-color: #eee;">
<h3 class="journal-featured__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></h3>
{% if item.summary %}
<p class="journal-featured__summary">{{ item.summary }}</p>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% if index.stream | length > 0 %}
<h2 style="font-family: var(--font-heading); font-size: 1rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-muted); margin-bottom: 0.5rem;">Innholdsfortegnelse</h2>
<ol class="journal-toc">
{% for item in index.stream %}
<li class="journal-toc__item">
<div>
<div class="journal-toc__title"><a href="{{ base_url }}/{{ item.short_id }}">{{ item.title }}</a></div>
<div class="journal-toc__meta">{{ item.published_at_short }}</div>
</div>
</li>
{% endfor %}
</ol>
{% endif %}
</div>
{% endblock %}

View file

@ -0,0 +1,291 @@
//! TipTap/ProseMirror JSON → HTML-konvertering.
//!
//! Konverterer `metadata.document` (TipTap JSON) til HTML-streng.
//! Identisk med maskinrommet/src/tiptap.rs — delt logikk.
//! Ref: oppgave 21.16 (synops-common) vil samle dette i felles crate.
use serde_json::Value;
/// Konverter et TipTap/ProseMirror-dokument (JSON) til HTML.
/// Returnerer tom streng hvis dokumentet er ugyldig.
pub fn document_to_html(doc: &Value) -> String {
let Some(content) = doc.get("content").and_then(|c| c.as_array()) else {
return String::new();
};
let mut html = String::new();
for node in content {
render_node(node, &mut html);
}
html
}
fn render_node(node: &Value, out: &mut String) {
let node_type = node.get("type").and_then(|t| t.as_str()).unwrap_or("");
match node_type {
"paragraph" => {
out.push_str("<p>");
render_inline_content(node, out);
out.push_str("</p>\n");
}
"heading" => {
let level = node
.get("attrs")
.and_then(|a| a.get("level"))
.and_then(|l| l.as_u64())
.unwrap_or(2)
.min(6);
out.push_str(&format!("<h{level}>"));
render_inline_content(node, out);
out.push_str(&format!("</h{level}>\n"));
}
"blockquote" => {
out.push_str("<blockquote>\n");
render_children(node, out);
out.push_str("</blockquote>\n");
}
"bulletList" | "bullet_list" => {
out.push_str("<ul>\n");
render_children(node, out);
out.push_str("</ul>\n");
}
"orderedList" | "ordered_list" => {
let start = node
.get("attrs")
.and_then(|a| a.get("start"))
.and_then(|s| s.as_u64())
.unwrap_or(1);
if start == 1 {
out.push_str("<ol>\n");
} else {
out.push_str(&format!("<ol start=\"{start}\">\n"));
}
render_children(node, out);
out.push_str("</ol>\n");
}
"listItem" | "list_item" => {
out.push_str("<li>");
render_children(node, out);
out.push_str("</li>\n");
}
"codeBlock" | "code_block" => {
let lang = node
.get("attrs")
.and_then(|a| a.get("language"))
.and_then(|l| l.as_str())
.unwrap_or("");
if lang.is_empty() {
out.push_str("<pre><code>");
} else {
out.push_str(&format!("<pre><code class=\"language-{}\">", escape_html(lang)));
}
render_inline_content(node, out);
out.push_str("</code></pre>\n");
}
"horizontalRule" | "horizontal_rule" => {
out.push_str("<hr>\n");
}
"image" => {
let attrs = node.get("attrs");
let src = attrs
.and_then(|a| a.get("src"))
.and_then(|s| s.as_str())
.unwrap_or("");
let alt = attrs
.and_then(|a| a.get("alt"))
.and_then(|s| s.as_str())
.unwrap_or("");
let title = attrs
.and_then(|a| a.get("title"))
.and_then(|s| s.as_str());
out.push_str(&format!(
"<img src=\"{}\" alt=\"{}\"",
escape_attr(src),
escape_attr(alt)
));
if let Some(t) = title {
out.push_str(&format!(" title=\"{}\"", escape_attr(t)));
}
out.push_str(">\n");
}
"hardBreak" | "hard_break" => {
out.push_str("<br>");
}
_ => {
render_children(node, out);
}
}
}
fn render_children(node: &Value, out: &mut String) {
if let Some(content) = node.get("content").and_then(|c| c.as_array()) {
for child in content {
render_node(child, out);
}
}
}
fn render_inline_content(node: &Value, out: &mut String) {
let Some(content) = node.get("content").and_then(|c| c.as_array()) else {
return;
};
for child in content {
let child_type = child.get("type").and_then(|t| t.as_str()).unwrap_or("");
match child_type {
"text" => {
let text = child.get("text").and_then(|t| t.as_str()).unwrap_or("");
let marks = child.get("marks").and_then(|m| m.as_array());
render_text_with_marks(text, marks, out);
}
"hardBreak" | "hard_break" => {
out.push_str("<br>");
}
"image" => {
render_node(child, out);
}
_ => {
render_node(child, out);
}
}
}
}
fn render_text_with_marks(text: &str, marks: Option<&Vec<Value>>, out: &mut String) {
let Some(marks) = marks else {
out.push_str(&escape_html(text));
return;
};
let mut close_tags: Vec<&str> = Vec::new();
for mark in marks {
let mark_type = mark.get("type").and_then(|t| t.as_str()).unwrap_or("");
match mark_type {
"bold" | "strong" => {
out.push_str("<strong>");
close_tags.push("</strong>");
}
"italic" | "em" => {
out.push_str("<em>");
close_tags.push("</em>");
}
"strike" | "strikethrough" => {
out.push_str("<s>");
close_tags.push("</s>");
}
"code" => {
out.push_str("<code>");
close_tags.push("</code>");
}
"underline" => {
out.push_str("<u>");
close_tags.push("</u>");
}
"link" => {
let href = mark
.get("attrs")
.and_then(|a| a.get("href"))
.and_then(|h| h.as_str())
.unwrap_or("#");
let target = mark
.get("attrs")
.and_then(|a| a.get("target"))
.and_then(|t| t.as_str());
out.push_str(&format!("<a href=\"{}\"", escape_attr(href)));
if let Some(t) = target {
out.push_str(&format!(" target=\"{}\"", escape_attr(t)));
}
out.push_str(" rel=\"noopener noreferrer\">");
close_tags.push("</a>");
}
_ => {}
}
}
out.push_str(&escape_html(text));
for tag in close_tags.iter().rev() {
out.push_str(tag);
}
}
fn escape_html(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
fn escape_attr(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn simple_paragraph() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "Hello world" }]
}]
});
assert_eq!(document_to_html(&doc), "<p>Hello world</p>\n");
}
#[test]
fn heading_levels() {
let doc = json!({
"type": "doc",
"content": [{
"type": "heading",
"attrs": { "level": 2 },
"content": [{ "type": "text", "text": "Title" }]
}]
});
assert_eq!(document_to_html(&doc), "<h2>Title</h2>\n");
}
#[test]
fn bold_mark() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{
"type": "text",
"text": "bold",
"marks": [{ "type": "bold" }]
}]
}]
});
assert_eq!(document_to_html(&doc), "<p><strong>bold</strong></p>\n");
}
#[test]
fn html_escaping() {
let doc = json!({
"type": "doc",
"content": [{
"type": "paragraph",
"content": [{ "type": "text", "text": "<script>alert('xss')</script>" }]
}]
});
let html = document_to_html(&doc);
assert!(!html.contains("<script>"));
assert!(html.contains("&lt;script&gt;"));
}
#[test]
fn empty_doc() {
let doc = json!({ "type": "doc" });
assert_eq!(document_to_html(&doc), "");
}
}