Complete section-style plugins: CSS gate, injection, picker, export (#75)
All checks were successful
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m9s
CD / Deploy to Test (push) Successful in 11s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 3m57s
CI / Import/export fidelity gate (push) Successful in 43s

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-<pluginId>-<styleId>
  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; "</style" is rejected as a breakout vector for inlined
  embedding. Hostile fixtures from the acceptance list are pinned in
  plugin-css.test.ts.
- Web: usePondPlugins loads the pond's active plugins once per visit;
  SectionStyleSheets links each active style plugin's immutable
  styles.css; SectionStyleMenu (toolbar) wraps/restyles/unwraps with a
  picker fed from the plugins' i18n titles. Sections show a faint dashed
  hint while editing so unstyled (plugin-disabled) sections stay findable.
- PDF export: PluginsService.sectionStyleCssForPond inlines the pond's
  active section-style CSS into the Gotenberg HTML, so styled sections
  survive the network-isolated render; covered in export.service.db.test.
- Reference plugin packages/plugins/section-styles-basic (callout, info,
  warning, colored-box; theme-neutral semi-transparent backgrounds), a
  workspace package whose tests validate it against the SDK schema and
  whose real files run through the api install gate.
- e2e section-styles.spec.ts: install → wrap → computed background in edit
  and read mode → unwrap → neutral fallback after disabling the plugin.

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 11:43:36 +02:00
parent 784f21d805
commit e32f961047
29 changed files with 1043 additions and 13 deletions

View File

@ -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<void> {
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',
'<div class="dt-section dt-style-section-styles-basic-callout"><p>boxed</p></div>',
);
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', '<p>x</p>');

View File

@ -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({

View File

@ -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,

View File

@ -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 `</style>`).
* 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 ?? ''}
</style>
</head>
<body>

View File

@ -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 </style> breakout, even hidden in a comment', () => {
expect(() => assertSafeCss('.a { color: red } /* </style><script> */')).toThrow(
PluginPackageError,
);
});
it('sees through comments hiding a rejected construct', () => {
expect(() => assertSafeCss('@imp/**/ort "x.css";')).not.toThrow();
expect(() => assertSafeCss('/* ok */ @import "x.css";')).toThrow(PluginPackageError);
});
});
describe('assertSafeSectionCss — hostile fixtures (issue #75 acceptance)', () => {
const scoped = (body: string) => `.${sectionStyleScopeClass(PLUGIN, 'callout')} { ${body} }`;
it('rejects an external url() even inside a properly scoped rule', () => {
expectUnsafe(
() => assertSafeSectionCss(scoped('background: url(https://evil.test/x)'), PLUGIN, STYLES),
'external URL',
);
});
it('rejects @import regardless of scoping', () => {
expectUnsafe(
() => assertSafeSectionCss(`@import "x.css";\n${scoped('color: red')}`, PLUGIN, STYLES),
'@import',
);
});
it('rejects a position:fixed overlay', () => {
expectUnsafe(
() =>
assertSafeSectionCss(
scoped('position: fixed; inset: 0; z-index: 9999; background: white'),
PLUGIN,
STYLES,
),
'position',
);
});
it.each(['absolute', 'sticky', 'var(--x)', 'inherit'])(
'rejects position values that can leave the flow: %s',
(value) => {
expectUnsafe(
() => assertSafeSectionCss(scoped(`position: ${value}`), PLUGIN, STYLES),
'position',
);
},
);
it('allows position: static and relative', () => {
assertSafeSectionCss(scoped('position: relative'), PLUGIN, STYLES);
assertSafeSectionCss(scoped('position: static'), PLUGIN, STYLES);
});
it('does not mistake background-position for position', () => {
assertSafeSectionCss(scoped('background-position: center top'), PLUGIN, STYLES);
});
it('rejects an unscoped selector', () => {
expectUnsafe(() => assertSafeSectionCss('body { display: none }', PLUGIN, STYLES), 'dt-style');
});
it('rejects a selector that merely contains the scope class', () => {
expectUnsafe(
() =>
assertSafeSectionCss(
`main .${sectionStyleScopeClass(PLUGIN, 'callout')} { color: red }`,
PLUGIN,
STYLES,
),
'dt-style',
);
});
it("rejects another plugin's scope class", () => {
expectUnsafe(
() => assertSafeSectionCss('.dt-style-other-plugin-callout { color: red }', PLUGIN, STYLES),
'dt-style',
);
});
it('rejects an undeclared styleId, including prefix look-alikes', () => {
expectUnsafe(
() =>
assertSafeSectionCss(`.${sectionStyleScopeClass(PLUGIN, 'warning')} {}`, PLUGIN, STYLES),
'dt-style',
);
expectUnsafe(
() =>
assertSafeSectionCss(
`.${sectionStyleScopeClass(PLUGIN, 'callout-two')} {}`,
PLUGIN,
STYLES,
),
'dt-style',
);
});
it('rejects an unscoped selector hidden in a selector list', () => {
expectUnsafe(
() =>
assertSafeSectionCss(
`.${sectionStyleScopeClass(PLUGIN, 'callout')}, body { color: red }`,
PLUGIN,
STYLES,
),
'dt-style',
);
});
it('enforces the scope inside grouping at-rules', () => {
expectUnsafe(
() =>
assertSafeSectionCss('@media (min-width: 600px) { body { color: red } }', PLUGIN, STYLES),
'dt-style',
);
assertSafeSectionCss(`@media (min-width: 600px) { ${scoped('color: red')} }`, PLUGIN, STYLES);
});
it('rejects at-rules outside the whitelist', () => {
expectUnsafe(
() => assertSafeSectionCss('@property --x { syntax: "*"; inherits: false }', PLUGIN, STYLES),
'@property',
);
expectUnsafe(() => assertSafeSectionCss('@layer base;', PLUGIN, STYLES), 'outside a rule');
});
it('rejects declarations outside any rule', () => {
expectUnsafe(
() => assertSafeSectionCss('color: red; display: none;', PLUGIN, STYLES),
'outside a rule',
);
});
});
describe('assertSafeSectionCss — accepted stylesheet shapes', () => {
it('accepts a realistic multi-style stylesheet', () => {
assertSafeSectionCss(
`
/* callout box */
.dt-style-${PLUGIN}-callout {
background: #fdf6e3;
border-left: 4px solid #b58900;
padding: 0.75rem 1rem;
position: relative;
}
.dt-style-${PLUGIN}-callout > :first-child { margin-top: 0; }
.dt-style-${PLUGIN}-callout h2,
.dt-style-${PLUGIN}-callout h3 { color: #b58900; }
.dt-style-${PLUGIN}-info { background: url('./assets/info.svg') no-repeat 0.5rem 0.5rem; }
@supports (backdrop-filter: blur(1px)) {
.dt-style-${PLUGIN}-info { backdrop-filter: blur(1px); }
}
@font-face { font-family: X; src: url('./assets/x.woff2'); }
@keyframes dt-style-pulse { from { opacity: 0.8 } to { opacity: 1 } }
`,
PLUGIN,
STYLES,
);
});
it('treats nested rule bodies as opaque (CSS nesting cannot escape the scope)', () => {
assertSafeSectionCss(
`.dt-style-${PLUGIN}-callout { color: #234; & h2 { color: #345; } }`,
PLUGIN,
STYLES,
);
});
it('requires at least one declared style', () => {
expectUnsafe(
() => assertSafeSectionCss('.dt-style-x-y { color: red }', PLUGIN, []),
'at least one',
);
});
});

