Degrade plugin content gracefully in HTML, PDF, and office exports (#79)
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

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
This commit is contained in:
Claude Fable 5 2026-07-11 14:13:09 +02:00
parent ef1c31dd2c
commit 9e8ebfe49c
10 changed files with 395 additions and 7 deletions

View File

@ -24,6 +24,8 @@ import { ConversionRequest, ConversionResult, PandocConverter } from './pandoc.c
* record the input they are handed, so image-inlining / font-inlining is checked * record the input they are handed, so image-inlining / font-inlining is checked
* without a live sidecar. */ * without a live sidecar. */
const enc = (text: string) => new TextEncoder().encode(text);
const PNG_BASE64 = const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; '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<void> {
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 =
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><rect width="5" height="5"></rect></svg>';
// Exactly the attribute encoding the shared HTML renderer applies.
const escapeAttr = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const svgData = escapeAttr(JSON.stringify({ source: 'graph', svg }));
const html =
`<div class="dt-plugin-block" data-plugin-block="mermaid/diagram" data-plugin-data="${svgData}">[mermaid/diagram]</div>` +
'<div class="dt-plugin-block" data-plugin-block="fx-toc/main" data-plugin-data="{}">[fx-toc/main]</div>' +
'<div class="dt-plugin-block" data-plugin-block="fx-gone/main" data-plugin-data="{}">[fx-gone/main]</div>';
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('<rect');
expect(renderer.lastHtml).not.toContain('<script>');
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 () => { it('fails a PDF export when the renderer is down', async () => {
renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded'); renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded');
const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '<p>x</p>'); const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '<p>x</p>');

View File

@ -17,6 +17,7 @@ import { PinoLogger } from 'nestjs-pino';
import { AppConfig } from '../config/app-config.service'; import { AppConfig } from '../config/app-config.service';
import { FileStorageService } from '../files/file-storage.service'; import { FileStorageService } from '../files/file-storage.service';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PluginsService } from '../plugins/plugins.service'; import { PluginsService } from '../plugins/plugins.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
@ -44,6 +45,7 @@ export class ExportService {
private readonly storage: FileStorageService, private readonly storage: FileStorageService,
private readonly jobs: ConversionJobService, private readonly jobs: ConversionJobService,
private readonly plugins: PluginsService, private readonly plugins: PluginsService,
private readonly fallbacks: PluginFallbackRenderer,
private readonly config: AppConfig, private readonly config: AppConfig,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
@ -175,7 +177,10 @@ export class ExportService {
}); });
if (!page) throw new NotFoundException(); 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 dataUriById = await this.inlineImages(page.pondId, imageFileIds(markdown));
const document = markdownForDocument(markdown, dataUriById); const document = markdownForDocument(markdown, dataUriById);
@ -212,7 +217,10 @@ export class ExportService {
if (!page) throw new NotFoundException(); if (!page) throw new NotFoundException();
const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts; 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({ const html = buildPdfHtml({
title: page.title, title: page.title,
pondName: page.pond.name, pondName: page.pond.name,

View File

@ -30,8 +30,8 @@ function escapeHtml(value: string): string {
* request), a title header sits above the content, and print CSS sets the page * 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. * size and sensible break behaviour. Page numbers come from Gotenberg's footer.
* *
* TODO(#79): once plugins land (M7), plugin blocks must render their declared * Plugin blocks arrive already degraded to their static form the caller runs
* static `fallback` here (ADR 0008) instead of whatever the content cache holds. * `PluginFallbackRenderer.applyToHtml` (#79) before building this document.
*/ */
export function buildPdfHtml(params: PdfHtmlParams): string { export function buildPdfHtml(params: PdfHtmlParams): string {
const { fonts } = params; const { fonts } = params;

View File

@ -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 class="dt-plugin-block" data-plugin-block="([^"/]+)\/([^"]*)" data-plugin-data="([^"]*)">.*?<\/div>/gs;
const IMAGE_MIME: Record<string, string> = {
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(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* 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<string> {
const matches = [...html.matchAll(BLOCK_PLACEHOLDER)];
if (matches.length === 0) return html;
const replacements = new Map<string, string>();
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<string> {
const inner = await this.staticContentFor(pluginId, dataAttribute);
return `<figure class="dt-plugin-fallback">${inner}</figure>`;
}
private async staticContentFor(pluginId: string, dataAttribute: string): Promise<string> {
// 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 `<p>${escapeHtml(view.fallback.value)}</p>`;
}
if (view?.fallback?.type === 'image') {
const dataUri = await this.imageDataUri(pluginId, view.fallback.url);
if (dataUri) {
return `<img src="${dataUri}" alt="${escapeHtml(view.name)}">`;
}
}
if (view) return `<p>${escapeHtml(`[${view.name}]`)}</p>`;
// 3. Nothing known about the plugin at all.
return `<p>${escapeHtml(NEUTRAL_MARKER)}</p>`;
}
/** 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<string | null> {
// fallbackFor builds `/api/v1/plugins/<id>/<version>/<relPath>`.
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<string> {
if (!markdown.includes('dorfteich-plugin') && !markdown.includes(':::')) return markdown;
const doc = markdownToDoc(markdown);
const texts = new Map<string, string>();
// Resolve every referenced plugin once; unknown ids get the neutral marker.
const ids = new Set<string>();
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 `<style>` block, for
* prepending to server-rendered HTML (public view). Empty when none. */
async sectionStyleTag(pondId: string): Promise<string> {
const css = await this.plugins.sectionStyleCssForPond(pondId);
return css.trim() === '' ? '' : `<style>${css}</style>`;
}
}

View File

@ -4,6 +4,7 @@ import { CommonModule } from '../common/common.module';
import { PluginAdminController } from './plugin-admin.controller'; import { PluginAdminController } from './plugin-admin.controller';
import { PluginAssetsController } from './plugin-assets.controller'; import { PluginAssetsController } from './plugin-assets.controller';
import { PluginFallbackRenderer } from './plugin-fallback-renderer';
import { PluginPondController } from './plugin-pond.controller'; import { PluginPondController } from './plugin-pond.controller';
import { PluginPackageService } from './plugin-package.service'; import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service'; import { PluginStorageService } from './plugin-storage.service';
@ -19,7 +20,13 @@ import { PluginsService } from './plugins.service';
@Module({ @Module({
imports: [CommonModule], imports: [CommonModule],
controllers: [PluginAdminController, PluginAssetsController, PluginPondController], controllers: [PluginAdminController, PluginAssetsController, PluginPondController],
providers: [PluginPackageService, PluginStorageService, PluginsService, PluginWatcherService], providers: [
exports: [PluginsService], PluginFallbackRenderer,
PluginPackageService,
PluginStorageService,
PluginsService,
PluginWatcherService,
],
exports: [PluginFallbackRenderer, PluginsService],
}) })
export class PluginsModule {} export class PluginsModule {}

View File

@ -1,5 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PluginsModule } from '../plugins/plugins.module';
import { PublicController } from './public.controller'; import { PublicController } from './public.controller';
import { PublicService } from './public.service'; import { PublicService } from './public.service';
@ -10,6 +12,7 @@ import { PublicService } from './public.service';
* marks `GET /media/:fileId` public too. * marks `GET /media/:fileId` public too.
*/ */
@Module({ @Module({
imports: [PluginsModule],
controllers: [PublicController], controllers: [PublicController],
providers: [PublicService], providers: [PublicService],
}) })

View File

@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { Pond, User } from '@prisma/client'; import { Pond, User } from '@prisma/client';
import { PermissionService } from '../permissions/permission.service'; import { PermissionService } from '../permissions/permission.service';
import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** The JSON the SPA renders for an anonymous (or any) reader of a public page. */ /** The JSON the SPA renders for an anonymous (or any) reader of a public page. */
@ -33,6 +34,7 @@ export class PublicService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly permissions: PermissionService, private readonly permissions: PermissionService,
private readonly fallbacks: PluginFallbackRenderer,
) {} ) {}
private async resolve( private async resolve(
@ -57,12 +59,17 @@ export class PublicService {
async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> { async content(user: User | null, pondSlug: string, pageSlug: string): Promise<PublicPageContent> {
const { pond, page } = await this.resolve(user, pondSlug, pageSlug); const { pond, page } = await this.resolve(user, pondSlug, pageSlug);
const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); 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 { return {
pondName: pond.name, pondName: pond.name,
pondSlug: pond.slug, pondSlug: pond.slug,
title: page.title, title: page.title,
slug: page.slug, slug: page.slug,
html: resolveMediaUrls(cache?.html ?? ''), html: styleTag + resolveMediaUrls(withFallbacks),
updatedAt: (cache?.updatedAt ?? new Date()).toISOString(), updatedAt: (cache?.updatedAt ?? new Date()).toISOString(),
}; };
} }

View File

@ -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('');
});
});

View File

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

View File

@ -1,5 +1,6 @@
export * from './schema'; export * from './schema';
export * from './markdown'; export * from './markdown';
export * from './export-fallbacks';
export * from './html'; export * from './html';
export * from './plain-text'; export * from './plain-text';
export * from './outline'; export * from './outline';