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
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:
parent
ef1c31dd2c
commit
9e8ebfe49c
@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
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>');
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
162
apps/api/src/plugins/plugin-fallback-renderer.ts
Normal file
162
apps/api/src/plugins/plugin-fallback-renderer.ts
Normal 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(/"/g, '"')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.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<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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
|||||||
@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
49
packages/shared/src/editor-schema/export-fallbacks.test.ts
Normal file
49
packages/shared/src/editor-schema/export-fallbacks.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
49
packages/shared/src/editor-schema/export-fallbacks.ts
Normal file
49
packages/shared/src/editor-schema/export-fallbacks.ts
Normal 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];
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user