From 9e8ebfe49c18e9c0073ffde3bca62b80b97d45c0 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 14:13:09 +0200 Subject: [PATCH] Degrade plugin content gracefully in HTML, PDF, and office exports (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes M7: exports and the public read view no longer show raw plugin placeholders (ADR 0008/0009). - PluginFallbackRenderer (api): replaces each plugin-block placeholder in content-cache HTML with its best static form — the block's stored SVG snapshot (block data is author-controlled, so it passes the same DOMPurify sanitizer as uploaded SVG files before entering host HTML), else the manifest fallback from the stored snapshot (text, or an image inlined as a data URI so network-isolated renderers work; tombstone-safe for uninstalled plugins), else the literal '[plugin content]' marker. - Office exports (docx/odt): the export markdown is degraded before pandoc — GFM knows neither the dorfteich-plugin fence nor the section fenced div, so blocks become their fallback text and sections plain quoted blocks (shared replacePluginNodesForExport, AST-level so nesting and embedded blocks inside sections survive). - PDF export applies the HTML fallback pass before building the Gotenberg document — resolving the TODO left in #67. - Public read view: the same fallback pass plus the pond's active section-style CSS inlined as a `; + } +} 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';