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 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('') }]
}