10 changed files with 589 additions and 48 deletions
@ -0,0 +1,80 @@ |
|||||||
|
import { ASCIIDOCTOR_SERVER_URL } from '@/constants' |
||||||
|
|
||||||
|
export type AsciiDoctorExportFormat = 'epub3' | 'epub' | 'pdf' | 'html5' | 'html' |
||||||
|
|
||||||
|
const FORMATS: Record< |
||||||
|
AsciiDoctorExportFormat, |
||||||
|
{ endpoint: string; mimeType: string; extension: string } |
||||||
|
> = { |
||||||
|
epub3: { endpoint: 'epub', mimeType: 'application/epub+zip', extension: 'epub' }, |
||||||
|
epub: { endpoint: 'epub', mimeType: 'application/epub+zip', extension: 'epub' }, |
||||||
|
pdf: { endpoint: 'pdf', mimeType: 'application/pdf', extension: 'pdf' }, |
||||||
|
html5: { endpoint: 'html5', mimeType: 'text/html; charset=utf-8', extension: 'html' }, |
||||||
|
html: { endpoint: 'html5', mimeType: 'text/html; charset=utf-8', extension: 'html' } |
||||||
|
} |
||||||
|
|
||||||
|
const CONVERT_TIMEOUT_MS = 120_000 |
||||||
|
|
||||||
|
export function isAsciiDoctorServerConfigured(): boolean { |
||||||
|
return ASCIIDOCTOR_SERVER_URL.length > 0 |
||||||
|
} |
||||||
|
|
||||||
|
export function normalizeAsciiDoctorFormat(format: string): AsciiDoctorExportFormat { |
||||||
|
const key = format.trim().toLowerCase() |
||||||
|
if (key === '' || key === 'epub3') return 'epub3' |
||||||
|
if (key in FORMATS) return key as AsciiDoctorExportFormat |
||||||
|
throw new Error(`Unsupported export format: ${format}`) |
||||||
|
} |
||||||
|
|
||||||
|
export async function convertAsciiDocViaServer( |
||||||
|
format: AsciiDoctorExportFormat, |
||||||
|
content: string, |
||||||
|
title: string, |
||||||
|
author: string, |
||||||
|
image?: string | null |
||||||
|
): Promise<{ blob: Blob; mimeType: string; extension: string }> { |
||||||
|
if (!isAsciiDoctorServerConfigured()) { |
||||||
|
throw new Error('AsciiDoctor server URL is not configured.') |
||||||
|
} |
||||||
|
|
||||||
|
const normalized = normalizeAsciiDoctorFormat(format) |
||||||
|
const info = FORMATS[normalized] |
||||||
|
const url = `${ASCIIDOCTOR_SERVER_URL.replace(/\/$/, '')}/convert/${info.endpoint}` |
||||||
|
|
||||||
|
const payload: Record<string, string> = { |
||||||
|
content, |
||||||
|
title: title.trim() || 'Publication', |
||||||
|
author: author.trim() |
||||||
|
} |
||||||
|
const cover = image?.trim() |
||||||
|
if (cover) payload.image = cover |
||||||
|
|
||||||
|
const controller = new AbortController() |
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), CONVERT_TIMEOUT_MS) |
||||||
|
|
||||||
|
let response: Response |
||||||
|
try { |
||||||
|
response = await fetch(url, { |
||||||
|
method: 'POST', |
||||||
|
headers: { 'Content-Type': 'application/json', Accept: '*/*' }, |
||||||
|
body: JSON.stringify(payload), |
||||||
|
signal: controller.signal |
||||||
|
}) |
||||||
|
} finally { |
||||||
|
window.clearTimeout(timeoutId) |
||||||
|
} |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
const detail = (await response.text()).slice(0, 400) |
||||||
|
throw new Error( |
||||||
|
detail ? `AsciiDoctor server returned HTTP ${response.status}: ${detail}` : `AsciiDoctor server returned HTTP ${response.status}` |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
const blob = await response.blob() |
||||||
|
if (blob.size === 0) { |
||||||
|
throw new Error('AsciiDoctor server returned an empty file.') |
||||||
|
} |
||||||
|
|
||||||
|
return { blob, mimeType: info.mimeType, extension: info.extension } |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import { describe, expect, it } from 'vitest' |
||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { assemblePublicationAsciidoc } from '@/lib/publication-asciidoc-assembler' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const PK = 'a'.repeat(64) |
||||||
|
|
||||||
|
function indexEvent(d: string, aTags: string[], id = d.padEnd(64, '0').slice(0, 64)): Event { |
||||||
|
return { |
||||||
|
id, |
||||||
|
kind: ExtendedKind.PUBLICATION, |
||||||
|
pubkey: PK, |
||||||
|
created_at: 100, |
||||||
|
content: '', |
||||||
|
tags: [ |
||||||
|
['d', d], |
||||||
|
['title', `Book ${d}`], |
||||||
|
['author', 'Jane Author', 'writer'], |
||||||
|
['image', 'https://example.com/cover.jpg'], |
||||||
|
['version', '1.2'], |
||||||
|
['summary', 'A short summary.'], |
||||||
|
...aTags.map((a) => ['a', a] as [string, string]) |
||||||
|
], |
||||||
|
sig: 'c'.repeat(128) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function sectionEvent(d: string, title: string, content: string): Event { |
||||||
|
return { |
||||||
|
id: `${d}-id`.padEnd(64, '0').slice(0, 64), |
||||||
|
kind: ExtendedKind.PUBLICATION_CONTENT, |
||||||
|
pubkey: PK, |
||||||
|
created_at: 50, |
||||||
|
content, |
||||||
|
tags: [['d', d], ['title', title]], |
||||||
|
sig: 'd'.repeat(128) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
describe('assemblePublicationAsciidoc', () => { |
||||||
|
it('builds document header with cover and metadata', () => { |
||||||
|
const coord = `30041:${PK}:intro` |
||||||
|
const root = indexEvent('my-book', [coord]) |
||||||
|
const intro = sectionEvent('intro', 'Introduction', 'Hello world.') |
||||||
|
const fetched = new Map<string, Event>([ |
||||||
|
[root.id, root], |
||||||
|
[`30040:${PK}:my-book`, root], |
||||||
|
[coord, intro], |
||||||
|
[intro.id, intro] |
||||||
|
]) |
||||||
|
const byAddress = new Map<string, Event>([ |
||||||
|
[`30040:${PK}:my-book`, root], |
||||||
|
[coord, intro] |
||||||
|
]) |
||||||
|
|
||||||
|
const assembled = assemblePublicationAsciidoc(root, fetched, byAddress) |
||||||
|
|
||||||
|
expect(assembled.title).toBe('Book my-book') |
||||||
|
expect(assembled.author).toBe('Jane Author (writer)') |
||||||
|
expect(assembled.image).toBe('https://example.com/cover.jpg') |
||||||
|
expect(assembled.content).toContain('= Book my-book') |
||||||
|
expect(assembled.content).toContain('Jane Author (writer)') |
||||||
|
expect(assembled.content).toContain(':doctype: book') |
||||||
|
expect(assembled.content).toContain(':epub-cover-image: https://example.com/cover.jpg') |
||||||
|
expect(assembled.content).toContain('[abstract]') |
||||||
|
expect(assembled.content).toContain('A short summary.') |
||||||
|
expect(assembled.content).toContain('== Introduction') |
||||||
|
expect(assembled.content).toContain('Hello world.') |
||||||
|
}) |
||||||
|
}) |
||||||
@ -0,0 +1,205 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { |
||||||
|
getPublicationIndexMetadataFromEvent, |
||||||
|
type PublicationIndexMetadata |
||||||
|
} from '@/lib/event-metadata' |
||||||
|
import { eventTagAddress } from '@/lib/publication-index' |
||||||
|
import { pubkeyToNpub } from '@/lib/pubkey' |
||||||
|
import { |
||||||
|
parsePublicationATagCoordinate, |
||||||
|
publicationRefKey, |
||||||
|
type PublicationSectionRef |
||||||
|
} from '@/lib/publication-section-fetch' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
const MAX_NEST_DEPTH = 8 |
||||||
|
|
||||||
|
export type AssembledPublicationAsciidoc = { |
||||||
|
content: string |
||||||
|
title: string |
||||||
|
author: string |
||||||
|
image: string |
||||||
|
} |
||||||
|
|
||||||
|
function escapeInline(value: string): string { |
||||||
|
return value.replace(/\n/g, ' ') |
||||||
|
} |
||||||
|
|
||||||
|
function heading(level: number, title: string): string { |
||||||
|
const marks = '='.repeat(Math.max(2, Math.min(6, level))) |
||||||
|
return `${marks} ${escapeInline(title)}\n\n` |
||||||
|
} |
||||||
|
|
||||||
|
function tagValue(event: Event, name: string): string | undefined { |
||||||
|
for (const tag of event.tags) { |
||||||
|
if ((tag[0] || '').trim().toLowerCase() !== name) continue |
||||||
|
const value = tag[1]?.trim() |
||||||
|
if (value) return value |
||||||
|
} |
||||||
|
return undefined |
||||||
|
} |
||||||
|
|
||||||
|
function titleFromIndex(event: Event): string { |
||||||
|
return tagValue(event, 'title') || tagValue(event, 'd') || 'Publication' |
||||||
|
} |
||||||
|
|
||||||
|
function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string): string { |
||||||
|
if (metadata.authors.length > 0) { |
||||||
|
return metadata.authors |
||||||
|
.map((a) => (a.role ? `${a.name} (${a.role})` : a.name)) |
||||||
|
.join('; ') |
||||||
|
} |
||||||
|
return pubkeyToNpub(pubkey) ?? pubkey |
||||||
|
} |
||||||
|
|
||||||
|
/** Ordered `a` / `e` refs from index tags (NKBIP-01 section order). */ |
||||||
|
export function orderedPublicationRefsFromIndex(event: Event): PublicationSectionRef[] { |
||||||
|
const refs: PublicationSectionRef[] = [] |
||||||
|
for (const tag of event.tags) { |
||||||
|
const name = (tag[0] || '').trim().toLowerCase() |
||||||
|
if (name === 'a' && tag[1]) { |
||||||
|
const parsed = parsePublicationATagCoordinate(tag[1]) |
||||||
|
if (!parsed) continue |
||||||
|
refs.push({ |
||||||
|
type: 'a', |
||||||
|
coordinate: parsed.coordinate, |
||||||
|
kind: parsed.kind, |
||||||
|
pubkey: parsed.pubkey, |
||||||
|
identifier: parsed.identifier, |
||||||
|
relay: tag[2] |
||||||
|
}) |
||||||
|
} else if (name === 'e' && tag[1]) { |
||||||
|
refs.push({ type: 'e', eventId: tag[1], relay: tag[2] }) |
||||||
|
} |
||||||
|
} |
||||||
|
return refs |
||||||
|
} |
||||||
|
|
||||||
|
function resolveRefEvent( |
||||||
|
ref: PublicationSectionRef, |
||||||
|
fetched: Map<string, Event> |
||||||
|
): Event | undefined { |
||||||
|
return fetched.get(publicationRefKey(ref)) |
||||||
|
} |
||||||
|
|
||||||
|
function appendIndexBody( |
||||||
|
index: Event, |
||||||
|
eventsByAddress: Map<string, Event>, |
||||||
|
fetched: Map<string, Event>, |
||||||
|
headingLevel: number, |
||||||
|
parts: string[] |
||||||
|
): void { |
||||||
|
if (headingLevel > MAX_NEST_DEPTH + 1) return |
||||||
|
|
||||||
|
for (const ref of orderedPublicationRefsFromIndex(index)) { |
||||||
|
if (ref.type === 'a' && ref.coordinate) { |
||||||
|
const partsCoord = ref.coordinate.split(':') |
||||||
|
const kind = ref.kind ?? parseInt(partsCoord[0], 10) |
||||||
|
|
||||||
|
if (kind === ExtendedKind.PUBLICATION) { |
||||||
|
const child = |
||||||
|
eventsByAddress.get(ref.coordinate) ?? resolveRefEvent(ref, fetched) |
||||||
|
if (!child) continue |
||||||
|
|
||||||
|
const sectionTitle = titleFromIndex(child) |
||||||
|
if (sectionTitle) parts.push(heading(headingLevel, sectionTitle)) |
||||||
|
|
||||||
|
const indexContent = child.content.trim() |
||||||
|
if (indexContent) parts.push(`${indexContent}\n\n`) |
||||||
|
|
||||||
|
appendIndexBody(child, eventsByAddress, fetched, headingLevel + 1, parts) |
||||||
|
} else if ( |
||||||
|
kind === ExtendedKind.PUBLICATION_CONTENT || |
||||||
|
kind === ExtendedKind.WIKI_ARTICLE |
||||||
|
) { |
||||||
|
const article = resolveRefEvent(ref, fetched) |
||||||
|
if (!article) continue |
||||||
|
|
||||||
|
let sectionTitle = tagValue(article, 'title')?.trim() || ref.identifier || '' |
||||||
|
if (!sectionTitle && ref.coordinate) { |
||||||
|
sectionTitle = ref.coordinate.split(':').slice(2).join(':') |
||||||
|
} |
||||||
|
if (sectionTitle) parts.push(heading(headingLevel, sectionTitle)) |
||||||
|
|
||||||
|
const body = article.content.trim() |
||||||
|
if (body) parts.push(`${body}\n\n`) |
||||||
|
} |
||||||
|
} else if (ref.type === 'e') { |
||||||
|
const article = resolveRefEvent(ref, fetched) |
||||||
|
if (!article) continue |
||||||
|
const sectionTitle = tagValue(article, 'title')?.trim() || 'Section' |
||||||
|
parts.push(heading(headingLevel, sectionTitle)) |
||||||
|
const body = article.content.trim() |
||||||
|
if (body) parts.push(`${body}\n\n`) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Build a single AsciiDoc book document (matches unfold {@link PublicationAsciidocAssembler}). */ |
||||||
|
export function assemblePublicationAsciidoc( |
||||||
|
rootIndex: Event, |
||||||
|
fetched: Map<string, Event>, |
||||||
|
eventsByAddress: Map<string, Event> |
||||||
|
): AssembledPublicationAsciidoc { |
||||||
|
const metadata = getPublicationIndexMetadataFromEvent(rootIndex) |
||||||
|
const title = metadata.title?.trim() || titleFromIndex(rootIndex) |
||||||
|
const author = authorFromMetadata(metadata, rootIndex.pubkey) |
||||||
|
const image = metadata.image?.trim() ?? '' |
||||||
|
const version = |
||||||
|
metadata.version?.trim() || tagValue(rootIndex, 'V')?.trim() || 'first edition' |
||||||
|
|
||||||
|
const header: string[] = [`= ${escapeInline(title)}`] |
||||||
|
if (author) header.push(escapeInline(author)) |
||||||
|
header.push(':doctype: book') |
||||||
|
header.push(':toc:') |
||||||
|
header.push(':toclevels: 2') |
||||||
|
header.push(':stem:') |
||||||
|
header.push(':page-break-mode: auto') |
||||||
|
header.push(':sectnums!:') |
||||||
|
header.push(':imagesdir:') |
||||||
|
header.push(':image-width: 1000px') |
||||||
|
header.push(':max-width: 1000px') |
||||||
|
if (author) header.push(`:author: ${escapeInline(author)}`) |
||||||
|
header.push(`:version: ${escapeInline(version)}`) |
||||||
|
header.push(`:revnumber: ${escapeInline(version)}`) |
||||||
|
if (metadata.releaseDate?.trim()) { |
||||||
|
header.push(`:revdate: ${escapeInline(metadata.releaseDate.trim())}`) |
||||||
|
} |
||||||
|
if (metadata.source?.trim()) { |
||||||
|
header.push(`:source: ${escapeInline(metadata.source.trim())}`) |
||||||
|
} |
||||||
|
if (metadata.type?.trim()) { |
||||||
|
header.push(`:publication-type: ${escapeInline(metadata.type.trim())}`) |
||||||
|
} |
||||||
|
if (metadata.language?.trim()) { |
||||||
|
header.push(`:lang: ${escapeInline(metadata.language.trim())}`) |
||||||
|
} |
||||||
|
if (image) { |
||||||
|
header.push(`:front-cover-image: ${image}`) |
||||||
|
header.push(`:epub-cover-image: ${image}`) |
||||||
|
header.push(`:ebook-cover-image: ${image}`) |
||||||
|
} |
||||||
|
|
||||||
|
const bodyParts: string[] = [] |
||||||
|
if (metadata.summary?.trim()) { |
||||||
|
bodyParts.push('[abstract]', '____', metadata.summary.trim(), '____', '') |
||||||
|
} |
||||||
|
|
||||||
|
appendIndexBody(rootIndex, eventsByAddress, fetched, 2, bodyParts) |
||||||
|
|
||||||
|
const content = `${header.join('\n')}\n\n${bodyParts.join('\n')}`.trimEnd() + '\n' |
||||||
|
|
||||||
|
return { content, title, author, image } |
||||||
|
} |
||||||
|
|
||||||
|
/** Register events from a batch fetch into address/id maps. */ |
||||||
|
export function indexPublicationEvents( |
||||||
|
target: Map<string, Event>, |
||||||
|
events: Iterable<Event> |
||||||
|
): void { |
||||||
|
for (const event of events) { |
||||||
|
target.set(event.id, event) |
||||||
|
const addr = eventTagAddress(event) |
||||||
|
if (addr) target.set(addr, event) |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,142 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { convertAsciiDocViaServer, isAsciiDoctorServerConfigured } from '@/lib/asciidoctor-server-client' |
||||||
|
import { eventTagAddress } from '@/lib/publication-index' |
||||||
|
import { |
||||||
|
assemblePublicationAsciidoc, |
||||||
|
indexPublicationEvents, |
||||||
|
orderedPublicationRefsFromIndex |
||||||
|
} from '@/lib/publication-asciidoc-assembler' |
||||||
|
import { |
||||||
|
batchFetchPublicationSectionEvents, |
||||||
|
buildPublicationSectionRelayUrls, |
||||||
|
publicationRefKey |
||||||
|
} from '@/lib/publication-section-fetch' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
|
||||||
|
export type PublicationDownloadFormat = 'adoc' | 'epub' | 'pdf' |
||||||
|
|
||||||
|
const MAX_NESTED_PUBLICATIONS = 128 |
||||||
|
const MAX_TOTAL_EVENTS = 5000 |
||||||
|
|
||||||
|
export { isAsciiDoctorServerConfigured } |
||||||
|
|
||||||
|
function safeFilename(title: string, extension: string): string { |
||||||
|
const safe = title.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^[\W_]+|[\W_]+$/g, '') |
||||||
|
const base = (safe || 'publication').slice(0, 80) |
||||||
|
return `${base}.${extension}` |
||||||
|
} |
||||||
|
|
||||||
|
function downloadBlob(filename: string, blob: Blob): void { |
||||||
|
const url = URL.createObjectURL(blob) |
||||||
|
const a = document.createElement('a') |
||||||
|
a.href = url |
||||||
|
a.download = filename |
||||||
|
document.body.appendChild(a) |
||||||
|
a.click() |
||||||
|
document.body.removeChild(a) |
||||||
|
URL.revokeObjectURL(url) |
||||||
|
} |
||||||
|
|
||||||
|
async function mergeRelayUrls(index: Event, relayUrls: string[]): Promise<string[]> { |
||||||
|
const refs = orderedPublicationRefsFromIndex(index) |
||||||
|
const primary = refs.length |
||||||
|
? await buildPublicationSectionRelayUrls(index, refs, 40, false) |
||||||
|
: [] |
||||||
|
const fallback = refs.length |
||||||
|
? await buildPublicationSectionRelayUrls(index, refs, 80, true) |
||||||
|
: [] |
||||||
|
return [...new Set([...primary, ...fallback, ...relayUrls])] |
||||||
|
} |
||||||
|
|
||||||
|
/** Fetch nested publication index + section events for export. */ |
||||||
|
export async function fetchPublicationTreeForExport( |
||||||
|
rootIndex: Event, |
||||||
|
relayUrls: string[] |
||||||
|
): Promise<Map<string, Event>> { |
||||||
|
const fetched = new Map<string, Event>() |
||||||
|
indexPublicationEvents(fetched, [rootIndex]) |
||||||
|
|
||||||
|
const queue: Event[] = [rootIndex] |
||||||
|
const visitedPublicationIds = new Set<string>() |
||||||
|
let traversedPublications = 0 |
||||||
|
|
||||||
|
while (queue.length > 0) { |
||||||
|
const publication = queue.shift()! |
||||||
|
if (visitedPublicationIds.has(publication.id)) continue |
||||||
|
visitedPublicationIds.add(publication.id) |
||||||
|
traversedPublications++ |
||||||
|
|
||||||
|
if (traversedPublications > MAX_NESTED_PUBLICATIONS) break |
||||||
|
|
||||||
|
const refs = orderedPublicationRefsFromIndex(publication) |
||||||
|
if (refs.length === 0) continue |
||||||
|
|
||||||
|
const relays = await mergeRelayUrls(publication, relayUrls) |
||||||
|
const resolved = await batchFetchPublicationSectionEvents(refs, relays) |
||||||
|
|
||||||
|
for (const ref of refs) { |
||||||
|
if (fetched.size >= MAX_TOTAL_EVENTS) break |
||||||
|
const ev = resolved.get(publicationRefKey(ref)) |
||||||
|
if (!ev) continue |
||||||
|
indexPublicationEvents(fetched, [ev]) |
||||||
|
if (ev.kind === ExtendedKind.PUBLICATION && !visitedPublicationIds.has(ev.id)) { |
||||||
|
queue.push(ev) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (fetched.size >= MAX_TOTAL_EVENTS) break |
||||||
|
} |
||||||
|
|
||||||
|
return fetched |
||||||
|
} |
||||||
|
|
||||||
|
export async function assemblePublicationForExport( |
||||||
|
rootIndex: Event, |
||||||
|
relayUrls: string[] |
||||||
|
): Promise<ReturnType<typeof assemblePublicationAsciidoc>> { |
||||||
|
const fetched = await fetchPublicationTreeForExport(rootIndex, relayUrls) |
||||||
|
const eventsByAddress = new Map<string, Event>() |
||||||
|
const seenIds = new Set<string>() |
||||||
|
for (const ev of fetched.values()) { |
||||||
|
if (seenIds.has(ev.id)) continue |
||||||
|
seenIds.add(ev.id) |
||||||
|
const addr = eventTagAddress(ev) |
||||||
|
if (addr) eventsByAddress.set(addr, ev) |
||||||
|
} |
||||||
|
const assembled = assemblePublicationAsciidoc(rootIndex, fetched, eventsByAddress) |
||||||
|
if (!assembled.content.trim()) { |
||||||
|
throw new Error('Publication has no exportable content.') |
||||||
|
} |
||||||
|
return assembled |
||||||
|
} |
||||||
|
|
||||||
|
export async function exportPublicationDownload( |
||||||
|
rootIndex: Event, |
||||||
|
format: PublicationDownloadFormat, |
||||||
|
relayUrls: string[] |
||||||
|
): Promise<{ filename: string }> { |
||||||
|
const assembled = await assemblePublicationForExport(rootIndex, relayUrls) |
||||||
|
|
||||||
|
if (format === 'adoc') { |
||||||
|
const blob = new Blob([assembled.content], { type: 'text/plain;charset=utf-8' }) |
||||||
|
const filename = safeFilename(assembled.title, 'adoc') |
||||||
|
downloadBlob(filename, blob) |
||||||
|
return { filename } |
||||||
|
} |
||||||
|
|
||||||
|
if (!isAsciiDoctorServerConfigured()) { |
||||||
|
throw new Error('Publication export server is not configured (VITE_ASCIIDOCTOR_SERVER_URL).') |
||||||
|
} |
||||||
|
|
||||||
|
const serverFormat = format === 'epub' ? 'epub3' : 'pdf' |
||||||
|
const converted = await convertAsciiDocViaServer( |
||||||
|
serverFormat, |
||||||
|
assembled.content, |
||||||
|
assembled.title, |
||||||
|
assembled.author, |
||||||
|
assembled.image || null |
||||||
|
) |
||||||
|
const filename = safeFilename(assembled.title, converted.extension) |
||||||
|
downloadBlob(filename, converted.blob) |
||||||
|
return { filename } |
||||||
|
} |
||||||
Loading…
Reference in new issue