Categorieënpagina, screenshot-hero, emoji/vlaggen, auteur-byline en publicatie-planning

- Categorie automatisch gekoppeld aan een vaste dekkleur (geen coverHue-veld meer nodig), /categorieen groepeert alle gepubliceerde reviews en categorieën zijn overal klikbaar
- Screenshot verschijnt als gelinkte hero tussen specs en body i.p.v. los in de Markdown-tekst; afbeeldingen in de body breken nu uit de tekstkolom
- :shortcode:-emoji en :flag_xx:-landvlaggen werken zowel in de Markdown-body als in frontmatter-tekstvelden
- Zichtbare "Door Menno"-byline + auteurs-metatag, adres via src/lib/site.ts
- Reviews met draft: false en een datum in de toekomst blijven volledig verborgen (ook niet als placeholder) tot na een build ná die datum; reviewpagina krijgt dan noindex
- make deploy-target (build + rsync naar de server)
- CGI-formulier gebruikt nu het net opgezette aitestbank.nl-maildomein i.p.v. een tijdelijk adres
This commit is contained in:
2026-07-24 16:04:10 +02:00
parent f406055926
commit 96c51baba0
19 changed files with 1648 additions and 84 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

View File

@@ -27,10 +27,10 @@ const reviews = defineCollection({
score: z.number().int().min(1).max(5).optional(),
verdict: z.string().optional().default(""),
// Alleen voor een eventueel echt merklogo (public/logos/...). De kleur van
// de gegenereerde placeholder-cover wordt niet hier gekozen, maar
// automatisch afgeleid van `category` — zie src/lib/category.ts.
logo: z.string().optional().default(""),
coverHue: z
.enum(["teal", "amber", "rust", "slate", "olive"])
.default("teal"),
})
.superRefine((data, ctx) => {
if (data.draft) return;

View File

@@ -1,11 +1,15 @@
---
import { SITE_AUTHOR_NAME, SITE_AUTHOR_EMAIL } from "../lib/site";
interface Props {
title: string;
description: string;
noindex?: boolean;
}
const { title, description } = Astro.props;
const { title, description, noindex = false } = Astro.props;
const path = Astro.url.pathname;
const isHome = path === "/";
const isCategorieen = path.startsWith("/categorieen");
const isVoorstellen = path.startsWith("/voorstellen");
---
@@ -16,7 +20,9 @@ const isVoorstellen = path.startsWith("/voorstellen");
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} · AITestBank</title>
<meta name="description" content={description} />
<link rel="icon" href="data:," />
<meta name="author" content={SITE_AUTHOR_NAME} />
{noindex && <meta name="robots" content="noindex, nofollow" />}
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<svg width="0" height="0" style="position:absolute">
@@ -37,6 +43,7 @@ const isVoorstellen = path.startsWith("/voorstellen");
</a>
<nav class="site">
<a href="/" aria-current={isHome ? "page" : undefined}>Reviews</a>
<a href="/categorieen" aria-current={isCategorieen ? "page" : undefined}>Categorieën</a>
<a href="/voorstellen" aria-current={isVoorstellen ? "page" : undefined}>Tool voorstellen</a>
</nav>
</header>

22
src/lib/category.ts Normal file
View File

@@ -0,0 +1,22 @@
const HUES = ["teal", "amber", "rust", "slate", "olive"] as const;
export type Hue = (typeof HUES)[number];
export function categorySlug(category: string): string {
return category
.normalize("NFD")
.replace(/[̀-ͯ]/g, "") // klemtoontekens eraf (bv. e-acute -> e)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
// Deterministisch: dezelfde categorie krijgt overal (homepage, categorieen-pagina,
// reviewpagina) dezelfde kleur, zonder dat je 'm per review handmatig hoeft te kiezen.
export function categoryHue(category: string): Hue {
const slug = categorySlug(category);
let hash = 0;
for (let i = 0; i < slug.length; i++) {
hash = (hash * 31 + slug.charCodeAt(i)) | 0;
}
return HUES[Math.abs(hash) % HUES.length];
}

17
src/lib/emojify.ts Normal file
View File

@@ -0,0 +1,17 @@
import { emojify as nodeEmojify } from "node-emoji";
// Zelfde landvlag-logica als src/lib/remark-flag-emoji.mjs (die geldt alleen
// voor de Markdown-body). Deze helper is voor losse frontmatter-strings
// (hosting, location, for_who, price_from, verdict, title, company, category)
// die niet door de Markdown-verwerking heen gaan.
function toFlagEmoji(countryCode: string): string {
const codePoints = [...countryCode.toUpperCase()].map(
(char) => 0x1f1e6 + (char.charCodeAt(0) - 65),
);
return String.fromCodePoint(...codePoints);
}
export function emojify(text: string): string {
const withFlags = text.replace(/:flag_([a-z]{2}):/gi, (_match, cc) => toFlagEmoji(cc));
return nodeEmojify(withFlags);
}

10
src/lib/publish.ts Normal file
View File

@@ -0,0 +1,10 @@
// Static site: "nu" is bevroren op het moment van de laatste build. Een
// review met draft: false en een datum in de toekomst is dus pas écht
// zichtbaar zodra er ná die datum opnieuw gebouwd + gedeployed wordt.
export function isPublished(data: { draft: boolean; date?: Date }): boolean {
return !data.draft && !!data.date && data.date.getTime() <= Date.now();
}
export function isScheduled(data: { draft: boolean; date?: Date }): boolean {
return !data.draft && !!data.date && data.date.getTime() > Date.now();
}

View File

@@ -0,0 +1,23 @@
import { findAndReplace } from "mdast-util-find-and-replace";
// remark-emoji's dataset (node-emoji) heeft geen landvlaggen — logisch, want
// dat zijn er ~260 en ze worden niet los benoemd maar samengesteld uit twee
// "regional indicator symbols". Elke ISO-3166-1-alpha-2-landcode werkt dus
// automatisch, zonder vaste lijst: :flag_nl: -> 🇳🇱, :flag_de: -> 🇩🇪, enz.
function toFlagEmoji(countryCode) {
const codePoints = [...countryCode.toUpperCase()].map(
(char) => 0x1f1e6 + (char.charCodeAt(0) - 65),
);
return String.fromCodePoint(...codePoints);
}
export default function remarkFlagEmoji() {
return (tree) => {
findAndReplace(tree, [
[
/:flag_([a-z]{2}):/gi,
(_match, countryCode) => toFlagEmoji(countryCode),
],
]);
};
}

4
src/lib/site.ts Normal file
View File

@@ -0,0 +1,4 @@
// Single-author blog: één plek om de auteursgegevens vast te leggen,
// i.p.v. dit per review in de frontmatter te herhalen.
export const SITE_AUTHOR_NAME = "Menno";
export const SITE_AUTHOR_EMAIL = "menno@aitestbank.nl";

124
src/pages/categorieen.astro Normal file
View File

@@ -0,0 +1,124 @@
---
import { getCollection } from "astro:content";
import Base from "../layouts/Base.astro";
import Cover from "../components/Cover.astro";
import RobotScore from "../components/RobotScore.astro";
import { categorySlug, categoryHue } from "../lib/category";
import { emojify } from "../lib/emojify";
import { isPublished } from "../lib/publish";
const all = await getCollection("reviews");
const published = all.filter((r) => isPublished(r.data));
const byCategory = new Map<string, typeof published>();
for (const post of published) {
const key = post.data.category;
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key)!.push(post);
}
const categories = [...byCategory.entries()].sort((a, b) => a[0].localeCompare(b[0], "nl"));
---
<Base
title="Categorieën"
description="Alle AITestBank-reviews gegroepeerd per categorie."
>
<section class="intro">
<h1>Categorieën</h1>
<p>Alle reviews gegroepeerd per soort tool.</p>
</section>
{
categories.length === 0 && (
<p class="empty">Nog geen gepubliceerde reviews.</p>
)
}
{
categories.map(([category, posts]) => (
<section class="category" id={categorySlug(category)}>
<h2>
<span class={`dot hue-${categoryHue(category)}`} />
{emojify(category)}
</h2>
<ul class="taglist">
{posts.map((post) => (
<li class="tag-row">
<Cover hue={categoryHue(category)} logo={post.data.logo} company={post.data.company} size="thumb" />
<div class="tag-body">
<h3>
<a href={`/reviews/${post.id}`}>{emojify(post.data.title)}</a>
</h3>
<p class="dek">{emojify(post.data.location)} · {emojify(post.data.verdict)}</p>
</div>
<RobotScore score={post.data.score} />
</li>
))}
</ul>
</section>
))
}
</Base>
<style>
section.intro {
padding: 44px 0 10px;
border-bottom: 1px solid var(--line);
margin-bottom: 8px;
}
section.intro h1 {
font-size: 34px;
margin: 0 0 12px;
text-wrap: balance;
letter-spacing: -0.01em;
}
section.intro p {
margin: 0 0 30px;
color: var(--muted);
font-size: 18px;
}
.empty { color: var(--muted); padding: 30px 0; }
.category { padding: 34px 0; border-bottom: 1px solid var(--line); scroll-margin-top: 20px; }
.category h2 {
display: flex;
align-items: center;
gap: 10px;
font-size: 22px;
margin: 0 0 18px;
letter-spacing: -0.005em;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--hue-teal-ink);
flex-shrink: 0;
}
.dot.hue-amber { background: var(--hue-amber-ink); }
.dot.hue-rust { background: var(--hue-rust-ink); }
.dot.hue-slate { background: var(--hue-slate-ink); }
.dot.hue-olive { background: var(--hue-olive-ink); }
ul.taglist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 20px; }
.tag-row {
display: grid;
grid-template-columns: 64px 1fr auto;
gap: 16px;
align-items: center;
}
.tag-row :global(.cover.thumb) { width: 64px; height: 64px; }
.tag-row :global(.cover.thumb) :global(.mark) { width: 38px; height: 38px; }
.tag-body h3 { margin: 0; font-size: 16px; line-height: 1.35; }
.tag-body h3 a { color: var(--ink); text-decoration: none; }
.tag-body h3 a:hover { color: var(--accent); }
.tag-body p.dek { margin: 3px 0 0; color: var(--muted); font-size: 14px; }
@media (max-width: 560px) {
.tag-row { grid-template-columns: 48px 1fr; }
.tag-row :global(.cover.thumb) { width: 48px; height: 48px; grid-row: 1 / 3; }
.tag-row :global(.cover.thumb) :global(.mark) { width: 28px; height: 28px; }
.tag-row > :last-child:not(.cover) { grid-column: 2; }
}
</style>

