PageSourceSearch

https://dhrn.de/js/news.js?v=160

js dhrn.de collected 2026-10-08 17:46:36 UTC 20,955 bytes, 423 lines download raw bytes

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, '&lt;')
10    .replace(/>/g, '&gt;')
11    .replace(/"/g, '&quot;')
12    .replace(/'/g, '&#39;')
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.