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:
14
Makefile
14
Makefile
@@ -1,6 +1,9 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: review
|
||||
DEPLOY_HOST := git.b10m.net
|
||||
DEPLOY_PATH := /var/www/aitestbank/dist/
|
||||
|
||||
.PHONY: review deploy
|
||||
|
||||
# Screenshot + AI-conceptreview maken en meteen in vim openen om verder te schrijven.
|
||||
# Gebruik: make review URL=https://voorbeeld.nl [SLUG=voorbeeld]
|
||||
@@ -16,3 +19,12 @@ review:
|
||||
@export NVM_DIR="$$HOME/.nvm"; \
|
||||
if [ -s "$$NVM_DIR/nvm.sh" ]; then . "$$NVM_DIR/nvm.sh"; nvm use >/dev/null; fi; \
|
||||
FILE=$$(node --env-file-if-exists=.env scripts/new-review.mjs "$(URL)" $(SLUG)) && vim "$$FILE"
|
||||
|
||||
# Bouwt de site en rsynct 'm naar de server. Raakt geen git aan (committen/
|
||||
# pushen doe je zelf) — dit is puur build + deploy.
|
||||
deploy:
|
||||
@export NVM_DIR="$$HOME/.nvm"; \
|
||||
if [ -s "$$NVM_DIR/nvm.sh" ]; then . "$$NVM_DIR/nvm.sh"; nvm use >/dev/null; fi; \
|
||||
rm -rf node_modules/.astro .astro dist; \
|
||||
npm run build
|
||||
rsync -avz --delete dist/ $(DEPLOY_HOST):$(DEPLOY_PATH)
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { defineConfig } from "astro/config";
|
||||
import remarkEmoji from "remark-emoji";
|
||||
import remarkFlagEmoji from "./src/lib/remark-flag-emoji.mjs";
|
||||
|
||||
export default defineConfig({
|
||||
site: "https://aitestbank.nl",
|
||||
output: "static",
|
||||
markdown: {
|
||||
remarkPlugins: [remarkEmoji, remarkFlagEmoji],
|
||||
},
|
||||
});
|
||||
|
||||
1199
package-lock.json
generated
1199
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -12,10 +12,14 @@
|
||||
"new-review": "node --env-file-if-exists=.env scripts/new-review.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^7.0.0"
|
||||
"@astrojs/markdown-remark": "^7.2.1",
|
||||
"astro": "^7.0.0",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"node-emoji": "^2.2.0",
|
||||
"remark-emoji": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.48.0",
|
||||
"@anthropic-ai/sdk": "^0.32.0"
|
||||
"@anthropic-ai/sdk": "^0.32.0",
|
||||
"playwright": "^1.48.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,22 @@
|
||||
*
|
||||
* Wat het doet:
|
||||
* 1. Screenshot van de site (Playwright) -> src/assets/reviews/<slug>/screenshot.png
|
||||
* (de reviewpagina toont deze automatisch als hero bovenaan, gelinkt naar de
|
||||
* website, zodra het bestand bestaat — zie reviews/[slug].astro)
|
||||
* 2. Titel/meta/zichtbare tekst van de site scrapen
|
||||
* 3. Claude (Haiku) laten samenvatten tot titel/bedrijfsnaam/categorie/doelgroep/prijsindicatie
|
||||
* 3. Claude (Haiku) laten samenvatten tot titel/bedrijfsnaam/categorie (één woord,
|
||||
* bepaalt ook de coverkleur en de sectie op /categorieen)/doelgroep/prijsindicatie
|
||||
* — met expliciete instructie om niets te verzinnen, onbekend = leeg veld
|
||||
* 4. Nieuw src/content/reviews/<slug>.md wegschrijven vanuit _template.md, met die velden ingevuld
|
||||
* 4. Claude laat ook een concepttekst schrijven voor de secties "Voor wie",
|
||||
* "Wie zit erachter" en "Prijs" (gebaseerd op de site-tekst, gemarkeerd
|
||||
* als AI-concept)
|
||||
* 5. Nieuw src/content/reviews/<slug>.md wegschrijven vanuit _template.md, met die velden ingevuld
|
||||
*
|
||||
* Wat het BEWUST NIET automatisch invult:
|
||||
* score, verdict, hands-on indruk — dat is jouw eigen testervaring na het zelf proberen,
|
||||
* niet iets wat een script (of AI die de website leest) kan beoordelen.
|
||||
* Alle AI-ingevulde velden krijgen een "# AI-concept, controleren!"-comment: check ze na.
|
||||
* score, verdict, "Wat is het" en "Hands-on indruk" — dat is jouw eigen testervaring
|
||||
* na het zelf proberen, niet iets wat een script (of AI die de website leest) kan
|
||||
* beoordelen. Alle AI-ingevulde velden/secties krijgen een "AI-concept, controleren!"-
|
||||
* markering: check ze na.
|
||||
*/
|
||||
import { chromium } from "playwright";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
@@ -46,6 +53,15 @@ function fillField(template, field, value) {
|
||||
return template.replace(re, `${field}: ${JSON.stringify(value)} # AI-concept, controleren!`);
|
||||
}
|
||||
|
||||
function fillSection(template, heading, placeholderComment, prose) {
|
||||
// Zelfde idee als fillField, maar dan voor een hele body-sectie: als er niets
|
||||
// gevonden is, blijft de originele hint-comment gewoon staan voor handmatig invullen.
|
||||
if (!prose) return template;
|
||||
const search = `## ${heading}\n\n<!-- ${placeholderComment} -->`;
|
||||
const replacement = `## ${heading}\n\n${prose}\n\n<!-- AI-concept, controleren! -->`;
|
||||
return template.replace(search, replacement);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [, , urlArg, slugArg] = process.argv;
|
||||
if (!urlArg) {
|
||||
@@ -111,7 +127,7 @@ async function main() {
|
||||
|
||||
const extraction = await anthropic.messages.create({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 1024,
|
||||
max_tokens: 2048,
|
||||
tools: [
|
||||
{
|
||||
name: "record_tool_info",
|
||||
@@ -130,7 +146,8 @@ async function main() {
|
||||
},
|
||||
category: {
|
||||
type: "string",
|
||||
description: "Korte Nederlandse categorie, bv. 'Klantenservice', 'Boekhouding', 'Marketing'",
|
||||
description:
|
||||
"Categorie in ÉÉN Nederlands woord (bepaalt ook de sectie op de categorieënpagina), bv. 'Klantenservice', 'Boekhouding', 'Marketing', 'Juridisch'. Geen meerwoordige omschrijvingen.",
|
||||
},
|
||||
for_who: { type: "string", description: "Korte typering van de doelgroep, gebaseerd op de site-tekst" },
|
||||
price_from: {
|
||||
@@ -143,8 +160,34 @@ async function main() {
|
||||
description:
|
||||
"Korte typering van hosting/privacy ALLEEN als expliciet vermeld (bv. 'EU, GDPR-verklaring'), anders lege string",
|
||||
},
|
||||
for_who_prose: {
|
||||
type: "string",
|
||||
description:
|
||||
"1-3 zinnen Nederlandse lopende tekst voor de sectie 'Voor wie': welk type gebruiker/bedrijf dit relevant vindt, en voor wie juist niet. Gebaseerd op de site-tekst, niet verzinnen. Lege string als er te weinig te zeggen valt.",
|
||||
},
|
||||
company_info_prose: {
|
||||
type: "string",
|
||||
description:
|
||||
"1-3 zinnen Nederlandse lopende tekst voor de sectie 'Wie zit erachter': team/locatie/funding/omzet ALLEEN als dat ergens op de site staat. Lege string als er niets over te vinden is (niet gokken of aanvullen met algemene kennis).",
|
||||
},
|
||||
price_prose: {
|
||||
type: "string",
|
||||
description:
|
||||
"1-3 zinnen Nederlandse lopende tekst voor de sectie 'Prijs': iets uitgebreider dan een los bedrag, bv. tiers, gratis laag, wat er wel/niet in zit — ALLEEN gebaseerd op wat er echt op de site staat. Lege string als er geen prijsinformatie te vinden is.",
|
||||
},
|
||||
},
|
||||
required: ["title", "company", "location", "category", "for_who", "price_from", "hosting"],
|
||||
required: [
|
||||
"title",
|
||||
"company",
|
||||
"location",
|
||||
"category",
|
||||
"for_who",
|
||||
"price_from",
|
||||
"hosting",
|
||||
"for_who_prose",
|
||||
"company_info_prose",
|
||||
"price_prose",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -153,8 +196,12 @@ async function main() {
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Hier is de inhoud van een bedrijfswebsite. Haal er alleen op wat er ECHT letterlijk staat, " +
|
||||
"verzin nooit iets. Onbekende/niet-vermelde velden: lege string, niet gokken.\n\n" +
|
||||
"Hier is de inhoud van een bedrijfswebsite, voor een conceptreview op een AI-tool-blog. " +
|
||||
"Haal er alleen op wat er ECHT letterlijk staat, verzin nooit feiten (prijzen, locatie, " +
|
||||
"team). Onbekende/niet-vermelde velden: lege string, niet gokken. Voor de lopende-tekst-" +
|
||||
"velden (for_who_prose/company_info_prose/price_prose) mag je de site-tekst wel in eigen " +
|
||||
"woorden samenvatten, in de rustige/nuchtere toon van de rest van de site — maar blijf bij " +
|
||||
"wat de site zelf claimt, voeg geen eigen mening of hands-on-oordeel toe.\n\n" +
|
||||
`Paginatitel: ${pageTitle}\n` +
|
||||
`Meta-omschrijving: ${metaDescription ?? ""}\n\n` +
|
||||
`Zichtbare tekst van de pagina:\n${bodyText}`,
|
||||
@@ -179,10 +226,17 @@ async function main() {
|
||||
// template zet ze op respectievelijk "" en 0 als leesbaar voorbeeld voor de
|
||||
// handmatige workflow, maar allebei zijn dat *ongeldige* waarden voor het
|
||||
// Zod-schema (date: "" faalt de date-coercion, score: 0 faalt min(1)) — dus
|
||||
// hier de regels helemaal weghalen, zodat het veld echt afwezig is en de
|
||||
// .optional() in het schema z'n werk kan doen.
|
||||
content = content.replace(/^date: ""\s*#.*\n/m, "");
|
||||
content = content.replace(/^score: 0\s*#.*\n/m, "");
|
||||
// hier uitcommentariëren i.p.v. laten staan, zodat het veld echt afwezig is
|
||||
// (de .optional() in het schema kan er dan mee uit de voeten) maar er wél
|
||||
// zichtbaar blijft staan HOE je 'm moet invullen zodra je gaat publiceren.
|
||||
content = content.replace(
|
||||
/^date: ""(\s*#.*)?$/m,
|
||||
"# date: 2026-01-01 # invullen zodra je draft: false zet",
|
||||
);
|
||||
content = content.replace(
|
||||
/^score: 0(\s*#.*)?$/m,
|
||||
"# score: 3 # geheel getal 1-5 (aantal robots) — invullen vóórdat je draft: false zet",
|
||||
);
|
||||
|
||||
content = content.replace(/^website: ""(\s*#.*)?$/m, `website: ${JSON.stringify(url)}`);
|
||||
content = fillField(content, "title", info.title);
|
||||
@@ -193,16 +247,33 @@ async function main() {
|
||||
content = fillField(content, "price_from", info.price_from);
|
||||
content = fillField(content, "hosting", info.hosting);
|
||||
|
||||
const companyForAlt = info.company || slug;
|
||||
content = content.replace(
|
||||
"## Wat is het\n\n<!--",
|
||||
`## Wat is het\n\n\n\n<!--`,
|
||||
content = fillSection(
|
||||
content,
|
||||
"Voor wie",
|
||||
"Voor welk type gebruiker/bedrijf is dit relevant, en voor wie juist niet.",
|
||||
info.for_who_prose,
|
||||
);
|
||||
content = fillSection(
|
||||
content,
|
||||
"Wie zit erachter",
|
||||
"Team, locatie, funding/omzet indien bekend. Onbekend? Dat gewoon zo benoemen.",
|
||||
info.company_info_prose,
|
||||
);
|
||||
content = fillSection(
|
||||
content,
|
||||
"Prijs",
|
||||
"Prijsmodel iets uitgebreider dan het specs-balkje: tiers, gratis laag, addertjes onder het gras.",
|
||||
info.price_prose,
|
||||
);
|
||||
|
||||
// Geen screenshot-markdown meer in de body: de reviewpagina toont 'm zelf al
|
||||
// automatisch als hero bovenaan (gelinkt naar de website) zodra
|
||||
// src/assets/reviews/<slug>/screenshot.png bestaat, zie reviews/[slug].astro.
|
||||
|
||||
await writeFile(contentPath, content, "utf-8");
|
||||
console.error(`Concept aangemaakt: ${path.relative(projectRoot, contentPath)}`);
|
||||
console.error(
|
||||
"Nog met de hand te doen: elk 'AI-concept, controleren!'-veld naast lezen, en zelf score/verdict/hands-on indruk schrijven na het testen.",
|
||||
"Nog met de hand te doen: elk 'AI-concept, controleren!'-veld/sectie naast lezen, en zelf 'Wat is het', score, verdict en hands-on indruk schrijven na het testen.",
|
||||
);
|
||||
|
||||
// enige stdout-regel, bedoeld om door scripts (bv. de Makefile) opgepakt te worden
|
||||
|
||||
@@ -12,9 +12,11 @@ import sys
|
||||
from email.message import EmailMessage
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
# TODO: zet hier het gewenste ontvangstadres
|
||||
TO_ADDR = "mjblom@gmail.com"
|
||||
FROM_ADDR = "aitestbank@b10m.net"
|
||||
# aitestbank.nl is als virtual domain toegevoegd aan de Postfix op dezelfde
|
||||
# server (@aitestbank.nl -> lokale user menno), dus dit blijft nu lokale
|
||||
# aflevering, geen extern mailverkeer.
|
||||
TO_ADDR = "menno@aitestbank.nl"
|
||||
FROM_ADDR = "voorstellen@aitestbank.nl"
|
||||
MAX_BODY_BYTES = 20_000
|
||||
|
||||
|
||||
|
||||
BIN
src/assets/reviews/fiskr/screenshot.png
Normal file
BIN
src/assets/reviews/fiskr/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
BIN
src/assets/reviews/lawsy/screenshot.png
Normal file
BIN
src/assets/reviews/lawsy/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 487 KiB |
@@ -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;
|
||||
|
||||
@@ -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
22
src/lib/category.ts
Normal 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
17
src/lib/emojify.ts
Normal 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
10
src/lib/publish.ts
Normal 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();
|
||||
}
|
||||
23
src/lib/remark-flag-emoji.mjs
Normal file
23
src/lib/remark-flag-emoji.mjs
Normal 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
4
src/lib/site.ts
Normal 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
124
src/pages/categorieen.astro
Normal 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user