Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import { esc, LICHT, DONKER } from "./huisstijl.js";
/*
* Het briefpapier van de post, en de opmaak die erop mag.
*
* Elke brief die deze app verstuurt gaat in twee versies weg (zie server/mail.js): de tekst is de
* hoofdzaak — dit is post van een mens — en de HTML is dezelfde woorden op papier. Dat papier stond
* eerst in server/promotie.js en nergens anders; de herstelmail bij "wachtwoord vergeten" ging als
* kale <p>'s de deur uit en de welkomstmail had helemaal geen HTML-versie. Vandaar dit bestand: één
* vel, één opmaak, alle brieven.
*
* Het is hetzelfde vel als de pagina's (server/huisstijl.js): luchtpostrand boven, wit vel op
* papierkleur, hetzelfde palet. Drie dingen gaan anders dan daar, en niet uit luiheid:
*
* * Alles staat inline in de stijl-attributen. Mailclients gooien een <style>-blok weg, en een
* CSS-variabele kent geen van allen.
* * Systeemletters. Een client die /lettertype/… van deze server ophaalt, vertelt daarmee wanneer
* en vanaf welk netwerk de brief geopend werd; dat is een leesbevestiging en die hoort er niet
* stilletjes in te zitten. Een schreefletter voor de brieftekst, want dat is wat de app met een
* brief ook doet, en een schreefloze voor het kleine grut eronder.
* * Geen enkel plaatje. Om dezelfde reden, en omdat dit post hoort te blijven.
*
* De donkere modus is het enige stuk dat wél een <style>-blok nodig heeft: alleen zo is er een
* `prefers-color-scheme` te stellen. Apple Mail en iOS doen daar iets mee, Gmail niet — die keert de
* boel zelf om. Beide gaan goed zolang kleur en achtergrond overal als paar gezet zijn, en dat is
* hier het geval: wie omkeert draait allebei om en houdt het contrast dus intact.
*
* De opmaak is markdown, en met opzet een kleine: **vet**, *cursief*, [tekst](url), een opsomming
* met streepjes, en kale URL's die vanzelf een link worden. Meer niet — koppen en citaten maken er
* een nieuwsbrief van. Wat je typt blijft leesbaar zonder opmaak, want de tekstversie krijgt
* precies de tekens die je intypte.
*/
/* ---------- Markdown ---------- */
/*
* Waar een link heen mag wijzen: het web, de post, en Postmarks eigen {{{ pm:unsubscribe }}} (die
* vullen zij bij het versturen in). Wat daar niet op lijkt — javascript:, data: — blijft gewoon
* tekst staan in plaats van een link te worden.
*/
const VEILIG = /^(?:https?:\/\/|mailto:|\{\{\{)/i;
const NADRUK = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g;
const nadruk = (s) => s.replace(NADRUK, (heel, vet, cursief) =>
vet ? `<strong>${vet}</strong>` : `<em>${cursief}</em>`);
/*
* Wat er in een regel gevonden moet worden vóór de nadruk erop losgelaten wordt: een teken dat met
* een backslash letterlijk is gemaakt, een link met een label, of een kale URL. In die volgorde —
* anders eet de kale URL de binnenkant van een link op. En in één doorloop, want een sterretje in
* een webadres hoort geen cursief te beginnen.
*/
const INLINE = /\\([[\]*\\])|\[([^\]\n]+)\]\(([^)\n]+)\)|(https?:\/\/[^\s<]+)/g;
/*
* Wat aan het eind van een kale URL zit maar er niet bij hoort: een punt, een sluithaakje, een
* aanhalingsteken dat na het ontsnappen " heet.
*/
const STAARTJE = /(?:"|[.,;:!?)])+$/;
const anker = (url, inhoud) => `<a href="${url}" class="b-web" style="color:${LICHT.accent}">${inhoud}</a>`;
const inline = (regel) => {
let uit = "", tot = 0;
for (const m of regel.matchAll(INLINE)) {
const [heel, letterlijkTeken, label, url, kaal] = m;
uit += nadruk(regel.slice(tot, m.index));
tot = m.index + heel.length;
if (letterlijkTeken) uit += letterlijkTeken;
else if (kaal) {
const staart = STAARTJE.exec(kaal)?.[0] ?? "";
const schoon = kaal.slice(0, kaal.length - staart.length);
uit += anker(schoon, schoon) + staart;
} else if (VEILIG.test(url.trim())) uit += anker(url.trim(), nadruk(label));
/* Wijst hij ergens heen waar wij niet heen linken, dan blijft er staan wat er getypt is. Niet
alleen het label: er is dan niets weggemoffeld, en dat is te zien in het voorbeeld. */
else uit += heel;
}
return uit + nadruk(regel.slice(tot));
};
/*
* Een naam die ergens ingevuld wordt, is niet van de schrijver van de brief: iemand die zich
* "[klik](http://…)" noemt hoort geen link in zijn eigen post te krijgen. Zo'n stuk gaat hierlangs
* en komt er letterlijk uit.
*/
export const letterlijk = (s) => String(s ?? "").replace(/[[\]*\\]/g, (ch) => `\\${ch}`);
const LIJST = /^\s*-\s+/;
const S = {
p: "margin:0 0 14px",
ul: "margin:0 0 14px;padding-left:20px",
li: "margin:0 0 9px",
};
/*
* Markdown → de blokken van een brief. Regel voor regel in plaats van per alinea, omdat een
* opsomming even goed midden in een alinea kan beginnen: "Drie dingen:" met daaronder drie
* streepjes is één blok en hoort toch een lijst te worden. Een lege regel sluit een alinea maar
* niet de lijst — zo blijven punten met wit ertussen één opsomming.
*/
export function inhoud(markdown) {
const uit = [];
let alinea = [], lijst = [];
const sluitAlinea = () => {
if (alinea.length) uit.push(`<p style="${S.p}">${alinea.map(inline).join("<br>")}</p>`);
alinea = [];
};
const sluitLijst = () => {
if (lijst.length) uit.push(`<ul style="${S.ul}">${lijst.map((r) => `<li style="${S.li}">${inline(r)}</li>`).join("")}</ul>`);
lijst = [];
};
for (const regel of esc(markdown).split("\n")) {
if (LIJST.test(regel)) { sluitAlinea(); lijst.push(regel.replace(LIJST, "").trim()); continue; }
if (!regel.trim()) { sluitAlinea(); continue; }
sluitLijst();
alinea.push(regel.trim());
}
sluitAlinea();
sluitLijst();
return uit.join("\n");
}
/* ---------- Het vel ---------- */
const SCHREEF = "Georgia,'Times New Roman',Times,serif";
const SCHREEFLOOS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif";
const luchtpost = (gap) =>
`repeating-linear-gradient(-45deg,${LICHT.alert} 0 10px,${gap} 10px 20px,${LICHT.accent} 20px 30px,${gap} 30px 40px)`;
/*
* Het vel zonder omhulsel — ook wat de voorbeeldweergave op /promotieteam laat zien, want "zo gaat
* hij eruit" hoort letterlijk hetzelfde te zijn als wat eruit gaat.
*
* De rand is een verloop met een effen kleur eronder: clients die geen verloop tekenen (oudere
* Outlook) houden een blauwe streep over in plaats van een gat.
*/
export const vel = (markdown, staart = "") => `<div class="b-vel" style="max-width:600px;margin:0 auto;background-color:${LICHT.panel}">
<div class="b-rand" style="height:6px;line-height:6px;font-size:0;background-color:${LICHT.accent};background-image:${luchtpost(LICHT.panel)}"> </div>
<div class="b-ink" style="padding:28px 26px 34px;font-family:${SCHREEF};font-size:16px;line-height:1.62;color:${LICHT.ink};background-color:${LICHT.panel}">
${inhoud(markdown)}${staart ? `
<div class="b-lijn b-stil" style="margin:26px 0 0;padding-top:14px;border-top:1px solid ${LICHT.rule};font-family:${SCHREEFLOOS};font-size:13.5px;line-height:1.55;color:${LICHT.mute}">
${inhoud(staart)}
</div>` : ""}
</div>
</div>`;
/*
* De omslag. `color-scheme` en `supported-color-schemes` vertellen de client dat deze brief allebei
* de standen aankan — zonder dat gaan sommige clients zelf lopen omkeren terwijl het niet nodig is.
*/
export const brief = (markdown, staart = "") => `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<style>
@media (prefers-color-scheme: dark) {
.b-papier { background-color: ${DONKER.paper} !important; }
.b-vel, .b-ink { background-color: ${DONKER.panel} !important; }
.b-ink { color: ${DONKER.ink} !important; }
.b-rand { background-color: ${DONKER.accent} !important; background-image: ${luchtpost(DONKER.panel)} !important; }
.b-lijn { border-top-color: ${DONKER.rule} !important; }
.b-stil { color: ${DONKER.mute} !important; }
.b-web { color: ${DONKER.accent} !important; }
}
</style>
</head>
<body class="b-papier" style="margin:0;padding:24px 12px;background-color:${LICHT.paper}">
${vel(markdown, staart)}
</body>
</html>`;