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
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:
parent
784f21d805
commit
e32f961047
@ -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>');
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
217
apps/api/src/plugins/plugin-css.test.ts
Normal file
217
apps/api/src/plugins/plugin-css.test.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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).
|
||||
|
||||
123
apps/web/e2e/section-styles.spec.ts
Normal file
123
apps/web/e2e/section-styles.spec.ts
Normal 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}`);
|
||||
});
|
||||
92
apps/web/src/editor/SectionStyleMenu.tsx
Normal file
92
apps/web/src/editor/SectionStyleMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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')}
|
||||
|
||||
@ -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"
|
||||
|
||||
25
apps/web/src/plugins/SectionStyleSheets.tsx
Normal file
25
apps/web/src/plugins/SectionStyleSheets.tsx
Normal 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`} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/plugins/use-pond-plugins.ts
Normal file
39
apps/web/src/plugins/use-pond-plugins.ts
Normal 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 })),
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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">`
|
||||
|
||||
@ -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',
|
||||
|
||||
22
packages/plugins/section-styles-basic/build.mjs
Normal file
22
packages/plugins/section-styles-basic/build.mjs
Normal 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)`);
|
||||
30
packages/plugins/section-styles-basic/manifest.json
Normal file
30
packages/plugins/section-styles-basic/manifest.json
Normal 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"
|
||||
}
|
||||
45
packages/plugins/section-styles-basic/manifest.test.ts
Normal file
45
packages/plugins/section-styles-basic/manifest.test.ts
Normal 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
19
packages/plugins/section-styles-basic/package.json
Normal file
19
packages/plugins/section-styles-basic/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
packages/plugins/section-styles-basic/styles.css
Normal file
56
packages/plugins/section-styles-basic/styles.css
Normal 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;
|
||||
}
|
||||
10
packages/plugins/section-styles-basic/tsconfig.json
Normal file
10
packages/plugins/section-styles-basic/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
@ -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": {
|
||||
|
||||
@ -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
18
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user