diff --git a/app/pages/api-docs.vue b/app/pages/api-docs.vue index 025adf8..aac5ad5 100644 --- a/app/pages/api-docs.vue +++ b/app/pages/api-docs.vue @@ -86,8 +86,10 @@ const publicEndpoints = [ { method: 'POST', path: '/api/service/waitlist', description: 'Person auf die Warteliste setzen. Erfordert Zustimmung zu AGB & Datenschutz.' }, { method: 'GET', path: '/api/service/waitlist', description: 'Aggregierte Wartelistenstatistiken mit Gesamtanzahl, Wachstum und sichtbarem Puffer abrufen.' }, + { method: 'POST', path: '/api/service/updates', description: 'E-Mail für Produkt-Updates und neue Features eintragen (Einwilligung erforderlich).' }, { method: 'GET', path: '/api/service/roadmap', description: 'Roadmap-Items inklusive Community-Durchschnitt, Gesamtstimmen und letzter Aktivität auslesen.' }, { method: 'POST', path: '/api/service/roadmap', description: 'Wichtigkeit (1–5) für einzelne Roadmap-Punkte voten; speichert jeden Vote mit Zeitstempel.' }, + { method: 'POST', path: '/api/service/roadmap-suggestions', description: 'Neuen Roadmap-Vorschlag mit optionaler Kontaktadresse einreichen.' }, { method: 'POST', path: '/api/service/auth/login', description: 'Login mit E-Mail & Passwort. Gibt JWT zurück und setzt Refresh-Cookie.' }, { method: 'POST', path: '/api/service/auth/register', description: 'Registrierung mit Einladungscode und Einwilligungen.' }, { method: 'POST', path: '/api/service/auth/refresh', description: 'Access-Token anhand des Refresh-Cookies erneuern.' }, diff --git a/app/pages/datenschutz.vue b/app/pages/datenschutz.vue index 2f57350..c6a9982 100644 --- a/app/pages/datenschutz.vue +++ b/app/pages/datenschutz.vue @@ -21,7 +21,9 @@

2. Verarbeitete Daten

@@ -33,7 +35,7 @@ @@ -41,6 +43,8 @@

4. Speicherdauer

@@ -51,6 +55,9 @@

Wir hosten OpenSquawk auf europäischen Cloud-Plattformen (derzeit Hetzner Cloud, Deutschland). Kommunikationsdaten werden in unserer MongoDB-Datenbank gespeichert. Externe KI-Dienstleister (z. B. OpenAI) erhalten ausschließlich pseudonymisierte Texte zur Verarbeitung von TTS/LLM-Funktionen. Es gelten entsprechende Auftragsverarbeitungsverträge. Eine Übermittlung in Drittstaaten erfolgt nur unter Nutzung von EU-Standardvertragsklauseln.

+

+ Hinweis: Formularübermittlungen (Warteliste, Feature-Benachrichtigungen, Roadmap-Vorschläge) lösen eine interne Benachrichtigungs-E-Mail an opensquawk@faktorxmensch.com aus, versendet über den konfigurierten SMTP- oder Transaktionsmail-Anbieter. Enthalten sind ausschließlich die von dir eingegebenen Angaben zur zügigen Bearbeitung. +

@@ -75,7 +82,7 @@ diff --git a/app/pages/news/[slug].vue b/app/pages/news/[slug].vue new file mode 100644 index 0000000..78c548a --- /dev/null +++ b/app/pages/news/[slug].vue @@ -0,0 +1,73 @@ + + + + + diff --git a/app/pages/news/index.vue b/app/pages/news/index.vue new file mode 100644 index 0000000..67e34f5 --- /dev/null +++ b/app/pages/news/index.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/content/news/alpha-prototype.md b/content/news/alpha-prototype.md new file mode 100644 index 0000000..4c1f6b9 --- /dev/null +++ b/content/news/alpha-prototype.md @@ -0,0 +1,33 @@ +--- +title: "Alpha-Prototyp bereit für frühe Tests" +date: "2025-01-20" +summary: "Unser erster OpenSquawk Alpha-Build läuft lokal für MSFS und zeigt, wohin die Reise Richtung community-getriebener AI-ATC geht." +--- + +Wir haben den Alpha-Prototyp von OpenSquawk intern stabilisiert und geben ihn jetzt an neugierige Mitentwickler:innen raus. Der Build kombiniert eine Nuxt-Oberfläche mit Node-Services für Speech-to-Text, Entscheidungslogik und TTS. Alles lässt sich lokal betreiben, solange du ein wenig Terminal-Erfahrung mitbringst. + +## Was dich erwartet + +- Self-host Setup: `yarn install`, `.env` befüllen, danach `yarn dev` oder `docker compose up` für das volle Paket. +- Simulator-Fokus: Microsoft Flight Simulator (MSFS) zuerst, X-Plane steht bereits in der Konzeptphase. +- Zielbild: Community-driven Features, damit Trainingsrichtung VATSIM realitätsnah bleibt und Hosting langfristig bezahlbar wird. + +## Startvoraussetzungen + +- Grundkenntnisse in Node/Nuxt (Logs lesen, Pakete aktualisieren, `.env` konfigurieren). +- Zugriff auf MSFS (PC) für die ersten Funkexperimente. +- Bereitschaft, Issues zu dokumentieren und kleine Tweaks per Pull Request zu liefern. + +## Wie du helfen kannst + +- Teste den Alpha-Build und melde Findings als Issues – am besten mit Logs oder kurzen Screenshots. +- Schau in die Issues labeled `help-wanted`, dort liegen konkrete Aufgaben für Node/Nuxt Devs, ATC SMEs, Tester:innen und Infra-/Kosten-Benchmarking. +- Teile eigene Feature-Ideen direkt in der Roadmap oder schreib uns via [opensquawk@faktorxmensch.com](mailto:opensquawk@faktorxmensch.com). + +## Nächste Schritte + +- X-Plane Plugin als nächstes Milestone. +- Lernpfad-Module iterieren (Ground → Departure → Arrival → VATSIM). +- Hosting-Kosten transparent benchmarken und im Blog teilen. + +Danke für jedes Feedback – gemeinsam bringen wir OpenSquawk Richtung „Open-source, low-cost AI ATC für Flugsimulatoren". diff --git a/server/api/service/roadmap-suggestions.post.ts b/server/api/service/roadmap-suggestions.post.ts new file mode 100644 index 0000000..2ca0108 --- /dev/null +++ b/server/api/service/roadmap-suggestions.post.ts @@ -0,0 +1,60 @@ +import { readBody, createError } from 'h3' +import { RoadmapSuggestion } from '../../models/RoadmapSuggestion' +import { sendAdminNotification } from '../../utils/notifications' + +interface RoadmapSuggestionBody { + title?: string + details?: string + email?: string + allowContact?: boolean + consentPrivacy?: boolean +} + +const MIN_TITLE_LENGTH = 4 +const MIN_DETAILS_LENGTH = 20 + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const title = body.title?.trim() + const details = body.details?.trim() + const email = body.email?.trim().toLowerCase() + const allowContact = Boolean(body.allowContact && email) + + if (!title || title.length < MIN_TITLE_LENGTH) { + throw createError({ statusCode: 400, statusMessage: 'Bitte gib einen kurzen Titel (mindestens 4 Zeichen) an.' }) + } + + if (!details || details.length < MIN_DETAILS_LENGTH) { + throw createError({ statusCode: 400, statusMessage: 'Beschreibe deinen Vorschlag mit mindestens 20 Zeichen.' }) + } + + if (!body.consentPrivacy) { + throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung zur Datenschutzerklärung.' }) + } + + if (body.allowContact && !email) { + throw createError({ statusCode: 400, statusMessage: 'Bitte gib eine E-Mail an, damit wir dich kontaktieren können.' }) + } + + const suggestion = await RoadmapSuggestion.create({ + title, + details, + email, + allowContact, + consentPrivacy: true, + }) + + const lines = [ + `Titel: ${title}`, + `Beschreibung: ${details}`, + `Kontakt: ${email || '—'}`, + allowContact ? 'Kontaktaufnahme erwünscht' : 'Keine Kontaktaufnahme erwünscht', + ] + + await sendAdminNotification('[OpenSquawk] Neuer Roadmap-Vorschlag', lines.join('\n')) + + return { + success: true, + suggestionId: suggestion._id.toString(), + } +}) diff --git a/server/api/service/updates.post.ts b/server/api/service/updates.post.ts new file mode 100644 index 0000000..a3fcf97 --- /dev/null +++ b/server/api/service/updates.post.ts @@ -0,0 +1,52 @@ +import { readBody, createError } from 'h3' +import { registerUpdateSubscriber } from '../../utils/subscribers' +import { sendAdminNotification } from '../../utils/notifications' + +interface UpdatesRequestBody { + email?: string + name?: string + consentPrivacy?: boolean + consentMarketing?: boolean + source?: string +} + +export default defineEventHandler(async (event) => { + const body = await readBody(event) + const email = body.email?.trim().toLowerCase() + const name = body.name?.trim() + const source = body.source?.trim() || 'landing-updates' + + if (!email) { + throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' }) + } + + if (!body.consentPrivacy) { + throw createError({ statusCode: 400, statusMessage: 'Bitte bestätige die Datenschutzerklärung' }) + } + + if (!body.consentMarketing) { + throw createError({ statusCode: 400, statusMessage: 'Wir benötigen deine Einwilligung für Produkt-Updates per E-Mail' }) + } + + const result = await registerUpdateSubscriber({ + email, + name, + source, + consentPrivacy: true, + consentMarketing: true, + }) + + if (result.created) { + const lines = [ + `E-Mail: ${email}`, + name ? `Name: ${name}` : null, + `Quelle: ${source}`, + ].filter(Boolean) + await sendAdminNotification('[OpenSquawk] Neue Updates-Liste', lines.join('\n')) + } + + return { + success: true, + alreadySubscribed: !result.created, + } +}) diff --git a/server/api/service/waitlist.post.ts b/server/api/service/waitlist.post.ts index afc8771..f46b06f 100644 --- a/server/api/service/waitlist.post.ts +++ b/server/api/service/waitlist.post.ts @@ -1,5 +1,7 @@ import { readBody, createError } from 'h3' import { WaitlistEntry } from '../../models/WaitlistEntry' +import { sendAdminNotification } from '../../utils/notifications' +import { registerUpdateSubscriber } from '../../utils/subscribers' interface WaitlistRequestBody { email?: string @@ -8,6 +10,7 @@ interface WaitlistRequestBody { consentPrivacy?: boolean consentTerms?: boolean source?: string + wantsProductUpdates?: boolean } export default defineEventHandler(async (event) => { @@ -16,6 +19,7 @@ export default defineEventHandler(async (event) => { const name = body.name?.trim() const notes = body.notes?.trim() const source = body.source?.trim() || 'landing' + const wantsProductUpdates = Boolean(body.wantsProductUpdates) if (!email) { throw createError({ statusCode: 400, statusMessage: 'E-Mail wird benötigt' }) @@ -30,12 +34,41 @@ export default defineEventHandler(async (event) => { const existing = await WaitlistEntry.findOne({ email }) if (existing) { + const previouslyWantedUpdates = Boolean(existing.wantsProductUpdates) existing.name = name || existing.name existing.notes = notes || existing.notes existing.source = source existing.consentPrivacy = true existing.consentTerms = true + if (wantsProductUpdates && !previouslyWantedUpdates) { + existing.wantsProductUpdates = true + existing.updatesOptedInAt = now + } await existing.save() + + if (wantsProductUpdates) { + const updateResult = await registerUpdateSubscriber({ + email, + name, + source: `${source}-waitlist`, + consentPrivacy: true, + consentMarketing: true, + }) + + if (!previouslyWantedUpdates && updateResult.created) { + const lines = [ + `E-Mail: ${email}`, + name ? `Name: ${name}` : null, + notes ? `Notizen: ${notes}` : null, + `Quelle: ${source}`, + ].filter(Boolean) + await sendAdminNotification( + '[OpenSquawk] Neue Updates-Liste (via Warteliste)', + `${lines.join('\n')}\nOpt-In: Produkt-Updates`, + ) + } + } + return { success: true, alreadyJoined: true, @@ -51,8 +84,29 @@ export default defineEventHandler(async (event) => { consentPrivacy: true, consentTerms: true, joinedAt: now, + wantsProductUpdates, + updatesOptedInAt: wantsProductUpdates ? now : undefined, }) + if (wantsProductUpdates) { + await registerUpdateSubscriber({ + email, + name, + source: `${source}-waitlist`, + consentPrivacy: true, + consentMarketing: true, + }) + } + + const lines = [ + `E-Mail: ${email}`, + name ? `Name: ${name}` : null, + notes ? `Notizen: ${notes}` : null, + `Quelle: ${source}`, + wantsProductUpdates ? 'Opt-In: Produkt-Updates' : 'Opt-In: nur Warteliste', + ].filter(Boolean) + await sendAdminNotification('[OpenSquawk] Neue Wartelisten-Anmeldung', lines.join('\n')) + return { success: true, alreadyJoined: false, diff --git a/server/models/RoadmapSuggestion.ts b/server/models/RoadmapSuggestion.ts new file mode 100644 index 0000000..86c9524 --- /dev/null +++ b/server/models/RoadmapSuggestion.ts @@ -0,0 +1,25 @@ +import mongoose from 'mongoose' + +export interface RoadmapSuggestionDocument extends mongoose.Document { + title: string + details: string + email?: string + allowContact: boolean + consentPrivacy: boolean + submittedAt: Date +} + +const roadmapSuggestionSchema = new mongoose.Schema({ + title: { type: String, required: true, trim: true, maxlength: 160 }, + details: { type: String, required: true, trim: true, maxlength: 4000 }, + email: { type: String, trim: true, lowercase: true }, + allowContact: { type: Boolean, default: false }, + consentPrivacy: { type: Boolean, default: false }, + submittedAt: { type: Date, default: () => new Date() }, +}) + +roadmapSuggestionSchema.index({ submittedAt: -1 }) + +export const RoadmapSuggestion = + (mongoose.models.RoadmapSuggestion as mongoose.Model | undefined) || + mongoose.model('RoadmapSuggestion', roadmapSuggestionSchema) diff --git a/server/models/UpdateSubscriber.ts b/server/models/UpdateSubscriber.ts new file mode 100644 index 0000000..e34b349 --- /dev/null +++ b/server/models/UpdateSubscriber.ts @@ -0,0 +1,32 @@ +import mongoose from 'mongoose' + +export interface UpdateSubscriberDocument extends mongoose.Document { + email: string + name?: string + source: string + consentPrivacy: boolean + consentMarketing: boolean + subscribedAt: Date + lastUpdatedAt: Date +} + +const updateSubscriberSchema = new mongoose.Schema({ + email: { type: String, required: true, lowercase: true, trim: true, unique: true }, + name: { type: String, trim: true }, + source: { type: String, default: 'landing-updates' }, + consentPrivacy: { type: Boolean, default: false }, + consentMarketing: { type: Boolean, default: false }, + subscribedAt: { type: Date, default: () => new Date() }, + lastUpdatedAt: { type: Date, default: () => new Date() }, +}) + +updateSubscriberSchema.pre('save', function updateTimestamp(this: UpdateSubscriberDocument, next) { + if (this.isModified()) { + this.set('lastUpdatedAt', new Date()) + } + next() +}) + +export const UpdateSubscriber = + (mongoose.models.UpdateSubscriber as mongoose.Model | undefined) || + mongoose.model('UpdateSubscriber', updateSubscriberSchema) diff --git a/server/models/WaitlistEntry.ts b/server/models/WaitlistEntry.ts index ac0bebc..c8dd27b 100644 --- a/server/models/WaitlistEntry.ts +++ b/server/models/WaitlistEntry.ts @@ -9,6 +9,8 @@ export interface WaitlistEntryDocument extends mongoose.Document { consentTerms: boolean joinedAt: Date activatedAt?: Date + wantsProductUpdates?: boolean + updatesOptedInAt?: Date } const waitlistSchema = new mongoose.Schema({ @@ -20,6 +22,8 @@ const waitlistSchema = new mongoose.Schema({ consentTerms: { type: Boolean, default: false }, joinedAt: { type: Date, default: () => new Date() }, activatedAt: { type: Date }, + wantsProductUpdates: { type: Boolean, default: false }, + updatesOptedInAt: { type: Date }, }) export const WaitlistEntry = diff --git a/server/utils/notifications.ts b/server/utils/notifications.ts new file mode 100644 index 0000000..8eaf7d8 --- /dev/null +++ b/server/utils/notifications.ts @@ -0,0 +1,103 @@ +const ADMIN_EMAIL_FALLBACK = 'opensquawk@faktorxmensch.com' + +const RESEND_ENDPOINT = 'https://api.resend.com/emails' + +async function sendViaResend(subject: string, text: string) { + const apiKey = process.env.NOTIFY_RESEND_API_KEY + if (!apiKey) { + return false + } + + const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK + const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk ' + + try { + const response = await fetch(RESEND_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from, + to, + subject, + text, + }), + }) + + if (!response.ok) { + const errorText = await response.text().catch(() => '') + console.error('Failed to send notification via Resend API', response.status, errorText) + return false + } + + return true + } catch (error) { + console.error('Error while sending notification via Resend API', error) + return false + } +} + +async function sendViaSmtp(subject: string, text: string) { + const host = process.env.NOTIFY_SMTP_HOST + if (!host) { + return false + } + + const user = process.env.NOTIFY_SMTP_USER + const pass = process.env.NOTIFY_SMTP_PASS + if (!user || !pass) { + console.warn('SMTP notification is configured without credentials – skipping send.') + return false + } + + const port = Number.parseInt(process.env.NOTIFY_SMTP_PORT || '', 10) + const secure = process.env.NOTIFY_SMTP_SECURE === 'true' + + let nodemailer: any = null + try { + const module = await import('nodemailer') + nodemailer = module.default ?? module + } catch (error) { + console.warn('nodemailer is not available. Skipping SMTP notification.', error) + return false + } + + const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK + const from = process.env.NOTIFY_EMAIL_FROM || 'OpenSquawk ' + + try { + const transporter = nodemailer.createTransport({ + host, + port: Number.isNaN(port) ? secure ? 465 : 587 : port, + secure, + auth: { + user, + pass, + }, + }) + + await transporter.sendMail({ from, to, subject, text }) + return true + } catch (error) { + console.error('Failed to send notification via SMTP', error) + return false + } +} + +export async function sendAdminNotification(subject: string, text: string) { + const sentViaResend = await sendViaResend(subject, text) + if (sentViaResend) { + return true + } + + const sentViaSmtp = await sendViaSmtp(subject, text) + if (sentViaSmtp) { + return true + } + + const to = process.env.NOTIFY_EMAIL_TO || ADMIN_EMAIL_FALLBACK + console.info(`[notify:fallback] ${subject}\nEmpfänger: ${to}\n${text}`) + return false +} diff --git a/server/utils/subscribers.ts b/server/utils/subscribers.ts new file mode 100644 index 0000000..e2eaf0e --- /dev/null +++ b/server/utils/subscribers.ts @@ -0,0 +1,38 @@ +import { UpdateSubscriber, UpdateSubscriberDocument } from '../models/UpdateSubscriber' + +interface RegisterSubscriberOptions { + email: string + name?: string + source?: string + consentPrivacy: boolean + consentMarketing: boolean +} + +export async function registerUpdateSubscriber(options: RegisterSubscriberOptions) { + const { email, name, source = 'landing-updates', consentPrivacy, consentMarketing } = options + const now = new Date() + + const existing = await UpdateSubscriber.findOne({ email }) + + if (existing) { + existing.name = name || existing.name + existing.source = source || existing.source + existing.consentPrivacy = existing.consentPrivacy || consentPrivacy + existing.consentMarketing = existing.consentMarketing || consentMarketing + existing.lastUpdatedAt = now + await existing.save() + return { created: false, document: existing as UpdateSubscriberDocument } + } + + const document = await UpdateSubscriber.create({ + email, + name, + source, + consentPrivacy, + consentMarketing, + subscribedAt: now, + lastUpdatedAt: now, + }) + + return { created: true, document } +} diff --git a/shared/utils/news.ts b/shared/utils/news.ts new file mode 100644 index 0000000..a4da2bb --- /dev/null +++ b/shared/utils/news.ts @@ -0,0 +1,238 @@ +export interface NewsPost { + slug: string + title: string + excerpt: string + publishedAt: string + body: string + html: string + readingTime: string +} + +interface FrontMatter { + title?: string + date?: string + summary?: string + excerpt?: string + description?: string + readingTime?: string +} + +const rawNewsModules = import.meta.glob('~~/content/news/*.md', { + query: '?raw', + import: 'default', + eager: true, +}) as Record + +const parsedNews: NewsPost[] = Object.entries(rawNewsModules) + .map(([path, raw]) => parseNewsFile(path, raw)) + .filter((entry): entry is NewsPost => Boolean(entry)) + .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()) + +export function getAllNews(): NewsPost[] { + return parsedNews.map((post) => ({ ...post })) +} + +export function getNewsBySlug(slug: string): NewsPost | null { + const found = parsedNews.find((post) => post.slug === slug) + return found ? { ...found } : null +} + +function parseNewsFile(path: string, raw: string): NewsPost | null { + if (!raw) { + return null + } + + const { body, attributes } = extractFrontMatter(raw) + const slug = extractSlug(path) + const title = attributes.title?.trim() || toTitleFromSlug(slug) + const publishedAt = parseDate(attributes.date) || new Date().toISOString() + const cleanBody = body.trim() + const excerptSource = attributes.summary || attributes.excerpt || attributes.description || '' + const excerpt = truncateText(excerptSource || firstParagraph(cleanBody), 220) + const html = markdownToHtml(cleanBody) + const readingTime = attributes.readingTime || formatReadingTime(cleanBody) + + return { + slug, + title, + excerpt, + publishedAt, + body: cleanBody, + html, + readingTime, + } +} + +function extractFrontMatter(raw: string): { body: string; attributes: FrontMatter } { + const FRONT_MATTER_REGEX = /^---\n([\s\S]*?)\n---\n?/ + const match = raw.match(FRONT_MATTER_REGEX) + if (!match) { + return { body: raw, attributes: {} } + } + const [, frontMatter] = match + const rest = raw.slice(match[0].length) + return { body: rest, attributes: parseFrontMatter(frontMatter) } +} + +function parseFrontMatter(source: string): FrontMatter { + const attributes: Record = {} + const lines = source.split(/\r?\n/) + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const colonIndex = trimmed.indexOf(':') + if (colonIndex === -1) continue + const key = trimmed.slice(0, colonIndex).trim() + const rawValue = trimmed.slice(colonIndex + 1).trim() + const value = rawValue.replace(/^['"]|['"]$/g, '').trim() + if (key) { + attributes[key] = value + } + } + return attributes +} + +function extractSlug(path: string): string { + const normalized = path.replace(/\\/g, '/').split('/') + const file = normalized[normalized.length - 1] || '' + return file.replace(/\.md$/i, '') +} + +function toTitleFromSlug(slug: string): string { + return slug + .split('-') + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' ') +} + +function parseDate(value?: string): string | null { + if (!value) return null + const parsed = new Date(value) + if (Number.isNaN(parsed.getTime())) { + return null + } + return parsed.toISOString() +} + +function firstParagraph(body: string): string { + const lines = body.split(/\r?\n/) + const collected: string[] = [] + for (const rawLine of lines) { + const line = rawLine.trim() + if (!line) { + if (collected.length) break + continue + } + if (line.startsWith('#') || line.startsWith('- ')) { + if (collected.length) break + continue + } + collected.push(line) + if (line.endsWith('.')) break + } + return collected.join(' ') +} + +function truncateText(text: string, maxLength: number): string { + const trimmed = text.trim() + if (!trimmed) return '' + if (trimmed.length <= maxLength) return trimmed + return `${trimmed.slice(0, maxLength - 1).trimEnd()}…` +} + +function formatReadingTime(content: string): string { + const words = content.split(/\s+/).filter(Boolean) + const minutes = Math.max(1, Math.round(words.length / 180)) + return `${minutes} Min Lesezeit` +} + +function markdownToHtml(markdown: string): string { + const lines = markdown.split(/\r?\n/) + let html = '' + let inList = false + + const closeList = () => { + if (inList) { + html += '' + inList = false + } + } + + for (const rawLine of lines) { + const line = rawLine.trim() + if (!line) { + closeList() + continue + } + if (line.startsWith('#')) { + closeList() + const level = Math.min(line.match(/^#+/)?.[0].length ?? 1, 6) + const content = line.replace(/^#+/, '').trim() + html += `${formatInline(content)}` + continue + } + if (line.startsWith('- ')) { + if (!inList) { + html += '
    ' + inList = true + } + html += `
  • ${formatInline(line.slice(2).trim())}
  • ` + continue + } + closeList() + html += `

    ${formatInline(line)}

    ` + } + + closeList() + return html +} + +function formatInline(value: string): string { + let text = escapeHtml(value) + text = text.replace(/\[([^\]]+)]\(([^)]+)\)/g, (_match, label, href) => { + const url = escapeAttribute(String(href)) + return `${String(label)}` + }) + text = text.replace(/`([^`]+)`/g, (_match, code) => `${String(code)}`) + text = text.replace(/\*\*([^*]+)\*\*/g, '$1') + text = text.replace(/\*([^*]+)\*/g, '$1') + return text +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case '\'': + return ''' + default: + return char + } + }) +} + +function escapeAttribute(value: string): string { + return value.replace(/[&<>"']/g, (char) => { + switch (char) { + case '&': + return '&' + case '<': + return '<' + case '>': + return '>' + case '"': + return '"' + case '\'': + return ''' + default: + return char + } + }) +} diff --git a/types/nodemailer.d.ts b/types/nodemailer.d.ts new file mode 100644 index 0000000..88cd8b0 --- /dev/null +++ b/types/nodemailer.d.ts @@ -0,0 +1,5 @@ +declare module 'nodemailer' { + const nodemailer: any + export function createTransport(options: any): any + export default nodemailer +}