View File

@@ -3,13 +3,20 @@ import { getCollection } from "astro:content";
import Base from "../layouts/Base.astro";
import Cover from "../components/Cover.astro";
import RobotScore from "../components/RobotScore.astro";
import { categorySlug, categoryHue } from "../lib/category";
import { emojify } from "../lib/emojify";
import { isPublished } from "../lib/publish";
const all = await getCollection("reviews");
const published = all
.filter((r) => !r.data.draft)
.filter((r) => isPublished(r.data))
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
const upcoming = all.filter((r) => r.data.draft);
const posts = [...published, ...upcoming];
// Geplande reviews (draft: false, datum in de toekomst) blijven helemaal
// onzichtbaar op de homepage — niet eens als placeholder-rij — tot de datum
// voorbij is en er een nieuwe build gebeurt. Alleen echte drafts (geen datum)
// verschijnen als "binnenkort".
const drafts = all.filter((r) => r.data.draft);
const posts = [...published, ...drafts];
const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
day: "numeric",
@@ -26,43 +33,40 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
<h1>Nederlandse AI-tools, eerlijk getest.</h1>
<p>
Elke week (of vaker) een AI-tool onder de loep: hoe het werkt, voor wie
het is, wat het kost. Score van 1 tot 5 robots, geen sponsored fluff.
het is, wat het kost. Score van 1 tot 5 robots.
</p>
</section>
<ul class="postlist">
{
posts.map((post) => (
<li class="post-row">
<Cover
hue={post.data.coverHue}
logo={post.data.logo}
company={post.data.company}
size="thumb"
/>
<div class="meta">
<span class="tabnum">
{post.data.draft ? "—" : dateFormatter.format(post.data.date)}
</span>
<span>·</span>
<span>{post.data.category}</span>
<span class={`status ${post.data.draft ? "pending" : ""}`}>
{post.data.draft ? "binnenkort" : "gepubliceerd"}
</span>
</div>
<h2>
<a href={post.data.draft ? undefined : `/reviews/${post.id}`}>
{post.data.title}
</a>
</h2>
<p class="dek">
{post.data.location} · {post.data.verdict}
</p>
<div class="score">
{!post.data.draft && <RobotScore score={post.data.score} />}
</div>
</li>
))
posts.map((post) => {
const live = isPublished(post.data);
return (
<li class="post-row">
<Cover
hue={categoryHue(post.data.category)}
logo={post.data.logo}
company={post.data.company}
size="thumb"
/>
<div class="meta">
<span class="tabnum">{live ? dateFormatter.format(post.data.date) : "—"}</span>
<span>·</span>
<a href={`/categorieen#${categorySlug(post.data.category)}`}>{emojify(post.data.category)}</a>
<span class={`status ${live ? "" : "pending"}`}>{live ? "gepubliceerd" : "binnenkort"}</span>
</div>
<h2>
<a href={live ? `/reviews/${post.id}` : undefined}>
{emojify(post.data.title)}
</a>
</h2>
<p class="dek">
{emojify(post.data.location)} · {emojify(post.data.verdict)}
</p>
<div class="score">{live && <RobotScore score={post.data.score} />}</div>
</li>
);
})
}
</ul>
</Base>
@@ -107,6 +111,11 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
gap: 10px;
align-items: center;
}
.post-row .meta a {
color: var(--muted);
text-decoration-color: var(--line);
}
.post-row .meta a:hover { color: var(--accent); text-decoration-color: var(--accent); }
.post-row .meta .status {
padding: 1px 7px;
border-radius: var(--radius);

View File

@@ -1,8 +1,13 @@
---
import { getCollection, render } from "astro:content";
import { Image } from "astro:assets";
import Base from "../../layouts/Base.astro";
import Cover from "../../components/Cover.astro";
import RobotScore from "../../components/RobotScore.astro";
import { categorySlug, categoryHue } from "../../lib/category";
import { emojify } from "../../lib/emojify";
import { SITE_AUTHOR_NAME, SITE_AUTHOR_EMAIL } from "../../lib/site";
import { isPublished } from "../../lib/publish";
export async function getStaticPaths() {
const all = await getCollection("reviews");
@@ -16,6 +21,15 @@ const { entry } = Astro.props;
const { Content } = await render(entry);
const d = entry.data;
// Screenshots liggen co-located naast de content (src/assets/reviews/<slug>/screenshot.png),
// niet elke review heeft er per se een (bv. heel oude of handmatig geschreven reviews),
// dus we checken op aanwezigheid en vallen anders terug op de gegenereerde cover.
const screenshots = import.meta.glob<{ default: ImageMetadata }>(
"/src/assets/reviews/*/screenshot.png",
{ eager: true },
);
const screenshot = screenshots[`/src/assets/reviews/${entry.id}/screenshot.png`];
const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
day: "numeric",
month: "long",
@@ -23,27 +37,25 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
});
---
<Base title={d.title} description={d.verdict}>
<Base title={emojify(d.title)} description={emojify(d.verdict)} noindex={!isPublished(d)}>
<article class="review">
<p class="breadcrumb"><a href="/">← Alle reviews</a></p>
<Cover hue={d.coverHue} logo={d.logo} company={d.company} size="hero" />
<h1>{d.title}</h1>
<h1>{emojify(d.title)}</h1>
<div class="review-meta">
{d.date && <span class="tabnum">{dateFormatter.format(d.date)}</span>}
{d.date && <span>·</span>}
<span>{d.category}</span>
<a href={`/categorieen#${categorySlug(d.category)}`}>{emojify(d.category)}</a>
<span>·</span>
<span>{d.location}</span>
<span>{emojify(d.location)}</span>
<span>·</span>
<a href={d.website} target="_blank" rel="noopener">{d.company} →</a>
<a class="company-link" href={d.website} target="_blank" rel="noopener">{emojify(d.company)} →</a>
</div>
<div class="verdict">
<div>
<div class="label">Eindoordeel</div>
<p class="oneliner">{d.verdict}</p>
<p class="oneliner">{emojify(d.verdict)}</p>
</div>
<div class="score-block">
<RobotScore score={d.score} size="lg" showNumber={false} />
@@ -54,18 +66,28 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
<dl class="specs">
<div>
<dt>Voor wie</dt>
<dd>{d.for_who}</dd>
<dd>{emojify(d.for_who)}</dd>
</div>
<div>
<dt>Vanaf</dt>
<dd class="tabnum">{d.price_from}</dd>
<dd class="tabnum">{emojify(d.price_from)}</dd>
</div>
<div>
<dt>Hosting</dt>
<dd>{d.hosting}</dd>
<dd>{emojify(d.hosting)}</dd>
</div>
</dl>
{
screenshot ? (
<a class="hero-shot" href={d.website} target="_blank" rel="noopener">
<Image src={screenshot.default} alt={`Screenshot van ${d.company}`} widths={[400, 800, 1200]} sizes="(min-width: 820px) 772px, 100vw" />
</a>
) : (
<Cover hue={categoryHue(d.category)} logo={d.logo} company={d.company} size="hero" />
)
}
<div class="body">
<Content />
</div>
@@ -81,6 +103,17 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
}
.breadcrumb a { color: var(--muted); }
.hero-shot {
display: block;
margin-bottom: 26px;
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
transition: border-color 0.15s ease;
}
.hero-shot:hover { border-color: var(--accent); }
.hero-shot :global(img) { display: block; width: 100%; height: auto; }
article.review h1 {
font-size: 40px;
margin: 0 0 10px;
@@ -96,6 +129,9 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
gap: 14px;
flex-wrap: wrap;
}
.review-meta a { color: var(--muted); text-decoration-color: var(--line); }
.review-meta a:hover { color: var(--accent); text-decoration-color: var(--accent); }
.review-meta a.company-link { color: var(--accent); text-decoration-color: currentColor; }
.verdict {
background: var(--paper-raised);
@@ -145,9 +181,32 @@ const dateFormatter = new Intl.DateTimeFormat("nl-NL", {
}
.specs dd { margin: 0; font-size: 16px; }
.body { max-width: 64ch; }
.body :global(h2) { font-size: 22px; margin: 42px 0 12px; letter-spacing: -0.005em; }
.body :global(p) { margin: 0 0 17px; }
/* geen max-width op .body zelf: tekstelementen krijgen 'm individueel, zodat
een afbeelding in de lopende tekst wél de volle artikelbreedte kan gebruiken.
p:has(img) sluit specifiek de <p> uit die de markdown-renderer om een losse
afbeelding heen zet (![]() wordt altijd <p><img></p>), anders zou die p z'n
eigen 64ch nog steeds doorgeven aan het kind-element. */
.body :global(h2) { max-width: 64ch; font-size: 22px; margin: 42px 0 12px; letter-spacing: -0.005em; }
.body :global(p) { max-width: 64ch; margin: 0 0 17px; }
.body :global(p:has(img)) { max-width: none; margin: 0 0 20px; }
.body :global(img) {
max-width: 100%;
height: auto;
display: block;
border: 1px solid var(--line);
border-radius: var(--radius);
}
.body :global(blockquote) {
max-width: 64ch;
margin: 0 0 20px;
padding: 2px 20px;
border-left: 3px solid var(--accent);
background: var(--paper-raised);
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--muted);
font-style: italic;
}
.body :global(blockquote p) { margin: 14px 0; }
@media (max-width: 620px) {
.specs { grid-template-columns: 1fr 1fr; }

View File

@@ -36,8 +36,8 @@ import Base from "../layouts/Base.astro";
<input type="text" id="naam" name="naam" maxlength="120" />
</div>
<div class="field">
<label for="email">Je e-mail <span class="opt">(optioneel, voor een reactie)</span></label>
<input type="email" id="email" name="email" maxlength="200" />
<label for="email">Je e-mail <span class="opt">(optioneel)</span></label>
<input type="email" id="email" name="email" maxlength="200" placeholder="voor een reactie" />
</div>
</div>