View File

@ -36,6 +36,13 @@ function isExternalUrl(target: string): boolean {
* forbidden construct; returns normally when it is safe to store and serve.
*/
export function assertSafeCss(css: string): void {
// Checked on the raw text: a closing style tag has no meaning in CSS, but
// would break out of a `<style>` element when the sheet is inlined into
// export HTML (#75) — comments could hide it from the stripped source.
if (/<\/style/i.test(css)) {
throw new PluginPackageError('plugin_css_unsafe', 'CSS may not contain "</style"');
}
const source = stripComments(css);
if (/@import\b/i.test(source)) {
@ -55,3 +62,140 @@ export function assertSafeCss(css: string): void {
}
}
}
/** The class a section styled by `styleId` of `pluginId` carries (shared
* `editor-schema/html.ts` renders it; plugin-architecture.md §Kinds). */
export function sectionStyleScopeClass(pluginId: string, styleId: string): string {
return `dt-style-${pluginId}-${styleId}`;
}
/** At-rules whose block contains ordinary rules recurse and enforce the
* selector scope inside them too. */
const GROUPING_AT_RULES = new Set(['media', 'supports', 'container', 'layer']);
/** At-rules whose block contains declarations or keyframe steps, never
* selectors that could reach outside the scope their bodies are skipped
* (the declaration gates still see them, they run on the whole source). */
const OPAQUE_AT_RULES = new Set(['font-face', 'keyframes']);
function unsafe(message: string): PluginPackageError {
return new PluginPackageError('plugin_css_unsafe', message);
}
/** Finds the `}` closing the block that starts at `open` (css[open] === '{'),
* ignoring braces inside quoted strings. */
function findBlockEnd(css: string, open: number): number {
let depth = 0;
for (let i = open; i < css.length; i += 1) {
const char = css[i];
if (char === '"' || char === "'") {
i = css.indexOf(char, i + 1);
if (i === -1) throw unsafe('CSS ends inside a string');
} else if (char === '{') {
depth += 1;
} else if (char === '}') {
depth -= 1;
if (depth === 0) return i;
}
}
throw unsafe('CSS ends inside an unclosed block');
}
/** Splits a rule prelude on top-level commas (commas inside `(...)`/`[...]`,
* e.g. in `:is(...)`, do not separate selectors). */
function splitSelectors(prelude: string): string[] {
const selectors: string[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < prelude.length; i += 1) {
const char = prelude[i];
if (char === '(' || char === '[') depth += 1;
else if (char === ')' || char === ']') depth -= 1;
else if (char === ',' && depth === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors.map((s) => s.trim()).filter((s) => s.length > 0);
}
/** Enforces that every selector between `from` and `to` starts with one of the
* allowed scope classes. Nested rules inside a scoped rule's block are safe by
* construction (CSS nesting desugars with `:is(<parent>)`, which cannot match
* outside the parent), so rule bodies are skipped as opaque. */
function assertRulesScoped(css: string, from: number, to: number, scopePattern: RegExp): void {
let i = from;
while (i < to) {
const char = css[i];
if (char === undefined || /\s/.test(char)) {
i += 1;
continue;
}
const preludeEnd = css.indexOf('{', i);
if (preludeEnd === -1 || preludeEnd >= to) {
const trailing = css.slice(i, to).trim();
if (trailing.length > 0) throw unsafe(`CSS has content outside a rule: "${trailing}"`);
return;
}
// A `;` before the first `{` means a block-less statement at-rule
// (`@layer a, b;`, `@charset …;`) or a stray declaration — never allowed.
const statementEnd = css.indexOf(';', i);
if (statementEnd !== -1 && statementEnd < preludeEnd) {
throw unsafe(`CSS has content outside a rule: "${css.slice(i, statementEnd).trim()}"`);
}
const prelude = css.slice(i, preludeEnd).trim();
const blockEnd = findBlockEnd(css, preludeEnd);
if (prelude.startsWith('@')) {
const name = (/^@([a-z-]+)/i.exec(prelude)?.[1] ?? '').toLowerCase();
if (GROUPING_AT_RULES.has(name)) {
assertRulesScoped(css, preludeEnd + 1, blockEnd, scopePattern);
} else if (!OPAQUE_AT_RULES.has(name)) {
throw unsafe(`CSS may not use @${name}`);
}
} else {
for (const selector of splitSelectors(prelude)) {
if (!scopePattern.test(selector)) {
throw unsafe(
`every selector must start with the plugin's own .dt-style-… class: "${selector}"`,
);
}
}
}
i = blockEnd + 1;
}
}
/**
* Validation gate for a `section_style` plugin's `styles.css` (issue #75, ADR
* 0008): on top of {@link assertSafeCss}, every rule must be scoped under one
* of the plugin's own `.dt-style-<pluginId>-<styleId>` classes (so its CSS can
* never restyle anything outside sections that opted into it), and the
* stylesheet may not position elements out of the content flow (a
* `position: fixed` rule could overlay the whole app with a click-catcher).
*/
export function assertSafeSectionCss(css: string, pluginId: string, styleIds: string[]): void {
assertSafeCss(css);
const source = stripComments(css);
// `static` and `relative` keep the element in (or tied to) its flow slot;
// everything else — fixed/absolute/sticky, but also indirections like
// var()/inherit that could resolve to them — is rejected.
// The lookbehind keeps `background-position: center` from matching.
for (const match of source.matchAll(/(?<![\w-])position\s*:([^;}]*)/gi)) {
const value = (match[1] ?? '').trim();
if (!/^(static|relative)$/i.test(value)) {
throw unsafe('CSS may only use position: static or relative');
}
}
if (styleIds.length === 0) {
throw unsafe('a section_style plugin must declare at least one sectionStyle');
}
const classes = styleIds.map((styleId) => sectionStyleScopeClass(pluginId, styleId));
// The trailing guard keeps `.…-callout` from also matching `.…-callout-two`
// when only `callout` is declared.
const scopePattern = new RegExp(`^\\.(${classes.join('|')})(?![a-z0-9-])`);
assertRulesScoped(source, 0, source.length, scopePattern);
}

View File

@ -1,3 +1,6 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { zipSync } from 'fflate';
import { describe, expect, it } from 'vitest';
@ -57,8 +60,22 @@ describe('PluginPackageService.parse — valid packages', () => {
expect(result.files.has('plugin.js')).toBe(true);
});
it('accepts the shipped section-styles-basic reference plugin', () => {
const pluginDir = join(__dirname, '../../../../packages/plugins/section-styles-basic');
const result = service.parse(
zip({
'manifest.json': readFileSync(join(pluginDir, 'manifest.json')),
'styles.css': readFileSync(join(pluginDir, 'styles.css')),
}),
);
expect(result.manifest.id).toBe('section-styles-basic');
expect(result.manifest.extensionPoints).toHaveLength(4);
});
it('accepts a section-style plugin with a stylesheet', () => {
const css = '.note { background: #eef; } .icon { background: url("./icon.png"); }';
const css =
'.dt-style-callouts-note { background: #eef; } ' +
'.dt-style-callouts-note h2 { background: url("./icon.png"); }';
const result = service.parse(manifestZip(styleManifest, { 'styles.css': enc(css) }));
expect(result.manifest.kind).toBe('section_style');
expect(result.files.has('styles.css')).toBe(true);
@ -111,7 +128,16 @@ describe('PluginPackageService.parse — each invalid class', () => {
it('rejects a stylesheet fetching an external url', () => {
expectReject(
manifestZip(styleManifest, {
'styles.css': enc('.n { background: url(https://evil.test/a) }'),
'styles.css': enc('.dt-style-callouts-note { background: url(https://evil.test/a) }'),
}),
'plugin_css_unsafe',
);
});
it('rejects a section-style stylesheet with a rule outside its scope', () => {
expectReject(
manifestZip(styleManifest, {
'styles.css': enc('.dt-style-callouts-note { color: #234; } body { display: none }'),
}),
'plugin_css_unsafe',
);

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { checkApiVersion, validateManifest, type PluginManifest } from '@dorfteich/plugin-sdk';
import { unzipSync } from 'fflate';
import { assertSafeCss } from './plugin-css';
import { assertSafeCss, assertSafeSectionCss } from './plugin-css';
import {
CODE_BUNDLE_FILE,
MANIFEST_FILE,
@ -114,11 +114,21 @@ export class PluginPackageService {
`A style plugin must include ${STYLES_FILE}`,
);
}
// Whenever a stylesheet is present (required for style plugins, optional for
// code plugins), it must pass the sanitation gate.
// Whenever a stylesheet is present it must pass the sanitation gate. A
// section-style plugin's CSS is served into the *host* page (not a sandbox
// frame), so it additionally must scope every rule under its own
// `.dt-style-…` classes (issue #75).
const styles = files.get(STYLES_FILE);
if (styles) {
assertSafeCss(new TextDecoder().decode(styles));
const css = new TextDecoder().decode(styles);
if (manifest.kind === 'section_style') {
const styleIds = manifest.extensionPoints
.filter((point) => point.type === 'sectionStyle')
.map((point) => point.id);
assertSafeSectionCss(css, manifest.id, styleIds);
} else {
assertSafeCss(css);
}
}
}
}

View File

@ -1,5 +1,5 @@
import { createReadStream } from 'node:fs';
import { access, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import { access, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { dirname, join, relative, resolve } from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
@ -104,4 +104,10 @@ export class PluginStorageService {
createAssetReadStream(fullPath: string): Readable {
return createReadStream(fullPath);
}
/** Reads a (small, text) asset in full e.g. a style plugin's `styles.css`
* for inlining into export HTML (#75). */
readAsset(fullPath: string): Promise<string> {
return readFile(fullPath, 'utf8');
}
}

View File

@ -9,7 +9,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginPackageError } from './plugin.constants';
import { PluginPackageError, STYLES_FILE } from './plugin.constants';
const DB_MODE_TO_VIEW: Record<DbPluginMode, PluginInstanceMode> = {
DISABLED: 'disabled',
@ -165,6 +165,25 @@ export class PluginsService {
.map((p) => this.toView(p));
}
/**
* The concatenated stylesheets of the pond's active `section_style` plugins
* (issue #75), for inlining into self-contained renders (PDF export HTML).
* Every stylesheet passed the install gate scoped selectors, no external
* fetches, no `</style>` breakout so embedding it verbatim is safe. A
* missing file (e.g. a volume restored without one version dir) degrades to
* neutral sections rather than failing the caller.
*/
async sectionStyleCssForPond(pondId: string): Promise<string> {
const parts: string[] = [];
for (const plugin of await this.listForPond(pondId)) {
if (plugin.kind !== 'section_style') continue;
const path = this.storage.assetPath(plugin.id, plugin.version, STYLES_FILE);
if (!path || !(await this.storage.assetExists(path))) continue;
parts.push(`/* ${plugin.id}@${plugin.version} */\n${await this.storage.readAsset(path)}`);
}
return parts.join('\n');
}
/**
* The optional plugins a Pond Admin may toggle for one pond (#72), each with
* this pond's current on/off state (default off activation is opt-in).

View File

@ -0,0 +1,123 @@
import { strToU8, zipSync } from 'fflate';
import { expect, test } from '@playwright/test';
import type { BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Section-style plugins end to end (issue #75): install a `section_style`
* fixture plugin, wrap editor content in one of its styles, and verify the
* scoped CSS actually applies (computed background) in edit and read mode;
* unwrap restores plain content; a disabled plugin leaves the section intact
* but neutral. Selectors are language-neutral (classes, not labels) the UI
* language follows the user's locale.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const PLUGIN_ID = 'e2e-section-styles';
// An unmistakable probe color no app style uses.
const BOXED_BACKGROUND = 'rgb(4, 5, 6)';
function sectionStyleZip(): Buffer {
const manifest = {
id: PLUGIN_ID,
name: 'E2E Section Styles',
version: '1.0.0',
apiVersion: '1',
kind: 'section_style',
extensionPoints: [
{ type: 'sectionStyle', id: 'boxed', title: { de: 'E2E-Box', en: 'E2E box' } },
],
license: 'MIT',
};
const css = `.dt-style-${PLUGIN_ID}-boxed { background: ${BOXED_BACKGROUND}; padding: 4px; }`;
return Buffer.from(
zipSync({ 'manifest.json': strToU8(JSON.stringify(manifest)), 'styles.css': strToU8(css) }),
);
}
async function installAsRequired(context: BrowserContext): Promise<void> {
// Idempotent per run: demote + remove any previous installation.
await context.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, {
data: { mode: 'disabled' },
});
await context.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`);
const installed = await context.request.post('/api/v1/admin/plugins', {
multipart: {
file: { name: `${PLUGIN_ID}.zip`, mimeType: 'application/zip', buffer: sectionStyleZip() },
},
});
expect(installed.status(), await installed.text()).toBe(201);
const mode = await context.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, {
data: { mode: 'required' },
});
expect(mode.status(), await mode.text()).toBe(200);
}
async function createPage(
context: BrowserContext,
title: string,
): Promise<{ pondSlug: string; pageSlug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title },
});
const page = await created.json();
return { pondSlug: pond.slug, pageSlug: page.slug };
}
test('wrap, restyle in read mode, unwrap, and neutral fallback when disabled', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-admin');
await installAsRequired(context);
const { pondSlug, pageSlug } = await createPage(context, `E2E Sections ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
const content = page.locator('.ProseMirror');
await expect(content).toHaveAttribute('contenteditable', 'true');
await content.click();
await page.keyboard.type('Boxed content');
// Wrap the paragraph in the plugin's style via the toolbar picker.
const picker = page.locator('.editor-toolbar__section-select');
await picker.selectOption(`${PLUGIN_ID}/boxed`);
const section = content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`);
await expect(section).toContainText('Boxed content');
// The plugin stylesheet is linked and its scoped rule applies.
await expect(section).toHaveCSS('background-color', BOXED_BACKGROUND);
await expect(picker).toHaveValue(`${PLUGIN_ID}/boxed`);
// Read mode renders the same styled section.
await page.getByRole('button', { name: /view|ansicht|lesen/i }).click();
await expect(section).toHaveCSS('background-color', BOXED_BACKGROUND);
// Unwrap leaves the text in place, without the section wrapper.
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await content.locator('p', { hasText: 'Boxed content' }).click();
await page.locator('.editor-toolbar__section-menu button').click();
await expect(content).toContainText('Boxed content');
await expect(section).toHaveCount(0);
// Re-wrap, then disable the plugin: the section node survives with neutral
// styling (content intact, class matches no stylesheet).
await picker.selectOption(`${PLUGIN_ID}/boxed`);
await expect(section).toHaveCSS('background-color', BOXED_BACKGROUND);
const disabled = await context.request.patch(`/api/v1/admin/plugins/${PLUGIN_ID}/mode`, {
data: { mode: 'disabled' },
});
expect(disabled.status()).toBe(200);
await page.reload();
await expect(content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`)).toContainText(
'Boxed content',
);
await expect(content.locator(`.dt-section.dt-style-${PLUGIN_ID}-boxed`)).not.toHaveCSS(
'background-color',
BOXED_BACKGROUND,
);
await context.request.delete(`/api/v1/admin/plugins/${PLUGIN_ID}`);
});

View File

@ -0,0 +1,92 @@
import type { Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { useTranslation } from 'react-i18next';
import type { SectionStyleOption } from '../plugins/use-pond-plugins';
/** The option's label in the UI language, falling back through English to the
* raw id (a manifest always carries de and en, so the id is a last resort). */
function optionLabel(option: SectionStyleOption, language: string): string {
const base = language.split('-')[0] ?? language;
return option.title[base] ?? option.title.en ?? option.styleId;
}
/**
* Toolbar control for styled sections (issue #75): a picker over the section
* styles the pond's active `section_style` plugins declare. Selecting a style
* wraps the selection (or restyles the surrounding section); the dedicated
* button unwraps. Hidden entirely when no style plugin is active the
* feature only exists where an admin turned a plugin on.
*/
export function SectionStyleMenu({
editor,
options,
}: {
editor: Editor;
options: SectionStyleOption[];
}): React.JSX.Element | null {
const { t, i18n } = useTranslation('editor');
const active = useEditorState({
editor,
selector: ({ editor: e }) => {
if (!e.isActive('section')) return null;
const attrs = e.getAttributes('section');
return { pluginId: String(attrs.pluginId ?? ''), styleId: String(attrs.styleId ?? '') };
},
});
if (options.length === 0) return null;
const activeKey = active ? `${active.pluginId}/${active.styleId}` : '';
function applyStyle(key: string): void {
const [pluginId, styleId] = key.split('/');
if (!pluginId || !styleId) return;
const chain = editor.chain().focus();
if (active) {
chain.updateAttributes('section', { pluginId, styleId }).run();
} else {
chain.wrapInSection({ pluginId, styleId }).run();
}
}
return (
<div className="editor-toolbar__group editor-toolbar__section-menu">
<label className="editor-toolbar__section-label">
<span className="visually-hidden">{t('toolbar.section.label')}</span>
<select
className="editor-toolbar__section-select"
title={t('toolbar.section.label')}
value={activeKey}
// Keep the editor selection: without this the select steals focus
// and the wrap applies to a collapsed selection at the wrong spot.
onMouseDown={(event) => event.stopPropagation()}
onChange={(event) => applyStyle(event.target.value)}
>
<option value="" disabled>
{t('toolbar.section.none')}
</option>
{options.map((option) => {
const key = `${option.pluginId}/${option.styleId}`;
return (
<option key={key} value={key}>
{optionLabel(option, i18n.language)}
</option>
);
})}
</select>
</label>
<button
type="button"
className="toolbar-button"
title={t('toolbar.section.remove')}
aria-label={t('toolbar.section.remove')}
disabled={!active}
onMouseDown={(event) => event.preventDefault()}
onClick={() => editor.chain().focus().unwrapSection().run()}
>
</button>
</div>
);
}

View File

@ -3,10 +3,16 @@ import { useEditorState } from '@tiptap/react';
import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import type { SectionStyleOption } from '../plugins/use-pond-plugins';
import { LinkMenu } from './LinkMenu';
import { SectionStyleMenu } from './SectionStyleMenu';
interface ToolbarProps {
editor: Editor;
/** Section styles offered by the pond's active plugins (issue #75); the
* section group is omitted while empty. */
sectionStyles?: SectionStyleOption[];
}
function ToolbarButton({
@ -73,7 +79,7 @@ function ImageInsertButton({
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
* column controls stay visible but disabled outside a table, so the
* toolbar's layout and tab order never shift while typing. */
export function Toolbar({ editor }: ToolbarProps): React.JSX.Element {
export function Toolbar({ editor, sectionStyles = [] }: ToolbarProps): React.JSX.Element {
const { t } = useTranslation('editor');
const state = useEditorState({
editor,
@ -205,6 +211,8 @@ export function Toolbar({ editor }: ToolbarProps): React.JSX.Element {
<ImageInsertButton editor={editor} label={t('toolbar.image')} />
</div>
<SectionStyleMenu editor={editor} options={sectionStyles} />
<div className="editor-toolbar__group">
<ToolbarButton
label={t('toolbar.table.insert')}

View File

@ -28,6 +28,8 @@ import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-contex
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
import { recallPage, rememberPage } from '../offline/page-cache';
import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
import { sectionStyleOptions, usePondPlugins } from '../plugins/use-pond-plugins';
// A pond loaded offline (no settings) still renders the vision defaults.
const DEFAULT_POND_FONTS = {
@ -116,6 +118,12 @@ function PageEditor({
editor?.setEditable(canEdit);
}, [editor, canEdit]);
// Active plugins feed the section-style stylesheets (read + edit mode) and
// the toolbar's style picker (#75). Offline the query fails silently and
// sections render with neutral styling — content stays intact.
const pondPlugins = usePondPlugins(page.pondId);
const sectionStyles = useMemo(() => sectionStyleOptions(pondPlugins.data), [pondPlugins.data]);
// Pond pages power wikilink title resolution + the `[[` autocomplete (#46).
const pondPages = useQuery({
queryKey: ['pages', page.pondId],
@ -131,7 +139,8 @@ function PageEditor({
return (
<WikilinkContext.Provider value={wikilinks}>
<div className="editor-shell">
{canEdit && <Toolbar editor={editor} />}
<SectionStyleSheets plugins={pondPlugins.data} />
{canEdit && <Toolbar editor={editor} sectionStyles={sectionStyles} />}
<div className="editor-shell__tools">
<button
type="button"

View File

@ -0,0 +1,25 @@
import type { PluginView } from '@dorfteich/shared';
/**
* Loads the stylesheets of the active `section_style` plugins (issue #75).
* Each plugin's `styles.css` was validated at install time every rule is
* scoped under `.dt-style-<pluginId>-<styleId>` so linking it into the app
* document is safe and only affects sections that opted into a style. Assets
* are version-pinned and immutable, so the browser caches across visits, and
* a plugin update changes the URL (cache-busts) by construction.
*/
export function SectionStyleSheets({
plugins,
}: {
plugins: PluginView[] | undefined;
}): React.JSX.Element {
return (
<>
{(plugins ?? [])
.filter((plugin) => plugin.kind === 'section_style')
.map((plugin) => (
<link key={plugin.id} rel="stylesheet" href={`${plugin.assetBasePath}styles.css`} />
))}
</>
);
}

View File

@ -0,0 +1,39 @@
import { useQuery, type UseQueryResult } from '@tanstack/react-query';
import type { PluginView } from '@dorfteich/shared';
import { apiGet } from '../lib/api';
/**
* The plugins active for a pond (required + optional-enabled; issue #72's
* read surface). Loaded once per pond visit and shared by every consumer
* the section-style stylesheets, the editor's style picker, and (later)
* block/pageTool mounting via the query cache.
*/
export function usePondPlugins(pondId: string | undefined): UseQueryResult<PluginView[]> {
return useQuery({
queryKey: ['pond-plugins', pondId],
queryFn: () => apiGet<PluginView[]>(`/ponds/${pondId}/plugins`),
enabled: Boolean(pondId),
// Activation changes are admin actions and clients pick them up on the
// next page load (ADR 0008) — no need to refetch while editing.
staleTime: 5 * 60 * 1000,
});
}
/** The declared section styles of the active `section_style` plugins,
* flattened for pickers: one entry per style with its i18n title. */
export interface SectionStyleOption {
pluginId: string;
styleId: string;
title: Record<string, string>;
}
export function sectionStyleOptions(plugins: PluginView[] | undefined): SectionStyleOption[] {
return (plugins ?? [])
.filter((plugin) => plugin.kind === 'section_style')
.flatMap((plugin) =>
plugin.extensionPoints
.filter((point) => point.type === 'sectionStyle')
.map((point) => ({ pluginId: plugin.id, styleId: point.id, title: point.title })),
);
}

View File

@ -586,6 +586,30 @@ button {
padding-right: 0;
}
.editor-toolbar__section-select {
height: 1.75rem;
max-width: 12rem;
padding: 0 var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
}
/* A section whose plugin is disabled/removed is an invisible wrapper (its
* dt-style- class matches no stylesheet). While writing, a faint dashed
* hint keeps every section findable, so "unwrap" has a visible target. */
.editor-content .dt-section {
border-radius: var(--radius);
outline: 1px dashed transparent;
outline-offset: 2px;
}
.editor-content:focus-within .dt-section {
outline-color: var(--color-border);
}
.toolbar-button {
display: inline-flex;
align-items: center;

View File

@ -54,6 +54,15 @@ my-plugin.zip
| `code` | `block` | a custom editor block (diagram, embed, …); host registers a ProseMirror node `plugin_block` instance with `pluginId`, `blockType`, `data` attrs | sandboxed iframe per block |
| `code` | `pageTool` | read-only widget rendered in the page tools panel or embedded as a block (TOC, page index, cross-page block embed) | sandboxed iframe |
`styles.css` is served into the host page, so the install gate (#75) enforces
its scoping instead of rewriting it: **every rule must be written under one of
the plugin's own `.dt-style-<pluginId>-<styleId>` classes** (each `<styleId>`
a declared `sectionStyle` extension point; grouping at-rules like `@media`
are checked inside, only `@font-face`/`@keyframes` are exempt). Positioning
out of the content flow (`position` other than `static`/`relative`) is
rejected — a fixed overlay could shadow the whole app. A rule that violates
the contract fails the install with `plugin_css_unsafe`.
## Sandbox runtime
- Each code-plugin surface runs in `<iframe sandbox="allow-scripts">`

View File

@ -22,7 +22,7 @@ export default tseslint.config(
prettier,
{
// Plain-Node maintenance/build scripts (no TypeScript, no bundler).
files: ['scripts/**/*.mjs', 'deploy/**/*.mjs'],
files: ['scripts/**/*.mjs', 'deploy/**/*.mjs', 'packages/plugins/*/build.mjs'],
languageOptions: {
globals: {
console: 'readonly',

View File

@ -0,0 +1,22 @@
// Packs the plugin into the installable ZIP (plugin-architecture.md §Package
// format): dist/section-styles-basic-<version>.zip with manifest.json and
// styles.css at the archive root — ready for the admin upload or the
// `plugins/` dropzone watcher.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { zipSync } from 'fflate';
const root = dirname(fileURLToPath(import.meta.url));
const manifest = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
const archive = zipSync({
'manifest.json': readFileSync(join(root, 'manifest.json')),
'styles.css': readFileSync(join(root, 'styles.css')),
});
mkdirSync(join(root, 'dist'), { recursive: true });
const target = join(root, 'dist', `${manifest.id}-${manifest.version}.zip`);
writeFileSync(target, archive);
console.log(`wrote ${target} (${archive.length} bytes)`);

View File

@ -0,0 +1,30 @@
{
"id": "section-styles-basic",
"name": "Basic section styles",
"version": "1.0.0",
"apiVersion": "1",
"kind": "section_style",
"extensionPoints": [
{
"type": "sectionStyle",
"id": "callout",
"title": { "de": "Hervorhebung", "en": "Callout" }
},
{
"type": "sectionStyle",
"id": "info",
"title": { "de": "Info-Kasten", "en": "Info box" }
},
{
"type": "sectionStyle",
"id": "warning",
"title": { "de": "Warnung", "en": "Warning" }
},
{
"type": "sectionStyle",
"id": "colored-box",
"title": { "de": "Farbige Box", "en": "Colored box" }
}
],
"license": "MIT"
}

View File

@ -0,0 +1,45 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { validateManifest } from '@dorfteich/plugin-sdk';
import { describe, expect, it } from 'vitest';
/**
* Reference-plugin self-check (plugin-architecture.md: reference plugins
* "double as the plugin-SDK integration tests"): the shipped manifest must
* validate against the SDK schema, and the stylesheet must define every
* declared style under its own scope class. The full install gate (CSS
* sanitation and scope enforcement) additionally runs against this real
* package in `apps/api` (plugin-package.service.test.ts).
*/
const manifest: unknown = JSON.parse(readFileSync(join(__dirname, 'manifest.json'), 'utf8'));
const css = readFileSync(join(__dirname, 'styles.css'), 'utf8');
describe('section-styles-basic package', () => {
it('has a valid section_style manifest', () => {
const result = validateManifest(manifest);
expect(result.issues).toEqual([]);
expect(result.manifest?.kind).toBe('section_style');
expect(result.manifest?.extensionPoints.map((p) => p.id)).toEqual([
'callout',
'info',
'warning',
'colored-box',
]);
});
it('titles every style in German and English (ADR 0012)', () => {
const result = validateManifest(manifest);
for (const point of result.manifest?.extensionPoints ?? []) {
expect(point.title.de, point.id).toBeTruthy();
expect(point.title.en, point.id).toBeTruthy();
}
});
it('styles every declared styleId under its scope class', () => {
const result = validateManifest(manifest);
for (const point of result.manifest?.extensionPoints ?? []) {
expect(css).toContain(`.dt-style-section-styles-basic-${point.id}`);
}
});
});

View File

@ -0,0 +1,19 @@
{
"name": "@dorfteich/plugin-section-styles-basic",
"version": "0.0.0",
"private": true,
"description": "Reference section_style plugin: colored callout/box styles proving the declarative plugin path (ADR 0008, issue #75)",
"license": "MIT",
"scripts": {
"build": "node build.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@dorfteich/plugin-sdk": "workspace:*",
"@types/node": "^26.1.0",
"fflate": "^0.8.2",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}

View File

@ -0,0 +1,56 @@
/*
* Basic section styles the declarative reference plugin (issue #75).
*
* Contract (plugin-architecture.md §Kinds, enforced at install): every rule is
* scoped under `.dt-style-section-styles-basic-<styleId>`; no @import, no
* external url(), no positioning out of the content flow. Backgrounds are
* semi-transparent and text inherits the surrounding color, so the styles
* read well on light and dark themes without knowing the host's tokens.
*/
.dt-style-section-styles-basic-callout {
background: rgba(140, 140, 140, 0.12);
border-left: 4px solid rgba(140, 140, 140, 0.9);
border-radius: 0 6px 6px 0;
padding: 0.75rem 1rem;
margin: 0.75rem 0;
}
.dt-style-section-styles-basic-info {
background: rgba(38, 139, 210, 0.12);
border-left: 4px solid rgba(38, 139, 210, 0.9);
border-radius: 0 6px 6px 0;
padding: 0.75rem 1rem;
margin: 0.75rem 0;
}
.dt-style-section-styles-basic-warning {
background: rgba(203, 75, 22, 0.14);
border-left: 4px solid rgba(203, 75, 22, 0.9);
border-radius: 0 6px 6px 0;
padding: 0.75rem 1rem;
margin: 0.75rem 0;
}
.dt-style-section-styles-basic-colored-box {
background: rgba(133, 153, 0, 0.14);
border: 1px solid rgba(133, 153, 0, 0.5);
border-radius: 8px;
padding: 1rem 1.25rem;
margin: 0.75rem 0;
}
/* Sections start/end flush: no stray outer margins on first/last children. */
.dt-style-section-styles-basic-callout > :first-child,
.dt-style-section-styles-basic-info > :first-child,
.dt-style-section-styles-basic-warning > :first-child,
.dt-style-section-styles-basic-colored-box > :first-child {
margin-top: 0;
}
.dt-style-section-styles-basic-callout > :last-child,
.dt-style-section-styles-basic-info > :last-child,
.dt-style-section-styles-basic-warning > :last-child,
.dt-style-section-styles-basic-colored-box > :last-child {
margin-bottom: 0;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"lib": ["ES2022"]
},
"include": ["*.ts"]
}

View File

@ -76,6 +76,11 @@
"deleteRow": "Zeile löschen",
"toggleHeaderRow": "Kopfzeile umschalten",
"deleteTable": "Tabelle löschen"
},
"section": {
"label": "Abschnitts-Stil",
"none": "Kein Abschnitt",
"remove": "Abschnitt auflösen"
}
},
"image": {

View File

@ -76,6 +76,11 @@
"deleteRow": "Delete row",
"toggleHeaderRow": "Toggle header row",
"deleteTable": "Delete table"
},
"section": {
"label": "Section style",
"none": "No section",
"remove": "Unwrap section"
}
},
"image": {

18
pnpm-lock.yaml generated
View File

@ -337,6 +337,24 @@ importers:
specifier: ^3.0.0
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
packages/plugins/section-styles-basic:
devDependencies:
'@dorfteich/plugin-sdk':
specifier: workspace:*
version: link:../../plugin-sdk
'@types/node':
specifier: ^26.1.0
version: 26.1.0
fflate:
specifier: ^0.8.2
version: 0.8.3
typescript:
specifier: ^5.7.0
version: 5.9.3
vitest:
specifier: ^3.0.0
version: 3.2.6(@types/node@26.1.0)(jiti@2.7.0)(jsdom@26.1.0)(terser@5.48.0)(tsx@4.23.0)
packages/shared:
dependencies:
markdown-it:

View File

@ -1,6 +1,9 @@
packages:
- apps/*
- packages/*
# Reference plugins (plugin-architecture.md): each is a workspace package so
# its manifest/CSS checks run with the normal recursive test/typecheck.
- packages/plugins/*
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
allowBuilds:
'@prisma/client': true