Browse Source

add EPUB and PDF downloads

imwald
Silberengel 1 week ago
parent
commit
d85d028a21
  1. 2
      .env.development
  2. 96
      src/components/NoteOptions/useMenuActions.tsx
  3. 7
      src/constants.ts
  4. 7
      src/i18n/locales/de.ts
  5. 7
      src/i18n/locales/en.ts
  6. 80
      src/lib/asciidoctor-server-client.ts
  7. 70
      src/lib/publication-asciidoc-assembler.test.ts
  8. 205
      src/lib/publication-asciidoc-assembler.ts
  9. 142
      src/lib/publication-export.ts
  10. 13
      vite.config.ts

2
.env.development

@ -4,3 +4,5 @@ VITE_PROXY_SERVER=/sites @@ -4,3 +4,5 @@ VITE_PROXY_SERVER=/sites
VITE_READ_ALOUD_TTS_URL=/api/piper-tts
VITE_LANGUAGE_TOOL_URL=/api/languagetool
VITE_TRANSLATE_URL=/api/translate
# Wikistr AsciiDoctor sidecar (EPUB/PDF); same API as unfold — proxy to localhost:8091 in dev.
VITE_ASCIIDOCTOR_SERVER_URL=/api/asciidoctor

96
src/components/NoteOptions/useMenuActions.tsx

