write/server/lib/tiptap-to-html.js
chris 1f51504de9 Add story writer app with editor, auth, export, and polish features
- React + TipTap editor with formatting toolbar (bold, italic, underline,
  strikethrough, alignment, highlight, scene breaks)
- Custom image node view with resize and alignment controls; server-side
  WebP conversion via sharp
- Express + SQLite backend with JWT auth and admin user management
- Export to PDF, EPUB, and ODT
- Five themes (Midnight, Gothic Night, Enchanted Forest, Aged Manuscript,
  Neon Noir); Lora body font for readability
- Writing streak, daily word goal, milestones, and Ollama writing prompts
- Docker Compose setup for self-hosted deployment behind NPMplus

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 11:47:55 -04:00

81 lines
2.7 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

function esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export function nodeToHtml(node) {
if (!node) return ''
const ch = () => (node.content || []).map(nodeToHtml).join('')
switch (node.type) {
case 'doc': return ch()
case 'paragraph': return `<p>${ch() || ' '}</p>\n`
case 'heading': {
const l = node.attrs?.level || 1
return `<h${l}>${ch()}</h${l}>\n`
}
case 'text': {
let t = esc(node.text || '')
for (const m of (node.marks || [])) {
if (m.type === 'bold') t = `<strong>${t}</strong>`
if (m.type === 'italic') t = `<em>${t}</em>`
if (m.type === 'underline') t = `<u>${t}</u>`
}
return t
}
case 'image': {
const { src = '', alt = '', width = '100%', align = 'center' } = node.attrs || {}
const ml = align === 'left' ? '0' : 'auto'
const mr = align === 'right' ? '0' : 'auto'
return `<img src="${esc(src)}" alt="${esc(alt)}" style="width:${esc(width)};display:block;margin-left:${ml};margin-right:${mr}"/>\n`
}
case 'bulletList': return `<ul>\n${ch()}</ul>\n`
case 'orderedList': return `<ol>\n${ch()}</ol>\n`
case 'listItem': return `<li>${ch()}</li>\n`
case 'blockquote': return `<blockquote>\n${ch()}</blockquote>\n`
case 'hardBreak': return '<br/>'
default: return ch()
}
}
export function extractChapters(doc) {
const chapters = []
for (const node of (doc?.content || [])) {
if (node.type === 'heading' && node.attrs?.level === 1) {
chapters.push((node.content || []).map(n => n.text || '').join('') || 'Chapter')
}
}
return chapters
}
// Splits the document into chapter sections at each H1 boundary
export function splitByChapters(doc, fallbackTitle = 'Story') {
const nodes = doc?.content || []
if (!nodes.length) return [{ title: fallbackTitle, html: '' }]
const groups = []
let current = { title: null, nodes: [] }
for (const node of nodes) {
if (node.type === 'heading' && node.attrs?.level === 1) {
if (current.nodes.length > 0 || current.title !== null) {
groups.push({ title: current.title || fallbackTitle, html: current.nodes.map(nodeToHtml).join('') })
}
current = {
title: (node.content || []).map(n => n.text || '').join('') || 'Chapter',
nodes: [node],
}
} else {
current.nodes.push(node)
}
}
if (current.nodes.length > 0 || current.title !== null) {
groups.push({ title: current.title || fallbackTitle, html: current.nodes.map(nodeToHtml).join('') })
}
return groups.length ? groups : [{ title: fallbackTitle, html: nodes.map(nodeToHtml).join('') }]
}