From e32f961047526dc89125733be517628dae150789 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 11 Jul 2026 11:43:36 +0200 Subject: [PATCH] Complete section-style plugins: CSS gate, injection, picker, export (#75) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of #75 on top of the section node (2e96173/784f21d): - Install gate for section_style CSS (plugin-css.ts): every rule must be scoped under one of the plugin's own .dt-style-- classes (enforced, not rewritten — grouping at-rules checked inside, @font-face/@keyframes exempt, statement at-rules rejected); positioning out of the content flow (anything but static/relative) is rejected as an overlay vector; " Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 --- .../import-export/export.service.db.test.ts | 56 ++++- apps/api/src/import-export/export.service.ts | 5 + .../src/import-export/import-export.module.ts | 3 +- apps/api/src/import-export/pdf-html.ts | 6 + apps/api/src/plugins/plugin-css.test.ts | 217 ++++++++++++++++++ apps/api/src/plugins/plugin-css.ts | 144 ++++++++++++ .../plugins/plugin-package.service.test.ts | 30 ++- .../api/src/plugins/plugin-package.service.ts | 18 +- .../api/src/plugins/plugin-storage.service.ts | 8 +- apps/api/src/plugins/plugins.service.ts | 21 +- apps/web/e2e/section-styles.spec.ts | 123 ++++++++++ apps/web/src/editor/SectionStyleMenu.tsx | 92 ++++++++ apps/web/src/editor/Toolbar.tsx | 10 +- apps/web/src/pages/PageEditorPage.tsx | 11 +- apps/web/src/plugins/SectionStyleSheets.tsx | 25 ++ apps/web/src/plugins/use-pond-plugins.ts | 39 ++++ apps/web/src/styles/base.css | 24 ++ docs/architecture/plugin-architecture.md | 9 + eslint.config.mjs | 2 +- .../plugins/section-styles-basic/build.mjs | 22 ++ .../section-styles-basic/manifest.json | 30 +++ .../section-styles-basic/manifest.test.ts | 45 ++++ .../plugins/section-styles-basic/package.json | 19 ++ .../plugins/section-styles-basic/styles.css | 56 +++++ .../section-styles-basic/tsconfig.json | 10 + packages/shared/i18n/de/editor.json | 5 + packages/shared/i18n/en/editor.json | 5 + pnpm-lock.yaml | 18 ++ pnpm-workspace.yaml | 3 + 29 files changed, 1043 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/plugins/plugin-css.test.ts create mode 100644 apps/web/e2e/section-styles.spec.ts create mode 100644 apps/web/src/editor/SectionStyleMenu.tsx create mode 100644 apps/web/src/plugins/SectionStyleSheets.tsx create mode 100644 apps/web/src/plugins/use-pond-plugins.ts create mode 100644 packages/plugins/section-styles-basic/build.mjs create mode 100644 packages/plugins/section-styles-basic/manifest.json create mode 100644 packages/plugins/section-styles-basic/manifest.test.ts create mode 100644 packages/plugins/section-styles-basic/package.json create mode 100644 packages/plugins/section-styles-basic/styles.css create mode 100644 packages/plugins/section-styles-basic/tsconfig.json 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 35904e6..f1bc708 100644 --- a/apps/api/src/import-export/export.service.db.test.ts +++ b/apps/api/src/import-export/export.service.db.test.ts @@ -1,12 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; -import { unzipSync } from 'fflate'; +import { unzipSync, zipSync } from 'fflate'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { FileStorageService } from '../files/file-storage.service'; import { FilesService } from '../files/files.service'; +import { PluginsService } from '../plugins/plugins.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; @@ -373,6 +377,56 @@ describe.skipIf(!hasTestDb)('export (e2e, issue #65)', () => { expect((result.body as Buffer).toString('utf8')).toContain('%PDF'); }); + it('inlines active section-style plugin CSS into the PDF html (#75)', async () => { + // Install the real reference plugin and make it active everywhere, so the + // export path exercises the same package users get. + const pluginDir = join(__dirname, '../../../../packages/plugins/section-styles-basic'); + const plugins = app.get(PluginsService); + // A `required` plugin refuses uninstall, and the local dev DB may carry + // state from an aborted earlier run — always demote + remove, both ways. + async function removeIfInstalled(): Promise { + await plugins.setMode('section-styles-basic', 'disabled').catch(() => undefined); + await plugins.uninstall('section-styles-basic').catch(() => undefined); + } + await removeIfInstalled(); + await plugins.install( + Buffer.from( + zipSync({ + 'manifest.json': new Uint8Array(readFileSync(join(pluginDir, 'manifest.json'))), + 'styles.css': new Uint8Array(readFileSync(join(pluginDir, 'styles.css'))), + }), + ), + ); + try { + await plugins.setMode('section-styles-basic', 'required'); + + const slug = await seedPage( + personalPondId, + 'Sectioned Pdf', + 'body', + '

boxed

', + ); + const page = await prisma.page.findFirstOrThrow({ + where: { pondId: personalPondId, slug }, + }); + + await api() + .post(`/api/v1/pages/${page.id}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'pdf' }) + .expect(201); + await worker.drain(); + + // The Gotenberg HTML carries both the section markup and the plugin's + // scoped CSS, so the box renders in the (network-isolated) PDF. + expect(renderer.lastHtml).toContain('dt-style-section-styles-basic-callout"'); + expect(renderer.lastHtml).toContain('.dt-style-section-styles-basic-callout {'); + expect(renderer.lastHtml).toContain('/* section-styles-basic@'); + } finally { + await removeIfInstalled(); + } + }); + 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 3efdc32..5cec25a 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 { PluginsService } from '../plugins/plugins.service'; import { PrismaService } from '../prisma/prisma.service'; import { ConversionJobService } from './conversion-job.service'; @@ -42,6 +43,7 @@ export class ExportService { private readonly permissions: PermissionService, private readonly storage: FileStorageService, private readonly jobs: ConversionJobService, + private readonly plugins: PluginsService, private readonly config: AppConfig, private readonly logger: PinoLogger, ) { @@ -217,6 +219,9 @@ export class ExportService { bodyHtml, fonts, fontFaceCss: await this.fontFaceCss(fonts), + // Styled sections keep their look in the PDF (#75); a pond without + // active style plugins contributes an empty string. + sectionStyleCss: await this.plugins.sectionStyleCssForPond(page.pondId), }); const job = await this.jobs.enqueue({ diff --git a/apps/api/src/import-export/import-export.module.ts b/apps/api/src/import-export/import-export.module.ts index 0bf2928..77bc087 100644 --- a/apps/api/src/import-export/import-export.module.ts +++ b/apps/api/src/import-export/import-export.module.ts @@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { FilesModule } from '../files/files.module'; import { PagesModule } from '../pages/pages.module'; +import { PluginsModule } from '../plugins/plugins.module'; import { SchedulerModule } from '../scheduler/scheduler.module'; import { SchedulerService } from '../scheduler/scheduler.service'; @@ -29,7 +30,7 @@ const EXPORT_PURGE_CADENCE_SECONDS = 60 * 60; * feature exports (#65/#67), and the GDPR account data export (#68). */ @Module({ - imports: [FilesModule, PagesModule, SchedulerModule], + imports: [FilesModule, PagesModule, PluginsModule, SchedulerModule], controllers: [JobsController, ImportController, ExportController, DataExportController], providers: [ ConversionJobService, diff --git a/apps/api/src/import-export/pdf-html.ts b/apps/api/src/import-export/pdf-html.ts index 92b0064..749affa 100644 --- a/apps/api/src/import-export/pdf-html.ts +++ b/apps/api/src/import-export/pdf-html.ts @@ -8,6 +8,11 @@ export interface PdfHtmlParams { fonts: PondFonts; /** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */ fontFaceCss: string; + /** The pond's active section-style plugin CSS (issue #75), already validated + * at install time (scoped selectors, no external fetches, no ``). + * Sections of a disabled plugin render neutrally — their class matches + * nothing. */ + sectionStyleCss?: string; } function escapeHtml(value: string): string { @@ -64,6 +69,7 @@ figure, img, table, pre { page-break-inside: avoid; } .pdf-header { margin-bottom: 1.5rem; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.75rem; } .pdf-header__pond { color: #64748b; font-size: 0.85rem; margin: 0 0 0.25rem; } .pdf-header__title { margin: 0; } +${params.sectionStyleCss ?? ''} diff --git a/apps/api/src/plugins/plugin-css.test.ts b/apps/api/src/plugins/plugin-css.test.ts new file mode 100644 index 0000000..952518b --- /dev/null +++ b/apps/api/src/plugins/plugin-css.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, it } from 'vitest'; + +import { assertSafeCss, assertSafeSectionCss, sectionStyleScopeClass } from './plugin-css'; +import { PluginPackageError } from './plugin.constants'; + +const PLUGIN = 'section-styles-basic'; +const STYLES = ['callout', 'info']; + +function expectUnsafe(run: () => void, messagePart: string): void { + try { + run(); + } catch (error) { + expect(error).toBeInstanceOf(PluginPackageError); + expect((error as PluginPackageError).code).toBe('plugin_css_unsafe'); + expect((error as PluginPackageError).message).toContain(messagePart); + return; + } + throw new Error('expected the CSS to be rejected'); +} + +describe('assertSafeCss', () => { + it('accepts relative and data: urls', () => { + assertSafeCss('.a { background: url("./x.png"), url(data:image/png;base64,AAAA) }'); + }); + + it.each([ + ['@import', '@import "other.css";'], + ['expression()', '.a { width: expression(alert(1)) }'], + ['external URL', '.a { background: url(https://evil.test/beacon) }'], + ['protocol-relative URL', '.a { background: url(//evil.test/beacon) }'], + ['javascript: URL', '.a { background: url(javascript:alert(1)) }'], + ['construct hidden in a string boundary', '.a { background: url( "https://evil.test" ) }'], + ])('rejects %s', (_name, css) => { + expect(() => assertSafeCss(css)).toThrow(PluginPackageError); + }); + + it('rejects a breakout, even hidden in a comment', () => { + expect(() => assertSafeCss('.a { color: red } /*