dorfteich/packages/shared/src/editor-schema/export-fallbacks.ts
Claude Fable 5 9e8ebfe49c
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m57s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m2s
CI / Import/export fidelity gate (push) Successful in 46s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m10s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m12s
Degrade plugin content gracefully in HTML, PDF, and office exports (#79)
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 <style> block, so public pages show
  styled sections and static plugin content without any plugin runtime.
- Covered in export.service.db.test (snapshot SVG sanitized — hostile
  <script> stripped; manifest text; tombstone text; quoted sections and
  no fence artifacts in the pandoc input) and shared export-fallbacks
  tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 14:13:09 +02:00

50 lines
1.9 KiB
TypeScript

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<string>();
doc.descendants((node) => {
if (node.type.name === 'plugin_block') ids.add(node.attrs.pluginId as string);
return true;
});
return [...ids];
}