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 { INestApplication } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { unzipSync } from 'fflate';
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { AuthTokensService } from '../auth/auth-tokens.service';
|
import { AuthTokensService } from '../auth/auth-tokens.service';
|
||||||
import { FileStorageService } from '../files/file-storage.service';
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
|
import { PluginsService } from '../plugins/plugins.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
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');
|
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 () => {
|
it('fails a PDF export when the renderer is down', async () => {
|
||||||
renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded');
|
renderer.failWith = new RenderError('render_failed', false, 'gotenberg exploded');
|
||||||
const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '<p>x</p>');
|
const slug = await seedPage(personalPondId, 'Pdf Fails', 'x', '<p>x</p>');
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { PinoLogger } from 'nestjs-pino';
|
|||||||
import { AppConfig } from '../config/app-config.service';
|
import { AppConfig } from '../config/app-config.service';
|
||||||
import { FileStorageService } from '../files/file-storage.service';
|
import { FileStorageService } from '../files/file-storage.service';
|
||||||
import { PermissionService } from '../permissions/permission.service';
|
import { PermissionService } from '../permissions/permission.service';
|
||||||
|
import { PluginsService } from '../plugins/plugins.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
import { ConversionJobService } from './conversion-job.service';
|
import { ConversionJobService } from './conversion-job.service';
|
||||||
@ -42,6 +43,7 @@ export class ExportService {
|
|||||||
private readonly permissions: PermissionService,
|
private readonly permissions: PermissionService,
|
||||||
private readonly storage: FileStorageService,
|
private readonly storage: FileStorageService,
|
||||||
private readonly jobs: ConversionJobService,
|
private readonly jobs: ConversionJobService,
|
||||||
|
private readonly plugins: PluginsService,
|
||||||
private readonly config: AppConfig,
|
private readonly config: AppConfig,
|
||||||
private readonly logger: PinoLogger,
|
private readonly logger: PinoLogger,
|
||||||
) {
|
) {
|
||||||
@ -217,6 +219,9 @@ export class ExportService {
|
|||||||
bodyHtml,
|
bodyHtml,
|
||||||
fonts,
|
fonts,
|
||||||
fontFaceCss: await this.fontFaceCss(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({
|
const job = await this.jobs.enqueue({
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module, OnModuleInit } from '@nestjs/common';
|
|||||||
|
|
||||||
import { FilesModule } from '../files/files.module';
|
import { FilesModule } from '../files/files.module';
|
||||||
import { PagesModule } from '../pages/pages.module';
|
import { PagesModule } from '../pages/pages.module';
|
||||||
|
import { PluginsModule } from '../plugins/plugins.module';
|
||||||
import { SchedulerModule } from '../scheduler/scheduler.module';
|
import { SchedulerModule } from '../scheduler/scheduler.module';
|
||||||
import { SchedulerService } from '../scheduler/scheduler.service';
|
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).
|
* feature exports (#65/#67), and the GDPR account data export (#68).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FilesModule, PagesModule, SchedulerModule],
|
imports: [FilesModule, PagesModule, PluginsModule, SchedulerModule],
|
||||||
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
controllers: [JobsController, ImportController, ExportController, DataExportController],
|
||||||
providers: [
|
providers: [
|
||||||
ConversionJobService,
|
ConversionJobService,
|
||||||
|
|||||||
@ -8,6 +8,11 @@ export interface PdfHtmlParams {
|
|||||||
fonts: PondFonts;
|
fonts: PondFonts;
|
||||||
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
|
/** Pre-built `@font-face` rules (base64 WOFF2) for the pond's fonts. */
|
||||||
fontFaceCss: string;
|
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 {
|
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 { 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__pond { color: #64748b; font-size: 0.85rem; margin: 0 0 0.25rem; }
|
||||||
.pdf-header__title { margin: 0; }
|
.pdf-header__title { margin: 0; }
|
||||||
|
${params.sectionStyleCss ?? ''}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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.
|
* forbidden construct; returns normally when it is safe to store and serve.
|
||||||
*/
|
*/
|
||||||
export function assertSafeCss(css: string): void {
|
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);
|
const source = stripComments(css);
|
||||||
|
|
||||||
if (/@import\b/i.test(source)) {
|
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 { zipSync } from 'fflate';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@ -57,8 +60,22 @@ describe('PluginPackageService.parse — valid packages', () => {
|
|||||||
expect(result.files.has('plugin.js')).toBe(true);
|
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', () => {
|
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) }));
|
const result = service.parse(manifestZip(styleManifest, { 'styles.css': enc(css) }));
|
||||||
expect(result.manifest.kind).toBe('section_style');
|
expect(result.manifest.kind).toBe('section_style');
|
||||||
expect(result.files.has('styles.css')).toBe(true);
|
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', () => {
|
it('rejects a stylesheet fetching an external url', () => {
|
||||||
expectReject(
|
expectReject(
|
||||||
manifestZip(styleManifest, {
|
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',
|
'plugin_css_unsafe',
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { checkApiVersion, validateManifest, type PluginManifest } from '@dorfteich/plugin-sdk';
|
import { checkApiVersion, validateManifest, type PluginManifest } from '@dorfteich/plugin-sdk';
|
||||||
import { unzipSync } from 'fflate';
|
import { unzipSync } from 'fflate';
|
||||||
|
|
||||||
import { assertSafeCss } from './plugin-css';
|
import { assertSafeCss, assertSafeSectionCss } from './plugin-css';
|
||||||
import {
|
import {
|
||||||
CODE_BUNDLE_FILE,
|
CODE_BUNDLE_FILE,
|
||||||
MANIFEST_FILE,
|
MANIFEST_FILE,
|
||||||
@ -114,11 +114,21 @@ export class PluginPackageService {
|
|||||||
`A style plugin must include ${STYLES_FILE}`,
|
`A style plugin must include ${STYLES_FILE}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Whenever a stylesheet is present (required for style plugins, optional for
|
// Whenever a stylesheet is present it must pass the sanitation gate. A
|
||||||
// code plugins), it must pass the sanitation gate.
|
// 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);
|
const styles = files.get(STYLES_FILE);
|
||||||
if (styles) {
|
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 { 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 { dirname, join, relative, resolve } from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Readable } from 'node:stream';
|
import type { Readable } from 'node:stream';
|
||||||
@ -104,4 +104,10 @@ export class PluginStorageService {
|
|||||||
createAssetReadStream(fullPath: string): Readable {
|
createAssetReadStream(fullPath: string): Readable {
|
||||||
return createReadStream(fullPath);
|
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 { PluginPackageService } from './plugin-package.service';
|
||||||
import { PluginStorageService } from './plugin-storage.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> = {
|
const DB_MODE_TO_VIEW: Record<DbPluginMode, PluginInstanceMode> = {
|
||||||
DISABLED: 'disabled',
|
DISABLED: 'disabled',
|
||||||
@ -165,6 +165,25 @@ export class PluginsService {
|
|||||||
.map((p) => this.toView(p));
|
.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
|
* 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).
|
* 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 { useRef } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { SectionStyleOption } from '../plugins/use-pond-plugins';
|
||||||
|
|
||||||
import { LinkMenu } from './LinkMenu';
|
import { LinkMenu } from './LinkMenu';
|
||||||
|
import { SectionStyleMenu } from './SectionStyleMenu';
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
/** Section styles offered by the pond's active plugins (issue #75); the
|
||||||
|
* section group is omitted while empty. */
|
||||||
|
sectionStyles?: SectionStyleOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToolbarButton({
|
function ToolbarButton({
|
||||||
@ -73,7 +79,7 @@ function ImageInsertButton({
|
|||||||
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
|
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
|
||||||
* column controls stay visible but disabled outside a table, so the
|
* column controls stay visible but disabled outside a table, so the
|
||||||
* toolbar's layout and tab order never shift while typing. */
|
* 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 { t } = useTranslation('editor');
|
||||||
const state = useEditorState({
|
const state = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
@ -205,6 +211,8 @@ export function Toolbar({ editor }: ToolbarProps): React.JSX.Element {
|
|||||||
<ImageInsertButton editor={editor} label={t('toolbar.image')} />
|
<ImageInsertButton editor={editor} label={t('toolbar.image')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SectionStyleMenu editor={editor} options={sectionStyles} />
|
||||||
|
|
||||||
<div className="editor-toolbar__group">
|
<div className="editor-toolbar__group">
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label={t('toolbar.table.insert')}
|
label={t('toolbar.table.insert')}
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-contex
|
|||||||
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
import { useForceSidebarHidden } from '../layout/sidebar-chrome';
|
||||||
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api';
|
||||||
import { recallPage, rememberPage } from '../offline/page-cache';
|
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.
|
// A pond loaded offline (no settings) still renders the vision defaults.
|
||||||
const DEFAULT_POND_FONTS = {
|
const DEFAULT_POND_FONTS = {
|
||||||
@ -116,6 +118,12 @@ function PageEditor({
|
|||||||
editor?.setEditable(canEdit);
|
editor?.setEditable(canEdit);
|
||||||
}, [editor, 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).
|
// Pond pages power wikilink title resolution + the `[[` autocomplete (#46).
|
||||||
const pondPages = useQuery({
|
const pondPages = useQuery({
|
||||||
queryKey: ['pages', page.pondId],
|
queryKey: ['pages', page.pondId],
|
||||||
@ -131,7 +139,8 @@ function PageEditor({
|
|||||||
return (
|
return (
|
||||||
<WikilinkContext.Provider value={wikilinks}>
|
<WikilinkContext.Provider value={wikilinks}>
|
||||||
<div className="editor-shell">
|
<div className="editor-shell">
|
||||||
{canEdit && <Toolbar editor={editor} />}
|
<SectionStyleSheets plugins={pondPlugins.data} />
|
||||||
|
{canEdit && <Toolbar editor={editor} sectionStyles={sectionStyles} />}
|
||||||
<div className="editor-shell__tools">
|
<div className="editor-shell__tools">
|
||||||
<button
|
<button
|
||||||
type="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;
|
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 {
|
.toolbar-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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` | `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 |
|
| `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
|
## Sandbox runtime
|
||||||
|
|
||||||
- Each code-plugin surface runs in `<iframe sandbox="allow-scripts">`
|
- Each code-plugin surface runs in `<iframe sandbox="allow-scripts">`
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export default tseslint.config(
|
|||||||
prettier,
|
prettier,
|
||||||
{
|
{
|
||||||
// Plain-Node maintenance/build scripts (no TypeScript, no bundler).
|
// Plain-Node maintenance/build scripts (no TypeScript, no bundler).
|
||||||
files: ['scripts/**/*.mjs', 'deploy/**/*.mjs'],
|
files: ['scripts/**/*.mjs', 'deploy/**/*.mjs', 'packages/plugins/*/build.mjs'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
console: 'readonly',
|
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",
|
"deleteRow": "Zeile löschen",
|
||||||
"toggleHeaderRow": "Kopfzeile umschalten",
|
"toggleHeaderRow": "Kopfzeile umschalten",
|
||||||
"deleteTable": "Tabelle löschen"
|
"deleteTable": "Tabelle löschen"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"label": "Abschnitts-Stil",
|
||||||
|
"none": "Kein Abschnitt",
|
||||||
|
"remove": "Abschnitt auflösen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
|
|||||||
@ -76,6 +76,11 @@
|
|||||||
"deleteRow": "Delete row",
|
"deleteRow": "Delete row",
|
||||||
"toggleHeaderRow": "Toggle header row",
|
"toggleHeaderRow": "Toggle header row",
|
||||||
"deleteTable": "Delete table"
|
"deleteTable": "Delete table"
|
||||||
|
},
|
||||||
|
"section": {
|
||||||
|
"label": "Section style",
|
||||||
|
"none": "No section",
|
||||||
|
"remove": "Unwrap section"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"image": {
|
"image": {
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -337,6 +337,24 @@ importers:
|
|||||||
specifier: ^3.0.0
|
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)
|
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:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
markdown-it:
|
markdown-it:
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
packages:
|
packages:
|
||||||
- apps/*
|
- apps/*
|
||||||
- packages/*
|
- 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.
|
# Postinstall scripts are opt-in with pnpm; esbuild needs its binary install.
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user