diff --git a/apps/api/src/import-export/export.service.db.test.ts b/apps/api/src/import-export/export.service.db.test.ts index f1bc708..513c82b 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -24,6 +24,8 @@ import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.c * record the input they are handed, so image-inlining / font-inlining is checked * without a live sidecar. */ +const enc = (text: string) => new TextEncoder().encode(text); + const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; @@ -427,6 +429,106 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { } }); + it('degrades plugin blocks in exports: snapshot SVG, manifest text, tombstone (#79)', async () => { + const plugins = app.get(PluginsService); + async function removeIfInstalled(id: string): Promise { + await plugins.setMode(id, 'disabled').catch(() => undefined); + await plugins.uninstall(id).catch(() => undefined); + } + // A block plugin with a text fallback; a second one that gets uninstalled. + const blockManifest = (id: string, fallback: string) => ({ + id, + name: `Fixture ${id}`, + version: '1.0.0', + apiVersion: '1', + kind: 'code', + extensionPoints: [{ type: 'block', id: 'main', title: { de: id, en: id } }], + permissions: [], + fallback: { type: 'text', value: fallback }, + license: 'MIT', + }); + await removeIfInstalled('fx-toc'); + await removeIfInstalled('fx-gone'); + await plugins.install( + Buffer.from( + zipSync({ + 'manifest.json': enc(JSON.stringify(blockManifest('fx-toc', '[Table of contents]'))), + 'plugin.js': enc('export default {}'), + }), + ), + ); + await plugins.install( + Buffer.from( + zipSync({ + 'manifest.json': enc(JSON.stringify(blockManifest('fx-gone', '[Gone but text]'))), + 'plugin.js': enc('export default {}'), + }), + ), + ); + await plugins.uninstall('fx-gone'); // tombstone: manifest snapshot remains + + try { + // Content cache as the shared renderer emits it: a diagram block with a + // stored SVG snapshot (hostile bits included), a toc block without one, + // and a block of the uninstalled plugin. + const svg = + ''; + // Exactly the attribute encoding the shared HTML renderer applies. + const escapeAttr = (value: string) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + const svgData = escapeAttr(JSON.stringify({ source: 'graph', svg })); + const html = + `
[mermaid/diagram]
` + + '
[fx-toc/main]
' + + '
[fx-gone/main]
'; + const markdown = [ + '```dorfteich-plugin fx-toc/main', + '{}', + '```', + '', + '::: {data-section-style="p/callout"}', + 'Sectioned text.', + ':::', + ].join('\n'); + const slug = await seedPage(personalPondId, 'Fallback Exports', markdown, html); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + + // PDF: snapshot SVG (sanitized!), manifest text, tombstone text. + await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'pdf' }) + .expect(201); + await worker.drain(); + expect(renderer.lastHtml).toContain(''); + expect(renderer.lastHtml).toContain('[Table of contents]'); + expect(renderer.lastHtml).toContain('[Gone but text]'); + expect(renderer.lastHtml).not.toContain('dt-plugin-block"'); + + // docx: fallback text, quoted section, no fence artifacts. + await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'docx' }) + .expect(201); + await worker.drain(); + expect(fake.lastInput).toContain('\\[Table of contents\\]'); + expect(fake.lastInput).toContain('> Sectioned text.'); + expect(fake.lastInput).not.toContain('dorfteich-plugin'); + expect(fake.lastInput).not.toContain(':::'); + } finally { + await removeIfInstalled('fx-toc'); + await removeIfInstalled('fx-gone'); + } + }); + it('fails a PDF export when the renderer is down', async () => { renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded'); const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '

x

'); diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index 5cec25a..d18d375 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -17,6 +17,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AppConfig } from '../config/app-config.service'; import { FileStorageService } from '../files/file-storage.service'; import { PermissionService } from '../permissions/permission.service'; +import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginsService } from '../plugins/plugins.service'; import { PrismaService } from '../prisma/prisma.service'; @@ -44,6 +45,7 @@ export class ExportService { private readonly storage: FileStorageService, private readonly jobs: ConversionJobService, private readonly plugins: PluginsService, + private readonly fallbacks: PluginFallbackRenderer, private readonly config: AppConfig, private readonly logger: PinoLogger, ) { @@ -175,7 +177,10 @@ export class ExportService { }); if (!page) throw new NotFoundException(); - const markdown = page.contentCache?.markdown ?? ''; + // Plugin blocks degrade to their fallback text and sections to quoted + // blocks first (#79) — GFM knows neither construct, and pandoc would + // otherwise emit the literal fences into the .docx/.odt. + const markdown = await this.fallbacks.applyToMarkdown(page.contentCache?.markdown ?? ''); const dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown)); const document = markdownForDocument(markdown, dataUriById); @@ -212,7 +217,10 @@ export class ExportService { if (!page) throw new NotFoundException(); const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts; - const bodyHtml = await this.inlineHtmlImages(page.pondId, page.contentCache?.html ?? ''); + // Plugin blocks first become their best static form (#79: stored SVG + // snapshot → manifest fallback → neutral marker), then images inline. + const withFallbacks = await this.fallbacks.applyToHtml(page.contentCache?.html ?? ''); + const bodyHtml = await this.inlineHtmlImages(page.pondId, withFallbacks); const html = buildPdfHtml({ title: page.title, pondName: page.pond.name, diff --git a/apps/api/src/import-export/pdf-html.ts b/apps/api/src/import-export/pdf-html.ts index 749affa..90dcf98 100644 --- a/apps/api/src/import-export/pdf-html.ts +++ b/apps/api/src/import-export/pdf-html.ts @@ -30,8 +30,8 @@ function escapeHtml(value: string): string { * request), a title header sits above the content, and print CSS sets the page * size and sensible break behaviour. Page numbers come from Gotenberg's footer. * - * TODO(#79): once plugins land (M7), plugin blocks must render their declared - * static `fallback` here (ADR 0008) instead of whatever the content cache holds. + * Plugin blocks arrive already degraded to their static form — the caller runs + * `PluginFallbackRenderer.applyToHtml` (#79) before building this document. */ export function buildPdfHtml(params: PdfHtmlParams): string { const { fonts } = params; diff --git a/apps/api/src/plugins/plugin-fallback-renderer.ts b/apps/api/src/plugins/plugin-fallback-renderer.ts new file mode 100644 index 0000000..98172c0 --- /dev/null +++ b/apps/api/src/plugins/plugin-fallback-renderer.ts @@ -0,0 +1,162 @@ +import { readFile } from 'node:fs/promises'; + +import { Injectable } from '@nestjs/common'; +import { markdownToDoc, docToMarkdown, replacePluginNodesForExport } from '@dorfteich/shared'; + +import { sanitizeSvg } from '../files/svg-sanitize'; + +import { PluginStorageService } from './plugin-storage.service'; +import { PluginsService } from './plugins.service'; + +/** The last-resort marker when neither a snapshot nor a manifest fallback + * exists (issue #79's literal wording — document content, not UI chrome). */ +const NEUTRAL_MARKER = '[plugin content]'; + +/** Matches the placeholder div the shared HTML renderer emits for a + * `plugin_block` (editor-schema/html.ts — attribute order is fixed there). */ +const BLOCK_PLACEHOLDER = + /
.*?<\/div>/gs; + +const IMAGE_MIME: Record = { + svg: 'image/svg+xml', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', +}; + +function unescapeAttribute(value: string): string { + return value + .replace(/"/g, '"') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/** + * Degrades plugin content for static renditions (issue #79, ADR 0008): the + * content-cache HTML carries neutral, data-preserving placeholders for + * `plugin_block` nodes; exports and the public read view replace them with + * the best static form available — the block's stored SVG snapshot (present + * for diagram-style plugins, sanitized before it may enter host HTML), else + * the manifest `fallback` from the stored snapshot (which survives uninstall + * as a tombstone), else the neutral marker. + */ +@Injectable() +export class PluginFallbackRenderer { + constructor( + private readonly plugins: PluginsService, + private readonly storage: PluginStorageService, + ) {} + + /** Replaces every plugin-block placeholder in content-cache HTML. */ + async applyToHtml(html: string): Promise { + const matches = [...html.matchAll(BLOCK_PLACEHOLDER)]; + if (matches.length === 0) return html; + + const replacements = new Map(); + for (const match of matches) { + const [whole, pluginId = '', , dataAttribute = ''] = match; + if (replacements.has(whole)) continue; + replacements.set(whole, await this.renderBlock(pluginId, dataAttribute)); + } + + return html.replace(BLOCK_PLACEHOLDER, (whole) => replacements.get(whole) ?? whole); + } + + private async renderBlock(pluginId: string, dataAttribute: string): Promise { + const inner = await this.staticContentFor(pluginId, dataAttribute); + return `
${inner}
`; + } + + private async staticContentFor(pluginId: string, dataAttribute: string): Promise { + // 1. The block's own stored snapshot (e.g. mermaid's rendered SVG). Block + // data is author-controlled, so the SVG must pass the same sanitizer + // as uploaded SVG files before it may enter host-rendered HTML. + try { + const data: unknown = JSON.parse(unescapeAttribute(dataAttribute)); + const svg = (data as { svg?: unknown })?.svg; + if (typeof svg === 'string' && svg.trim() !== '') { + return sanitizeSvg(svg); + } + } catch { + // No usable snapshot — fall through to the manifest fallback. + } + + // 2. The manifest fallback from the stored snapshot (tombstone-safe). + const view = await this.plugins.fallbackFor(pluginId); + if (view?.fallback?.type === 'text') { + return `

${escapeHtml(view.fallback.value)}

`; + } + if (view?.fallback?.type === 'image') { + const dataUri = await this.imageDataUri(pluginId, view.fallback.url); + if (dataUri) { + return `${escapeHtml(view.name)}`; + } + } + if (view) return `

${escapeHtml(`[${view.name}]`)}

`; + + // 3. Nothing known about the plugin at all. + return `

${escapeHtml(NEUTRAL_MARKER)}

`; + } + + /** Inlines a manifest image fallback as a data URI, so the rendition works + * in network-isolated renderers (Gotenberg) and static HTML alike. */ + private async imageDataUri(pluginId: string, url: string): Promise { + // fallbackFor builds `/api/v1/plugins///`. + const match = /^\/api\/v1\/plugins\/[^/]+\/([^/]+)\/(.+)$/.exec(url); + if (!match) return null; + const [, version = '', relPath = ''] = match; + const full = this.storage.assetPath(pluginId, version, relPath); + if (!full || !(await this.storage.assetExists(full))) return null; + const mime = IMAGE_MIME[relPath.split('.').pop()?.toLowerCase() ?? '']; + if (!mime) return null; + const bytes = await readFile(full); + return `data:${mime};base64,${bytes.toString('base64')}`; + } + + /** + * Degrades the export markdown for pandoc (docx/odt, #65): plugin blocks + * become their fallback text, sections become plain quoted blocks — GFM + * knows neither construct, and literal fences in a .docx are broken output. + */ + async applyToMarkdown(markdown: string): Promise { + if (!markdown.includes('dorfteich-plugin') && !markdown.includes(':::')) return markdown; + const doc = markdownToDoc(markdown); + const texts = new Map(); + // Resolve every referenced plugin once; unknown ids get the neutral marker. + const ids = new Set(); + doc.descendants((node) => { + if (node.type.name === 'plugin_block') ids.add(node.attrs.pluginId as string); + return true; + }); + for (const id of ids) { + const view = await this.plugins.fallbackFor(id); + texts.set( + id, + view?.fallback?.type === 'text' + ? view.fallback.value + : view + ? `[${view.name}]` + : NEUTRAL_MARKER, + ); + } + return docToMarkdown(replacePluginNodesForExport(doc, (id) => texts.get(id) ?? NEUTRAL_MARKER)); + } + + /** The pond's active section-style CSS as an inline ``; + } +} diff --git a/apps/api/src/plugins/plugins.module.ts b/apps/api/src/plugins/plugins.module.ts index ffafe50..150fdea 100644 --- a/apps/api/src/plugins/plugins.module.ts +++ b/apps/api/src/plugins/plugins.module.ts @@ -4,6 +4,7 @@ import { CommonModule } from '../common/common.module'; import { PluginAdminController } from './plugin-admin.controller'; import { PluginAssetsController } from './plugin-assets.controller'; +import { PluginFallbackRenderer } from './plugin-fallback-renderer'; import { PluginPondController } from './plugin-pond.controller'; import { PluginPackageService } from './plugin-package.service'; import { PluginStorageService } from './plugin-storage.service'; @@ -19,7 +20,13 @@ import { PluginsService } from './plugins.service'; @Module({ imports: [CommonModule], controllers: [PluginAdminController, PluginAssetsController, PluginPondController], - providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService], - exports: [PluginsService], + providers: [ + PluginFallbackRenderer, + PluginPackageService, + PluginStorageService, + PluginsService, + PluginWatcherService, + ], + exports: [PluginFallbackRenderer, PluginsService], }) export class PluginsModule {} diff --git a/apps/api/src/public/public.module.ts b/apps/api/src/public/public.module.ts index d4e1695..2760e64 100644 --- a/apps/api/src/public/public.module.ts +++ b/apps/api/src/public/public.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; +import { PluginsModule } from '../plugins/plugins.module'; + import { PublicController } from './public.controller'; import { PublicService } from './public.service'; @@ -10,6 +12,7 @@ import { PublicService } from './public.service'; * marks `GET /media/:fileId` public too. */ @Module({ + imports: [PluginsModule], controllers: [PublicController], providers: [PublicService], }) diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index 84109aa..768ab60 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { Pond, User } from '@prisma/client'; import { PermissionService } from '../permissions/permission.service'; +import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PrismaService } from '../prisma/prisma.service'; /** The JSON the SPA renders for an anonymous (or any) reader of a public page. */ @@ -33,6 +34,7 @@ export class PublicService { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionService, + private readonly fallbacks: PluginFallbackRenderer, ) {} private async resolve( @@ -57,12 +59,17 @@ export class PublicService { async content(user: User | null, pondSlug: string, pageSlug: string): Promise { const { pond, page } = await this.resolve(user, pondSlug, pageSlug); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); + // Plugin blocks render their static form (#79), and the pond's active + // section-style CSS travels inline — the public view loads no plugin + // runtime, and the CSS passed the install gate's scoping rules. + const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); + const styleTag = await this.fallbacks.sectionStyleTag(pond.id); return { pondName: pond.name, pondSlug: pond.slug, title: page.title, slug: page.slug, - html: resolveMediaUrls(cache?.html ?? ''), + html: styleTag + resolveMediaUrls(withFallbacks), updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), }; } diff --git a/packages/shared/src/editor-schema/export-fallbacks.test.ts b/packages/shared/src/editor-schema/export-fallbacks.test.ts new file mode 100644 index 0000000..41ee81f --- /dev/null +++ b/packages/shared/src/editor-schema/export-fallbacks.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { collectPluginBlockIds, replacePluginNodesForExport } from './export-fallbacks'; +import { docToMarkdown, markdownToDoc } from './markdown'; + +const SOURCE = [ + '# Title', + '', + '```dorfteich-plugin mermaid/diagram', + '{"source":"graph TD; A-->B"}', + '```', + '', + '::: {data-section-style="section-styles-basic/callout"}', + 'Inside the section.', + '', + '```dorfteich-plugin toc/toc', + '{}', + '```', + ':::', +].join('\n'); + +describe('replacePluginNodesForExport (issue #79)', () => { + it('collects the referenced plugin ids', () => { + expect(collectPluginBlockIds(markdownToDoc(SOURCE)).sort()).toEqual(['mermaid', 'toc']); + }); + + it('degrades blocks to fallback paragraphs and sections to blockquotes', () => { + const doc = replacePluginNodesForExport(markdownToDoc(SOURCE), (pluginId) => + pluginId === 'mermaid' ? '[Mermaid diagram]' : '[plugin content]', + ); + const markdown = docToMarkdown(doc); + expect(markdown).not.toContain('dorfteich-plugin'); + expect(markdown).not.toContain(':::'); + // Brackets arrive markdown-escaped; pandoc renders them literally. + expect(markdown).toContain('\\[Mermaid diagram\\]'); + // The section became a quoted block that still carries its content and + // the embedded block's fallback. + expect(markdown).toContain('> Inside the section.'); + expect(markdown).toContain('> \\[plugin content\\]'); + }); + + it('drops a block whose fallback resolves to empty text without artifacts', () => { + const doc = replacePluginNodesForExport( + markdownToDoc('```dorfteich-plugin gone/away\n{}\n```'), + () => '', + ); + expect(docToMarkdown(doc)).toBe(''); + }); +}); diff --git a/packages/shared/src/editor-schema/export-fallbacks.ts b/packages/shared/src/editor-schema/export-fallbacks.ts new file mode 100644 index 0000000..254378c --- /dev/null +++ b/packages/shared/src/editor-schema/export-fallbacks.ts @@ -0,0 +1,49 @@ +import { Fragment, Node } from 'prosemirror-model'; + +/** + * Degrades plugin-owned nodes for office exports (issue #79, ADR 0008/0009): + * pandoc receives GFM, which knows neither the `dorfteich-plugin` fence nor + * the section fenced div — left alone they would surface as literal fence + * text in the .docx. So before serializing the export markdown: + * + * - a `plugin_block` becomes a paragraph with its fallback text (the caller + * resolves it from the manifest snapshot; plugins are a server-side + * registry this package knows nothing about); + * - a `section` becomes a blockquote of its content — the "plain quoted + * block" rendition of a styled container in a format with no CSS. + */ +export function replacePluginNodesForExport( + doc: Node, + fallbackTextFor: (pluginId: string, blockType: string) => string, +): Node { + const schema = doc.type.schema; + + function mapNode(node: Node): Node { + if (node.type.name === 'plugin_block') { + const text = fallbackTextFor( + node.attrs.pluginId as string, + node.attrs.blockType as string, + ).trim(); + return schema.node('paragraph', null, text === '' ? [] : [schema.text(text)]); + } + const children: Node[] = []; + node.forEach((child) => children.push(mapNode(child))); + if (node.type.name === 'section') { + return schema.node('blockquote', null, Fragment.from(children)); + } + return node.isLeaf ? node : node.copy(Fragment.from(children)); + } + + return mapNode(doc); +} + +/** The plugin ids referenced by `plugin_block` nodes in `doc`, for resolving + * their fallbacks in one batch before {@link replacePluginNodesForExport}. */ +export function collectPluginBlockIds(doc: Node): string[] { + const ids = new Set(); + doc.descendants((node) => { + if (node.type.name === 'plugin_block') ids.add(node.attrs.pluginId as string); + return true; + }); + return [...ids]; +} diff --git a/packages/shared/src/editor-schema/index.ts b/packages/shared/src/editor-schema/index.ts index 01a549a..9ee25c1 100644 --- a/packages/shared/src/editor-schema/index.ts +++ b/packages/shared/src/editor-schema/index.ts @@ -1,5 +1,6 @@ export * from './schema'; export * from './markdown'; +export * from './export-fallbacks'; export * from './html'; export * from './plain-text'; export * from './outline';