@ -42,7 +42,10 @@ import { @@ -42,7 +42,10 @@ import {
isEventInPinList
} from '@/lib/replaceable-list-latest'
import indexedDb from '@/services/indexed-db.service'
import { generateBech32IdFromATag } from '@/lib/tag'
import {
exportPublicationDownload,
isAsciiDoctorServerConfigured
} from '@/lib/publication-export'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useMuteList } from '@/contexts/mute-list-context'
@ -58,6 +61,7 @@ import { @@ -58,6 +61,7 @@ import {
Bell,
BellOff,
Bookmark,
Download,
Pin,
Settings,
Share2,
@ -839,52 +843,22 @@ export function useMenuActions({ @@ -839,52 +843,22 @@ export function useMenuActions({
if (!isArticleType) return
try {
const title = articleMetadata?.title || 'Article'
let content = event.content
let filename = `${title}.adoc`
// For publications (30040), export all referenced sections
if (event.kind === ExtendedKind.PUBLICATION) {
const contentParts: string[] = []
// Extract all 'a' tag references
const aTags = event.tags.filter(tag => tag[0] === 'a' && tag[1])
// Fetch all referenced events
const fetchPromises = aTags.map(async (tag) => {
try {
const coordinate = tag[1]
const [kindStr] = coordinate.split(':')
const kind = parseInt(kindStr)
if (isNaN(kind)) return null
// Try to fetch the event
const aTag = ['a', coordinate, tag[2] || '', tag[3] || '']
const bech32Id = generateBech32IdFromATag(aTag)
if (bech32Id) {
const fetchedEvent = await eventService.fetchEvent(bech32Id)
return fetchedEvent
}
return null
} catch (error) {
logger.warn('[NoteOptions] Error fetching referenced event for export:', error)
return null
}
closeDrawer()
await toast.promise(exportPublicationDownload(event, 'adoc', relayUrls), {
loading: t('Exporting publication…'),
success: () => t('Article exported as AsciiDoc'),
error: (err: unknown) =>
t('Failed to export article') +
': ' +
(err instanceof Error ? err.message : String(err))
})
const referencedEvents = (await Promise.all(fetchPromises)).filter((e): e is Event => e !== null)
// Combine all events into one AsciiDoc document
for (const refEvent of referencedEvents) {
const refTitle = refEvent.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled'
contentParts.push(`= ${refTitle}\n\n${refEvent.content}\n\n`)
return
}
if (contentParts.length > 0) {
content = contentParts.join('\n')
}
}
const title = articleMetadata?.title || 'Article'
const content = event.content
const filename = `${title}.adoc`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
@ -904,6 +878,19 @@ export function useMenuActions({ @@ -904,6 +878,19 @@ export function useMenuActions({
}
}
const exportPublicationAs = (format: 'epub' | 'pdf') => {
closeDrawer()
void toast.promise(exportPublicationDownload(event, format, relayUrls), {
loading: t('Exporting publication…'),
success: () =>
format === 'epub' ? t('Publication exported as EPUB') : t('Publication exported as PDF'),
error: (err: unknown) =>
t('Failed to export publication') +
': ' +
(err instanceof Error ? err.message : String(err))
})
}
// View on external sites functions
const handleViewOnAlexandria = () => {
if (!naddr) return
@ -1441,6 +1428,29 @@ export function useMenuActions({ @@ -1441,6 +1428,29 @@ export function useMenuActions({
})
}
if (event.kind === ExtendedKind.PUBLICATION) {
actions.push({
icon: Download,
label: t('Download as AsciiDoc'),
separator: actions.length === savesGroupStartIndex && savesGroupNeedsSeparator,
onClick: () => {
void exportAsAsciidoc()
}
})
if (isAsciiDoctorServerConfigured()) {
actions.push({
icon: Download,
label: t('Download as EPUB'),
onClick: () => exportPublicationAs('epub')
})
actions.push({
icon: Download,
label: t('Download as PDF'),
onClick: () => exportPublicationAs('pdf')
})
}
}
// Delete only when signed in as the author with a signing key (not read-only npub)
if (canSignEvents && pubkey && event.pubkey === pubkey) {
actions.push({

7
src/constants.ts

@ -29,6 +29,13 @@ export const LANGUAGE_TOOL_URL = @@ -29,6 +29,13 @@ export const LANGUAGE_TOOL_URL =
export const TRANSLATE_URL =
(import.meta.env.VITE_TRANSLATE_URL as string | undefined)?.trim() || ''
/**
* Wikistr/unfold AsciiDoctor sidecar for EPUB/PDF export (`POST /convert/{epub|pdf|html5}`).
* Dev default: `/api/asciidoctor` (Vite proxy localhost:8091). Production: set full URL or same-origin path.
*/
export const ASCIIDOCTOR_SERVER_URL =
(import.meta.env.VITE_ASCIIDOCTOR_SERVER_URL as string | undefined)?.trim() || ''
/** HiveTalk (WebRTC video call) base URL; override with VITE_HIVETALK_BASE_URL for self-hosted instances. */
export const HIVETALK_BASE_URL =
(import.meta.env.VITE_HIVETALK_BASE_URL as string | undefined) ?? 'https://honey.hivetalk.org'

7
src/i18n/locales/de.ts

@ -1897,6 +1897,13 @@ export default { @@ -1897,6 +1897,13 @@ export default {
"Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.":
"Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.",
'Article exported as AsciiDoc': 'Article exported as AsciiDoc',
'Download as AsciiDoc': 'Als AsciiDoc herunterladen',
'Download as EPUB': 'Als EPUB herunterladen',
'Download as PDF': 'Als PDF herunterladen',
'Exporting publication…': 'Publikation wird exportiert…',
'Publication exported as EPUB': 'Publikation als EPUB exportiert',
'Publication exported as PDF': 'Publikation als PDF exportiert',
'Failed to export publication': 'Export der Publikation fehlgeschlagen',
'Article exported as Markdown': 'Article exported as Markdown',
'Article title (optional)': 'Article title (optional)',
articleDTagDefaultHint:

7
src/i18n/locales/en.ts

@ -1924,6 +1924,13 @@ export default { @@ -1924,6 +1924,13 @@ export default {
"Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.":
"Are you sure you want to unregister the service worker? This will clear this app's service worker caches and you will need to reload the page.",
'Article exported as AsciiDoc': 'Article exported as AsciiDoc',
'Download as AsciiDoc': 'Download as AsciiDoc',
'Download as EPUB': 'Download as EPUB',
'Download as PDF': 'Download as PDF',
'Exporting publication…': 'Exporting publication…',
'Publication exported as EPUB': 'Publication exported as EPUB',
'Publication exported as PDF': 'Publication exported as PDF',
'Failed to export publication': 'Failed to export publication',
'Article exported as Markdown': 'Article exported as Markdown',
'Article title (optional)': 'Article title (optional)',
articleDTagDefaultHint:

80
src/lib/asciidoctor-server-client.ts

@ -0,0 +1,80 @@ @@ -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 }
}

70
src/lib/publication-asciidoc-assembler.test.ts

@ -0,0 +1,70 @@ @@ -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.')
})
})

205
src/lib/publication-asciidoc-assembler.ts

@ -0,0 +1,205 @@ @@ -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)
}
}

142
src/lib/publication-export.ts

@ -0,0 +1,142 @@ @@ -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 }
}

13
vite.config.ts

@ -50,7 +50,7 @@ function fullReloadOnProvidersAndPages(): Plugin { @@ -50,7 +50,7 @@ function fullReloadOnProvidersAndPages(): Plugin {
}
/** Loopback targets in `server.proxy` — optional in dev; see PROXY_SETUP.md. */
const OPTIONAL_DEV_PROXY_LOOPBACK_PORTS = [4000, 5000, 8010, 8090, 9876] as const
const OPTIONAL_DEV_PROXY_LOOPBACK_PORTS = [4000, 5000, 8010, 8090, 8091, 9876] as const
function blobFromLogArgs(args: unknown[]): string {
return args
@ -69,6 +69,7 @@ const DEV_INDEX_RELAY_PROXY_PATH_MARKERS = [ @@ -69,6 +69,7 @@ const DEV_INDEX_RELAY_PROXY_PATH_MARKERS = [
'/api/languagetool',
'/v2/',
'/api/piper-tts',
'/api/asciidoctor',
'/api/translate',
'/sites',
'/dev-index-relay',
@ -263,6 +264,16 @@ export default defineConfig(({ mode }) => { @@ -263,6 +264,16 @@ export default defineConfig(({ mode }) => {
hint: 'Start LibreTranslate (or compatible API) on :5000 — see PROXY_SETUP.md'
})
},
'/api/asciidoctor': {
target: 'http://127.0.0.1:8091',
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api\/asciidoctor/u, '') || '/',
configure: jsonProxyErrorHandler(503, {
ok: false,
error: 'asciidoctor_proxy_unreachable',
hint: 'Start the Wikistr AsciiDoctor server on :8091 (see ../wikistr/deployment)'
})
},
'/sites': {
target: 'http://127.0.0.1:8090',
changeOrigin: true,

Loading…
Cancel
Save