1// news.js â Automatisierte News aus data/news.json (maschinell geschrieben von 2// der News-Automation, siehe README "Automatisierte News"). Reine Helfer: 3// Escaping, Tag-Mapping, Vorschautext, Overlay-Render. Kein Build, keine Dependencies. 4 5/* ---------- XSS-Floor: alles escapen, jede URL scheme-pruefen ---------- */ 6export function esc(s) { 7 return String(s ?? '') 8 .replace(/&/g, '&') 9 .replace(/</g, '<') 10 .replace(/>/g, '>') 11 .replace(/"/g, '"') 12 .replace(/'/g, ''') 13} 14 15// Nur http(s) durchlassen â sonst '#' (blockt javascript:, data:, relative Tricks) 16export function safeUrl(u) { 17 return /^https?:\/\//i.test(String(u ?? '')) ? String(u) : '#' 18} 19 20/* ---------- Tag -> Filter-Topic (Site-Keys) ---------- */ 21// Tag-Text (beliebige Schreibweise) -> Site-Filterschluessel oder null 22function tagToCat(tag) { 23 const t = String(tag ?? '').toLowerCase().replace(/[\s._-]/g, '') 24 if (t === 'ki' || t === 'ai' || t.includes('kuenstliche') || t.includes('künstliche') || t.includes('artificial')) return 'ki' 25 if (t.includes('cyber')) return 'cyber' 26 if (t.includes('industrytech') || t.includes('industry') || t.includes('industrie')) return 'industry' 27 if (t.includes('nachhaltig') || t.includes('energie') || t.includes('sustain')) return 'nachhaltigkeit' 28 if (t.includes('metaverse') || t.includes('xr')) return 'xr' 29 if (t.includes('startup')) return 'industry' 30 return null 31} 32 33// Eindeutige, gemappte Topics eines Eintrags (Reihenfolge wie tags) 34function mappedTopics(tags) { 35 const out = [] 36 for (const tag of Array.isArray(tags) ? tags : []) { 37 const c = tagToCat(tag) 38 if (c && !out.includes(c)) out.push(c) 39 } 40 return out 41} 42 43/* ---------- Themen eines Eintrags: Pipeline-Slugs vor Tag-Mapping ---------- */ 44// `topics` liefert die Automation als exakte Filterschluessel mit â das ist der 45// Vertrag zwischen den beiden Repos. `tags` ist Anzeigetext und nur der Rueckfall 46// fuer Alt-Posts ohne topics-Feld: eine Umformulierung dort darf den Themenfilter 47// nicht abschalten. Unbekannte Slugs fallen still raus, damit ausschliesslich 48// echte Filterschluessel in `cat` landen (gleiche Totalitaets-Garantie wie in 49// motifForTopics: jede Eingabe liefert eine Liste, nie einen Fehler). 50const TOPIC_KEYS = new Set(['ki', 'cyber', 'industry', 'nachhaltigkeit', 'xr']) 51function topicKeys(entry) { 52 const given = Array.isArray(entry?.topics) ? entry.topics : [] 53 const known = [] 54 for (const t of given) { 55 if (typeof t === 'string' && TOPIC_KEYS.has(t) && !known.includes(t)) known.push(t) 56 } 57 return known.length ? known : mappedTopics(entry?.tags) 58} 59 60/* ---------- Beitragstyp -> Filterschluessel (Plural wie die Chips) ---------- */ 61// Das Schema fuehrt 'event'/'angebot' (Singular), die Chips heissen 62// 'events'/'angebote' (Plural). Ohne diese Bruecke landet der Typ nie in `cat`, 63// und beide Chips filtern ins Leere â genau der Defekt aus dem Kundenfeedback 64// vom 30.07.2026. Der typeof-String-Test blockt geerbte Object-Keys 65// ('constructor', 'toString'), die sonst als Filterschluessel durchrutschten. 66const TYPE_FILTER = { event: 'events', angebot: 'angebote' } 67export function typeFilterKey(postType) { 68 const key = TYPE_FILTER[typeof postType === 'string' ? postType : ''] 69 return typeof key === 'string' ? key : undefined 70} 71 72/* ---------- Karten-Motiv: letzter Rueckfall der Motiv-Kette ---------- */ 73// Historisch das gemeinsame Motiv der kuratierten Karten â die sind seit dem 74// 28.07.2026 raus (v2/js/data.js). Heute nur noch Rueckfall an drei Stellen: wenn 75// MOTIF_POOL leer waere (pickMotif), wenn eine Karte kein motif-Feld traegt und 76// wenn ein totes Bild kein data-motif findet (main.js, error-Listener). Der 77// Normalfall ist ein Themen- oder Streumotiv aus MOTIF_POOL. 78export const CARD_MOTIF = '/assets/img/angebot-map.avif' 79 80/* ---------- Platzhalter-Rotation: fuenf Themenmotive statt einem ---------- */ 81// Bildlose Karten bekommen eines von fuenf Motiven, damit die Sektion nicht 82// fuenfmal dasselbe Bild zeigt. Die Reihenfolge ist Teil des Vertrags: pickMotif 83// indiziert in genau diese Liste â Umsortieren oder Einfuegen weist allen 84// bestehenden Karten andere Motive zu. Pfade wurzelabsolut wie alle v2-Assets. 85export const MOTIF_POOL = [ 86 '/assets/img/motiv-ki.avif', 87 '/assets/img/motiv-cybersicherheit.avif', 88 '/assets/img/motiv-industrytech.avif', 89 '/assets/img/motiv-nachhaltigkeit.avif', 90 '/assets/img/motiv-metaverse.avif', 91] 92 93/* ---------- Thema -> Motiv: jede Karte zeigt ihr eigenes Themenbild ---------- */ 94// Site-Filterschluessel -> Motiv aus MOTIF_POOL (Reihenfolge siehe oben). Die 95// Schluessel sind der Vertrag mit der Automation: sie liefert `topics` als exakt 96// diese Slugs, und tagToCat erzeugt oben dieselben aus deutschen Tags. Ein 97// unbekannter Slug faellt still auf die Hash-Streuung zurueck. 98export const TOPIC_MOTIF = { 99 ki: MOTIF_POOL[0], 100 cyber: MOTIF_POOL[1], 101 industry: MOTIF_POOL[2], 102 nachhaltigkeit: MOTIF_POOL[3], 103 xr: MOTIF_POOL[4], 104} 105 106// Erstes Topic der Liste, das ein Motiv hat. Der typeof-Test ist die Totalitaets- 107// Garantie: er blockt geerbte Object-Keys ('constructor', 'toString') genaus
107o wie 108// einen fehlenden Pool-Eintrag â beides waere kein Bildpfad. 109function motifForTopics(list) { 110 for (const topic of Array.isArray(list) ? list : []) { 111 const motif = TOPIC_MOTIF[typeof topic === 'string' ? topic : ''] 112 if (typeof motif === 'string') return motif 113 } 114 return undefined 115} 116 117// Themenmotiv zuerst, Hash-Streuung als Rueckfall. Rein und total: jede Eingabe 118// (null, undefined, kaputte Typen) liefert einen Pfad, nie einen Fehler. 119// `topics` ist der in toCard aus den Tags gemappte Satz; `entry.topics` liefert 120// die Automation explizit mit und hat Vorrang. Fehlen beide (Alt-Post ohne 121// passenden Tag), greift die bisherige Streuung unveraendert. 122// Ihr Schluessel ist idempotencyKey â die Automation leitet ihn rein funktional 123// aus Partner + Quell-URLs + contentHash ab, er ueberlebt Re-Runs byte-identisch. 124// title nur als Fallback: Titel schreibt die Pipeline neu, das Motiv wuerde dann 125// springen. Ein 'link'-Feld gibt es im Schema nicht. 126export function pickMotif(entry, topics) { 127 if (!MOTIF_POOL.length) return CARD_MOTIF 128 const themed = motifForTopics(entry?.topics) ?? motifForTopics(topics) 129 if (themed) return themed 130 const key = String(entry?.idempotencyKey ?? entry?.title ?? '') 131 let sum = 0 132 for (let i = 0; i < key.length; i++) sum += key.charCodeAt(i) 133 return MOTIF_POOL[sum % MOTIF_POOL.length] 134} 135 136/* ---------- Karten-Bild: erste https-URL aus entry.images, sonst undefined ---- */ 137// Nur https:// durchlassen â safeUrl NICHT wiederverwenden: dessen '#'-Fallback 138// waere als <img src> falsch. (Uebernommen aus feat/news-cards-pagination.) 139export function firstImageUrl(entry) { 140 const url = entry?.images?.[0]?.url 141 return typeof url === 'string' && /^https:\/\//.test(url) ? url : undefined 142} 143 144/* ---------- Datum: "14. Juli 2026" (de-DE); ungueltiges -> '' ---------- */ 145// Bewusst gehalten: der Formatter fuer echte ZEITSTEMPEL (publishedAt / 146// generatedAt) â seit der Umstellung auf displayDateIso ohne Konsumenten in 147// diesem Repo, aber ueber den Cross-Repo-Vertragstest gepinnt. Entfernen ist 148// deshalb eine Vertragsentscheidung, kein Aufraeumen; fuer nackte Kalendertage 149// ist formatDateUtc weiter unten zustaendig. 150const DATE_FMT = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: 'long', year: 'numeric' }) 151export function formatDate(iso) { 152 if (!iso) return '' 153 const d = new Date(iso) 154 return Number.isNaN(d.getTime()) ? '' : DATE_FMT.format(d) 155} 156 157/* ---------- Kalendertag: "30. September 2026" aus nacktem 'YYYY-MM-DD' ----- */ 158// Zweiter Formatter, fest auf UTC â formatDate reicht hier nicht: new Date( 159// '2026-09-30') parst nach ISO-8601 als UTC-Mitternacht, DATE_FMT formatiert 160// aber in der Zone des Browsers. Westlich von UTC (New York, Honolulu) kippt 161// der Kalendertag damit auf den Vortag zurueck. Ein Veranstaltungstermin ist 162// ein Kalendertag, kein Zeitpunkt; er darf sich nicht mit dem Standort des 163// Lesers verschieben. formatDate selbst bleibt unveraendert fuer echte 164// Zeitstempel (publishedAt) â es ist ueber den Cross-Repo-Vertragstest gepinnt. 165// Total wie isoDay (weiter unten): alles, was dort kein echter Kalendertag ist 166// â Scheindaten wie '2026-02-31' eingeschlossen â liefert ''. 167const DAY_FMT = new Intl.DateTimeFormat('de-DE', { timeZone: 'UTC', day: '2-digit', month: 'long', year: 'numeric' }) 168export function formatDateUtc(day) { 169 const iso = isoDay(day) 170 return iso ? DAY_FMT.format(new Date(`${iso}T00:00:00Z`)) : '' 171} 172 173/* ---------- Anzeigedatum: nur Events, nur mit gueltigem eventDate ---------- */ 174// Kundenwunsch 11.08.: die Karte zeigt den Veranstaltungstermin statt des
175// Veroeffentlichungsdatums â und nur dort, wo es einen gibt. Angebote bleiben 176// datumslos, auch wenn sie ein eventDate tragen: dort ist es die Gueltigkeits- 177// frist, kein Termin. Anzeige und Verfall sind damit bewusst entkoppelt â 178// isExpiredEntry liest eventDate weiterhin fuer BEIDE Typen. 179// Warum ein eigenes Feld und nicht `date` umbiegen: `date` ist in main.js der 180// Sortierschluessel des Grids (publishedAt, absteigend) fuer âAlle" und 181// âAngebote". Truege es den Termin, sortierte sich alles still um â die 182// Mehrzahl der Beitraege hat keinen. Nur die EVENTS-Ansicht ordnet seit dem 183// 20.08.2026 chronologisch: main.js sortiert dort per eventOrderMs (unten) 184// ueber data-event-date, das genau diesen Wert traegt. 185export function displayDateIso(entry) { 186 return entry?.postType === 'event' ? (isoDay(entry?.eventDate) ?? '') : '' 187} 188 189/* ---------- Sortierschluessel: Events chronologisch (innowerft 19.08.) ----- */ 190// Kundenwunsch 19.08.: die Events-Ansicht zeigt Termine chronologisch (naechster 191// zuerst) statt in Veroeffentlichungsreihenfolge â das gesuchte Event am 30.09. 192// stand sonst auf Seite 2 hinter juenger publizierten, aber spaeteren Terminen. 193// Rein und total wie die Nachbarn: gueltiger Kalendertag -> UTC-Mitternacht in 194// ms, alles andere (fehlend, Scheindaten, kaputte Typen) -> MAX_SAFE_INTEGER, 195// also ans Ende. Bewusst NICHT Infinity: zwei datumslose Karten ergaeben im 196// Komparator Infinity - Infinity = NaN, und ein NaN-Rueckgabewert hebelt die 197// Stabilitaetsgarantie von Array.prototype.sort aus. Konsument: applyFilter in 198// main.js (nur bei state.cat === 'events'); Vertrag gepinnt im Cross-Repo-Test. 199export function eventOrderMs(day) { 200 const iso = isoDay(day) 201 return iso ? Date.parse(`${iso}T00:00:00Z`) : Number.MAX_SAFE_INTEGER 202} 203 204/* ---------- Vorschautext: erster Absatz, Attribution-Zeilen raus, ~140 Zeichen */ 205// Body-Format: Absaetze durch Leerzeile getrennt; Attribution als "- Titel: URL"- 206// Zeilen am Ende. Fuer die Karte nur den ersten Absatz, Attribution entfernen, kuerzen. 207export function excerptFromBody(body) { 208 const first = String(body ?? '').split(/\n{2,}/)[0] || '' 209 const cleaned = first 210 .split('\n') 211 .filter((line) => !/^\s*[-ââ]\s+.+:\s*https?:\/\/\S+\s*$/.test(line)) 212 .join(' ') 213 .replace(/\s+/g, ' ') 214 .trim() 215 if (cleaned.length <= 140) return cleaned 216 const cut = cleaned.slice(0, 140) 217 const lastSpace = cut.lastIndexOf(' ') 218 return (lastSpace > 80 ? cut.slice(0, lastSpace) : cut).replace(/[\s.,;:ââ-]+$/, '') + 'â¦' 219} 220 221/* ---------- Eintrag -> Card-Objekt (traegt den vollen Eintrag fuer's Overlay) */ 222export function toCard(entry) { 223 const topics = topicKeys(entry) 224 // Einmal ziehen und mitgeben: img nutzt es als Platzhalter, motif traegt es 225 // zusaetzlich in die Karte, damit ein spaeter sterbendes Partnerbild auf SEIN 226 // Motiv zurueckfaellt statt auf ein globales (main.js, error-Listener). 227 const motif = pickMotif(entry, topics) 228 // `cat` traegt drei Klassen nebeneinander: 'news' (alle), den Beitragstyp und 229 // die Themen. main.js filtert je Chip-Reihe gegen genau eine davon. 230 const type = typeFilterKey(entry.postType) 231 // Einmal ziehen: `img` braucht die URL, `hasRealImage` nur die Ja/Nein-Antwort. 232 // Ohne dieses Flag koennte main.js ein echtes Artikelbild nicht von einem 233 // eingesetzten Motiv unterscheiden â beide stehen am Ende in `img`. 234 const image = firstImageUrl(entry) 235 return { 236 cat: ['news', ...(type ? [type] : []), ...topics], 237 tag: entry.tags?.[0] ?? 'News', 238 by: entry.partnerName, 239 partnerId: entry.partnerId, 240 img: image ?? motif, 241 hasRealImage: image !== undefined, 242 motif, 243 title: entry.title, 244 excerpt: excerptFromBody(entry.body), 245 date: entry.publishedAt || entry.generatedAt || '', 246 // Zwei Datumsfelder mit getrennten Aufgaben: `date` sortiert das Grid, 247 // `displayDate` steht auf der Karte. Siehe displayDateIso oben. 248 displayDate: displayDateIso(entry), 249 post: entry, 250 } 251} 252 253/* ---------- Logo-Regel: wann ersetzt das Partnerlogo das Kartenbild? -------- */ 254// Angebots- UND Event-Karten zeigen im Bildbereich das Partnerlogo â aber NUR, 255// wenn die Karte kein echtes Artikelbild traegt. 256// 257// Zwei Kundenentscheidungen stecken hier uebereinander: 258// - Corinna 04.08.: Angebots-Karten zeigen das Partnerlogo. 259// - Corinna 08.08.: bildlose EVENTS zeigen ebenfalls das Logo des Anbieters 260// statt des Schluesseltechnologie-Motivs â bei einem Event ist der Anbieter 261// die aussagekraeftigere Information als ein generisches Themenbild. 262// Vorher trugen Event-Karten 'events' (nie 'angebote') in `cat` und landeten 263// deshalb immer beim Motiv; genau das kehrt diese Zeile um. 264// 265// Unveraendert: ein von der Automation hotverlinktes Artikelbild schlaegt das 266// Logo (bis 06.08. lagen vier Beitraege mit unsichtbarem Bild vor), und 267// kuratierte Karten aus data.js kennen `hasRealImage` nicht â fuer sie bleibt es 268// beim Logo. Die kuratierten Karten tragen ausschliesslich 'angebote', nie 269// 'events' (js/data.js), die neue Bedingung kann sie also nicht beruehren. 270//
271// Das Motiv bleibt der LETZTE Rueckfall: findet main.js zur partnerId keinen 272// Website-Partner oder hat der Partner kein Logo, greift wieder das Themenmotiv. 273// Rein und total: jede Eingabe (null, kaputte Typen) liefert einen Boolean. 274export function usesPartnerLogo(card) { 275 if (!Array.isArray(card?.cat) || card.hasRealImage) return false 276 return card.cat.includes('angebote') || card.cat.includes('events') 277} 278 279/* ---------- Laden + validieren: jeder Fehler -> [] â dann Leerzustand ------ */ 280let warned = false 281function warnOnce(reason) { 282 if (!warned) { 283 warned = true 284 console.warn('[news] automatisierte News nicht geladen:', reason) 285 } 286 return [] 287} 288 289function isValidEntry(e) { 290 return !!e && typeof e.title === 'string' && typeof e.partnerName === 'string' && Array.isArray(e.sources) 291} 292 293/* ---------- Anzeige-Verfall: Vergangenes faellt beim Rendern raus ---------- */ 294// Hoechstalter eines undatierten Angebots in Tagen. WERT-DRIFT: die News-Automation 295// nennt denselben Wert im Retro-Filter-Prompt ihres Repos. Wird er dort geaendert, 296// muss er hier mitwandern (und umgekehrt) â sonst zeigt die Seite Angebote, die die 297// Automation laengst aussortiert. Der Cross-Repo-Vertragstest pinnt genau diese Zahl. 298export const UNDATED_ANGEBOT_MAX_AGE_DAYS = 180 299 300// Kalendertag in Europe/Berlin. formatToParts statt format: das Muster einer Locale 301// ist nicht garantiert, die Teile sind es â so kommt immer 'YYYY-MM-DD' heraus, 302// egal welche ICU-Version der Browser mitbringt. 303const BERLIN_DAY_FMT = new Intl.DateTimeFormat('en-CA', { 304 timeZone: 'Europe/Berlin', 305 year: 'numeric', 306 month: '2-digit', 307 day: '2-digit', 308}) 309function berlinDay(date) { 310 const parts = BERLIN_DAY_FMT.formatToParts(date) 311 const part = (type) => parts.find((p) => p.type === type)?.value ?? '' 312 return `${part('year')}-${part('month')}-${part('day')}` 313} 314export function berlinToday() { 315 return berlinDay(new Date()) 316} 317 318// 'YYYY-MM-DD' oder undefined. Das Muster allein reicht nicht: '2026-02-31' passt 319// darauf, und new Date() rollt es still auf den 3. Maerz weiter. Der Rueckweg- 320// Vergleich sortiert genau solche Scheindaten aus. 321function isoDay(value) { 322 const s = typeof value === 'string' ? value.trim() : '' 323 if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return undefined 324 const ms = Date.parse(`${s}T00:00:00Z`) 325 if (Number.isNaN(ms)) return undefined 326 return new Date(ms).toISOString().slice(0, 10) === s ? s : undefined 327} 328 329// Tag plus/minus n Tage, gerechnet in UTC â dort hat jeder Tag exakt 86400000 ms, 330// die Berliner Sommerzeit kann also keine Verschiebung einschleusen. 331function shiftDay(day, deltaDays) { 332 return new Date(Date.parse(`${day}T00:00:00Z`) + deltaDays * 86400000).toISOString().slice(0, 10) 333} 334 335// Reine Helferin (Node-importierbar, unit-getestet): gehoert der Eintrag heute noch 336// in die Liste? Die Kundenregel lautet "nur Events und Angebote, die in der Zukunft 337// stattfinden" und bindet BEIDE Typen gleich. Ein eventDate laeuft deshalb immer am 338// Ende SEINES Tages ab â beim Event ist es der Termin, beim Angebot die Frist 339// ("gueltig bis"). Nur datumslose Angebote altern stattdessen ueber 340// UNDATED_ANGEBOT_MAX_AGE_DAYS; datumslose Events bleiben stehen (die Automation 341// laesst sie schon beim Einlesen nicht durch). 342// Alles andere bleibt sichtbar: Eintraege ohne postType (also saemtliche Alt-Posts), 343// kaputte Datumsangaben, fehlendes publishedAt. Im Zweifel anzeigen â ein zu viel 344// gezeigter Eintrag ist harmlos, ein still verschwundener nicht. 345export function isExpiredEntry(entry, todayBerlin) { 346 const today = isoDay(todayBerlin) 347 if (!today) return false 348 const postType = entry?.postType 349 if (postType !== 'event' && postType !== 'angebot') return false 350 // Mit Datum: einheitlich fuer beide Typen â heute laeuft noch, gestern nicht. 351 const eventDay = isoDay(entry?.eventDate) 352 if (eventDay) return eventDay < today 353 // Ohne (oder mit unbrauchbarem) Datum altern nur Angebote. 354 if (postType !== 'angebot') return false 355 const publishedMs = typeof entry?.publishedAt === 'string' ? Date.parse(entry.publishedAt) : Number.NaN 356 if (Number.isNaN(publishedMs)) return false 357 return berlinDay(new Date(publishedMs)) < shiftDay(today, -UNDATED_ANGEBOT_MAX_AGE_DAYS) 358} 359 360export async function loadAutomatedNews() { 361 try { 362 const res = await fetch('/data/news.json', { cache: 'no-cache' }) 363 if (!res.ok) return warnOnce('HTTP ' + res.status) 364 const json = await res.json() 365 if (json?.version !== 1 || !Array.isArray(json.posts)) return warnOnce('unerw
365artetes Schema') 366 // Heute EINMAL bestimmen: so misst jeder Eintrag gegen denselben Tag, auch 367 // wenn das Laden ueber Mitternacht laeuft. 368 const today = berlinToday() 369 return json.posts.filter((e) => isValidEntry(e) && !isExpiredEntry(e, today)) 370 } catch (err) { 371 return warnOnce(err) 372 } 373} 374 375/* ---------- Overlay: Body (Markdown-artiger Text) escape-first rendern ----- */ 376// Bare https?://-URLs verlinken â NACH dem Escapen, auf dem escapten Text. 377function linkify(escaped) { 378 return escaped.replace(/https?:\/\/[^\s<]+/g, (u) => 379 `<a href="${u}" target="_blank" rel="noopener noreferrer">${u}</a>`) 380} 381 382// Ein Block, dessen Zeilen ALLE mit "- " oder "⢠" beginnen, wird zur echten 383// Liste. Nur dann: die Pipeline haengt ihre Quellenzeilen als "- Titel: URL" 384// unter eine Ueberschriftszeile ohne Marker â ein gemischter Block bliebe sonst 385// halb Absatz, halb Liste. Gebraucht wird das von den festen Angebots-Beitraegen 386// (js/data.js), deren Body eine Leistungsliste des Partners ist. 387const BULLET = /^[-â¢]\s+/ 388 389export function renderPostBody(body) { 390 return esc(body) 391 .split(/\n{2,}/) 392 .map((p) => p.trim()) 393 .filter(Boolean) 394 .map((block) => { 395 const lines = block.split('\n').map((l) => l.trim()).filter(Boolean) 396 if (lines.length > 0 && lines.every((l) => BULLET.test(l))) { 397 return `<ul>${lines.map((l) => `<li>${linkify(l.replace(BULLET, ''))}</li>`).join('')}</ul>` 398 } 399 return `<p>${linkify(block.replace(/\n/g, '<br>'))}</p>` 400 }) 401 .join('') 402} 403 404/* ---------- Overlay: Quellenliste aus entry.sources[] ---------- */
405export function renderSources(entry) { 406 const sources = Array.isArray(entry?.sources) ? entry.sources : [] 407 if (!sources.length) return '' 408 const items = sources.map((s) => { 409 const url = safeUrl(s?.canonicalUrl) 410 const label = esc(s?.sourceTitle || s?.canonicalUrl || 'Zur Originalmeldung') 411 return `<li><a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${label}</a></li>` 412 }).join('') 413 // Die Pipeline verlinkt Originalmeldungen; die festen Angebots-Beitraege 414 // (js/data.js) verlinken die Partner-Website â "Originalmeldung" waere dort 415 // schlicht falsch, deshalb darf der Eintrag seine Ueberschrift mitbringen. 416 const heading = 417 typeof entry?.sourcesHeading === 'string' && entry.sourcesHeading !== '' 418 ? esc(entry.sourcesHeading) 419 : sources.length > 1 420 ? 'Originalmeldungen' 421 : 'Originalmeldung' 422 return `<h3>${heading}</h3><ul>${items}</ul>` 423}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.