function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function escapeHtml(text) {
if (!text) return '';
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(text).replace(/[&<>"']/g, m => map[m]);
}
export function dungeonTemplate(data) {
const bodyFonts = [
"'Lora', serif",
"'Merriweather', serif",
"'Libre Baskerville', serif",
"'Source Serif 4', serif"
];
const headingFonts = [
"'New Rocker', system-ui",
"'UnifrakturCook', cursive",
"'IM Fell DW Pica', serif",
"'Cinzel', serif",
"'Cormorant Garamond', serif",
"'Playfair Display', serif"
];
const quoteFonts = [
"'Playfair Display', serif",
"'Libre Baskerville', serif",
"'Merriweather', serif"
];
const bodyFont = pickRandom(bodyFonts);
const headingFont = pickRandom(headingFonts);
const quoteFont = pickRandom(quoteFonts);
// Check if we have a map image to include
const hasMap = data.map && typeof data.map === 'string' && data.map.startsWith('data:image/');
return `
${data.title}
${escapeHtml(data.title)}
${data.flavor ? `
${escapeHtml(data.flavor)}
` : ''}
${data.hooksRumors && data.hooksRumors.length > 0 ? `
Hooks & Rumors
${data.hooksRumors.map(hook => `${escapeHtml(hook)} `).join('')}
` : ''}
${data.randomEvents && data.randomEvents.length > 0 ? `
Random Events (d6)
${data.randomEvents.map((event, index) => `
${index + 1}
${escapeHtml(event)}
`).join('')}
` : ''}
${data.rooms && data.rooms.length > 0 ? `
Locations
${data.rooms.map(room => `
${escapeHtml(room.name)}
${escapeHtml(room.description)}
`).join('')}
` : ''}
${data.encounters && data.encounters.length > 0 ? `
Encounters (d6)
${data.encounters.map((encounter, index) => {
// Truncate details to 4 sentences max to prevent overflow
let details = encounter.details || '';
// Keep location prefix in details (format: "Location Name: details")
// Split into sentences and keep only first 4
const sentences = details.match(/[^.!?]+[.!?]+/g) || [details];
if (sentences.length > 4) {
details = sentences.slice(0, 4).join(' ').trim();
}
// Also limit by character count as fallback (max ~350 chars)
if (details.length > 350) {
details = details.substring(0, 347).trim();
// Try to end at a sentence boundary
const lastPeriod = details.lastIndexOf('.');
if (lastPeriod > 280) {
details = details.substring(0, lastPeriod + 1);
} else {
details += '...';
}
}
return `
${index + 1}
${escapeHtml(encounter.name)}
${escapeHtml(details)}
`;
}).join('')}
` : ''}
${data.treasure && data.treasure.length > 0 ? `
Treasure
${data.treasure.map(item => `
${typeof item === 'object' && item.name ? `${escapeHtml(item.name)} — ${escapeHtml(item.description)}` : escapeHtml(item)}
`).join('')}
` : ''}
${data.npcs && data.npcs.length > 0 ? `
NPCs
${data.npcs.map(npc => `
${escapeHtml(npc.name)} : ${escapeHtml(npc.trait)}
`).join('')}
` : ''}
${data.plotResolutions && data.plotResolutions.length > 0 ? `
Plot Resolutions
${data.plotResolutions.map(resolution => {
// Truncate to 3 sentences max to prevent overflow
let text = resolution || '';
// Split into sentences and keep only first 3
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
if (sentences.length > 3) {
text = sentences.slice(0, 3).join(' ').trim();
}
// Also limit by character count as fallback (max ~200 chars)
if (text.length > 200) {
text = text.substring(0, 197).trim();
// Try to end at a sentence boundary
const lastPeriod = text.lastIndexOf('.');
if (lastPeriod > 150) {
text = text.substring(0, lastPeriod + 1);
} else {
text += '...';
}
}
return `
${escapeHtml(text)}
`;
}).join('')}
` : ''}
${hasMap ? `
` : ''}
`;
}