/** * Render a set of MwSource into a single markwhen `.mw` document. * * The output is deterministic for a given input (stable sort + escaping) * so that projections can be cached and diffed. */ import type { MwEvent, MwProjection, MwSource } from './types'; const MAX_TITLE = 90; const TAG_COLOR_DEFAULTS: Record = { rcal: 'blue', rnotes: 'green', rtasks: 'orange', rvote: 'purple', rschedule: 'teal', rtrips: 'amber', rinbox: 'gray', }; export interface RenderOptions { title?: string; timezone?: string; /** Default view when a markwhen renderer opens the doc. */ view?: 'timeline' | 'calendar' | 'gantt'; /** Extra frontmatter tag colors to merge with inferred defaults. */ colors?: Record; /** * Absolute URL base (protocol + host, no trailing slash). If set, any * relative `href` on events is prefixed with this so the markwhen * parser recognizes it as a link. Required for in-timeline "open in * rApp" clickthrough. */ baseUrl?: string; } function iso(ts: number): string { // UTC YYYY-MM-DD HH:mm:ss form; markwhen accepts ISO-like. const d = new Date(ts); const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`; } function sanitizeTitle(raw: string): string { const t = raw.replace(/[\r\n]+/g, ' ').trim(); return t.length > MAX_TITLE ? t.slice(0, MAX_TITLE - 1) + '…' : t; } function formatRange(ev: MwEvent): string { if (ev.end && ev.end > ev.start) return `${iso(ev.start)} / ${iso(ev.end)}`; return iso(ev.start); } function formatTags(tags: string[] | undefined): string { if (!tags || tags.length === 0) return ''; return ' ' + tags.map(t => `#${t.replace(/\s+/g, '_')}`).join(' '); } function dedup(events: MwEvent[]): MwEvent[] { const seen = new Set(); const out: MwEvent[] = []; for (const ev of events) { if (seen.has(ev.sourceId)) continue; seen.add(ev.sourceId); out.push(ev); } return out; } function absolutizeHref(href: string, baseUrl?: string): string { if (/^[a-z]+:\/\//i.test(href)) return href; if (!baseUrl) return href; const base = baseUrl.replace(/\/$/, ''); return href.startsWith('/') ? `${base}${href}` : `${base}/${href}`; } function stripEmoji(s: string): string { // Pull leading emoji + space off a label like "📅 Calendar" → "Calendar". return s.replace(/^\p{Extended_Pictographic}\s*/u, '').trim() || s; } export function renderMarkwhen(sources: MwSource[], opts: RenderOptions = {}): MwProjection { const title = opts.title ?? 'rSpace Timeline'; const tz = opts.timezone ?? 'UTC'; const view = opts.view ?? 'timeline'; const colors: Record = { ...opts.colors }; for (const s of sources) { if (s.color) colors[s.tag] = s.color; else if (!colors[s.tag] && TAG_COLOR_DEFAULTS[s.id]) colors[s.tag] = TAG_COLOR_DEFAULTS[s.id]; } const frontColors = Object.entries(colors) .map(([t, c]) => `#${t}: ${c}`) .join('\n'); const lines: string[] = []; lines.push('---'); lines.push(`title: ${title}`); lines.push(`view: ${view}`); lines.push(`timezone: ${tz}`); if (frontColors) lines.push(frontColors); lines.push('---', ''); lines.push('// Generated by shared/markwhen/projection.ts — do not hand-edit.', ''); const allEvents: MwEvent[] = []; for (const src of sources) { const events = dedup([...src.events].sort((a, b) => a.start - b.start)); if (events.length === 0) continue; const openLabel = `Open in ${stripEmoji(src.label)}`; lines.push(`section ${src.label} #${src.tag}`); for (const ev of events) { const titleLine = `${formatRange(ev)}: ${sanitizeTitle(ev.title)}${formatTags(ev.tags)}`; lines.push(titleLine); if (ev.description) { for (const dLine of ev.description.split(/\r?\n/)) { if (dLine.trim()) lines.push(' ' + dLine); } } if (ev.href) { const url = absolutizeHref(ev.href, opts.baseUrl); // Markdown-style link so the markwhen parser detects it as a // clickable link in the event's description tooltip/popup. lines.push(` [${openLabel}](${url})`); } } lines.push('endSection', ''); allEvents.push(...events); } return { text: lines.join('\n'), events: allEvents, count: allEvents.length, }; }